diff --git a/lib/committee/drivers/open_api_3/driver.rb b/lib/committee/drivers/open_api_3/driver.rb index 9ad0656e..803a7b07 100644 --- a/lib/committee/drivers/open_api_3/driver.rb +++ b/lib/committee/drivers/open_api_3/driver.rb @@ -33,6 +33,11 @@ def default_validate_success_only false end + # Whether to deserialize parameters based on OpenAPI 3 style/explode settings + def default_deserialize_parameters + true + end + def name :open_api_3 end diff --git a/lib/committee/errors.rb b/lib/committee/errors.rb index a35227e7..b12d3e16 100644 --- a/lib/committee/errors.rb +++ b/lib/committee/errors.rb @@ -33,4 +33,15 @@ class ReferenceNotFound < Error class OpenAPI3Unsupported < Error end + + class ParameterDeserializationError < InvalidRequest + attr_reader :parameter_name, :style, :raw_value + + def initialize(param_name, style, raw_value, message) + @parameter_name = param_name + @style = style + @raw_value = raw_value + super("Parameter '#{param_name}' (style: #{style}): #{message}") + end + end end diff --git a/lib/committee/schema_validator/open_api_3.rb b/lib/committee/schema_validator/open_api_3.rb index 911d2a38..aae272ac 100644 --- a/lib/committee/schema_validator/open_api_3.rb +++ b/lib/committee/schema_validator/open_api_3.rb @@ -93,6 +93,21 @@ def request_unpack(request) query_param = unpacker.unpack_query_params(request) query_param.merge!(request_param) if request.get? && validator_option.allow_get_body + + if @operation_object && validator_option.deserialize_parameters + deserializer = ParameterDeserializer.new(@operation_object.request_operation) + + query_param = deserializer.deserialize_query_params(query_param) + + path_param = request.env[validator_option.path_hash_key] + path_param = deserializer.deserialize_path_params(path_param) + request.env[validator_option.path_hash_key] = path_param + + headers = request.env[validator_option.headers_key] + headers = deserializer.deserialize_headers(headers) + request.env[validator_option.headers_key] = headers + end + request.env[validator_option.query_hash_key] = query_param end @@ -116,5 +131,6 @@ def copy_coerced_data_to_params(request) require_relative "open_api_3/router" require_relative "open_api_3/operation_wrapper" +require_relative "open_api_3/parameter_deserializer" require_relative "open_api_3/request_validator" require_relative "open_api_3/response_validator" diff --git a/lib/committee/schema_validator/open_api_3/operation_wrapper.rb b/lib/committee/schema_validator/open_api_3/operation_wrapper.rb index a21263f0..f507d5a4 100644 --- a/lib/committee/schema_validator/open_api_3/operation_wrapper.rb +++ b/lib/committee/schema_validator/open_api_3/operation_wrapper.rb @@ -70,12 +70,11 @@ def request_content_types request_operation.operation_object&.request_body&.content&.keys || [] end - private - + # Expose request_operation for parameter deserialization + # @return [OpenAPIParser::RequestOperation] attr_reader :request_operation - # @!attribute [r] request_operation - # @return [OpenAPIParser::RequestOperation] + private # @return [OpenAPIParser::SchemaValidator::Options] def build_openapi_parser_body_option(validator_option) diff --git a/lib/committee/schema_validator/open_api_3/parameter_deserializer.rb b/lib/committee/schema_validator/open_api_3/parameter_deserializer.rb new file mode 100644 index 00000000..c315dd92 --- /dev/null +++ b/lib/committee/schema_validator/open_api_3/parameter_deserializer.rb @@ -0,0 +1,495 @@ +# frozen_string_literal: true + +require 'set' + +module Committee + module SchemaValidator + class OpenAPI3 + # Deserializes request parameters based on OpenAPI 3 parameter style and explode settings + # + # @see https://swagger.io/docs/specification/serialization/ + # @see https://spec.openapis.org/oas/latest.html#parameter-object + class ParameterDeserializer + # @param [OpenAPIParser::RequestOperation] request_operation + def initialize(request_operation) + @request_operation = request_operation + @parameters = request_operation.operation_object.parameters || [] + end + + # Deserialize query parameters + # @param [Hash] raw_params Raw query parameters from Rack + # @return [Hash] Deserialized parameters according to OpenAPI schema + def deserialize_query_params(raw_params) + deserialize_params_by_location(raw_params, 'query') + end + + # Deserialize path parameters + # @param [Hash] raw_params Raw path parameters + # @return [Hash] Deserialized parameters according to OpenAPI schema + def deserialize_path_params(raw_params) + deserialize_params_by_location(raw_params, 'path') + end + + # Deserialize header parameters + # @param [Hash] raw_headers Raw headers + # @return [Hash] Deserialized headers according to OpenAPI schema + def deserialize_headers(raw_headers) + deserialize_params_by_location(raw_headers, 'header') + end + + private + + # Deserialize parameters for a specific location (query, path, header) + # @param [Hash] raw_params Raw parameters + # @param [String] location Parameter location ('query', 'path', 'header') + # @return [Hash] Deserialized parameters + def deserialize_params_by_location(raw_params, location) + return raw_params if raw_params.nil? || raw_params.empty? + + result = Committee::Utils.indifferent_hash + params_for_location = @parameters.select { |p| p.in == location } + + # If no parameters are defined for this location, return raw params as-is + return raw_params if params_for_location.empty? + + # Collect parameter names that will be deserialized + # This includes both the parameter name and any properties (for exploded objects) + deserialized_keys = Set.new + + # Deserialize each parameter defined in the schema + params_for_location.each do |param_def| + value = extract_and_deserialize(param_def, raw_params) + + # Only include non-nil values + if !value.nil? + # For objects, ensure the result is also an indifferent hash + value = convert_to_indifferent_hash(value) if value.is_a?(Hash) + + result[param_def.name] = value + deserialized_keys.add(param_def.name) + + # For form-style exploded objects, mark the property keys as deserialized + if param_def.schema&.type == 'object' && + (param_def.style.nil? || param_def.style == 'form') && + (param_def.explode.nil? || param_def.explode) + param_def.schema.properties&.each_key do |prop_name| + deserialized_keys.add(prop_name.to_s) + end + end + + # For deep object style, mark the bracket-notation keys as deserialized + if param_def.style == 'deepObject' + prefix = "#{param_def.name}[" + raw_params.each_key do |key| + key_str = key.to_s + deserialized_keys.add(key) if key_str.start_with?(prefix) + end + end + end + end + + # Include params not in schema (for additionalProperties and unknown params) + # Only include params that weren't consumed by deserialization + raw_params.each do |key, value| + result[key] = value unless deserialized_keys.include?(key) + end + + result + end + + # Convert a hash to an indifferent hash (supports both string and symbol keys) + # @param [Hash] hash + # @return [Committee::Utils::IndifferentHash] + def convert_to_indifferent_hash(hash) + return hash unless hash.is_a?(Hash) + Committee::Utils.indifferent_hash.merge(hash) + end + + # Extract and deserialize a single parameter + # @param [OpenAPIParser::Schemas::Parameter] param_def Parameter definition + # @param [Hash] raw_params Raw parameters + # @return [Object] Deserialized value + def extract_and_deserialize(param_def, raw_params) + style = param_def.style || default_style(param_def.in) + explode = param_def.explode.nil? ? default_explode(style) : param_def.explode + + case style + when 'form' + deserialize_form_style(param_def, raw_params, explode) + when 'simple' + deserialize_simple_style(param_def, raw_params, explode) + when 'label' + deserialize_label_style(param_def, raw_params, explode) + when 'matrix' + deserialize_matrix_style(param_def, raw_params, explode) + when 'spaceDelimited' + deserialize_space_delimited(param_def, raw_params, explode) + when 'pipeDelimited' + deserialize_pipe_delimited(param_def, raw_params, explode) + when 'deepObject' + deserialize_deep_object(param_def, raw_params) + else + # Unsupported style - return raw value + raw_params[param_def.name] + end + rescue StandardError => e + raise Committee::ParameterDeserializationError.new(param_def.name, style, raw_params[param_def.name], e.message) + end + + # Get default style for a parameter location + # @param [String] location Parameter location + # @return [String] Default style + def default_style(location) + case location + when 'query', 'cookie' + 'form' + when 'path', 'header' + 'simple' + else + 'form' + end + end + + # Get default explode setting for a style + # @param [String] style Parameter style + # @return [Boolean] Default explode value + def default_explode(style) + style == 'form' + end + + # Deserialize form style parameter + # Default for query and cookie parameters + # @param [OpenAPIParser::Schemas::Parameter] param_def + # @param [Hash] raw_params + # @param [Boolean] explode + # @return [Object] Deserialized value + def deserialize_form_style(param_def, raw_params, explode) + param_name = param_def.name + schema = param_def.schema + return nil unless schema + + case schema.type + when 'object' + deserialize_form_object(param_name, schema, raw_params, explode) + when 'array' + deserialize_form_array(param_name, schema, raw_params, explode) + else + # Primitive type - just return the value + raw_params[param_name] + end + end + + # Deserialize form-style object + # @param [String] param_name + # @param [OpenAPIParser::Schemas::Schema] schema + # @param [Hash] raw_params + # @param [Boolean] explode + # @return [Hash, nil] Deserialized object + def deserialize_form_object(param_name, schema, raw_params, explode) + if explode + # explode=true: object properties are separate parameters + # Example: ?role=admin&status=active → { role: "admin", status: "active" } + collect_object_properties(schema, raw_params, param_name) + else + # explode=false: comma-separated key,value pairs + # Example: ?id=role,admin,status,active → { role: "admin", status: "active" } + value = raw_params[param_name] + value ? parse_comma_separated_object(value) : nil + end + end + + # Deserialize form-style array + # @param [String] param_name + # @param [OpenAPIParser::Schemas::Schema] schema + # @param [Hash] raw_params + # @param [Boolean] explode + # @return [Array, nil] Deserialized array + def deserialize_form_array(param_name, schema, raw_params, explode) + value = raw_params[param_name] + return nil unless value + + if explode + # explode=true: Rack already collects ?id=1&id=2 into an array + # Just ensure it's an array + Array(value) + else + # explode=false: comma-separated values + # Example: ?id=1,2,3 → ["1", "2", "3"] + value.is_a?(String) ? value.split(',') : Array(value) + end + end + + # Collect object properties from separate parameters (form explode=true) + # @param [OpenAPIParser::Schemas::Schema] schema + # @param [Hash] raw_params + # @param [String] param_name Original parameter name (not used in exploded form) + # @return [Hash, nil] Collected object properties + def collect_object_properties(schema, raw_params, param_name) + result = Committee::Utils.indifferent_hash + properties = schema.properties || {} + + properties.each do |prop_name, prop_schema| + # Look for property directly in raw_params (exploded form) + if raw_params.key?(prop_name) + result[prop_name] = raw_params[prop_name] + elsif raw_params.key?(prop_name.to_s) + result[prop_name] = raw_params[prop_name.to_s] + end + end + + result.empty? ? nil : result + end + + # Parse comma-separated object (form explode=false) + # Example: "role,admin,status,active" → { "role" => "admin", "status" => "active" } + # @param [String] value Comma-separated string + # @return [Hash] Parsed object + def parse_comma_separated_object(value) + parts = value.split(',') + result = Committee::Utils.indifferent_hash + + # Parts should be in key,value,key,value format + (0...parts.length).step(2) do |i| + break if i + 1 >= parts.length + result[parts[i]] = parts[i + 1] + end + + result + end + + # Deserialize deep object style (query parameters only) + # Example: ?filter[role]=admin&filter[status]=active → { filter: { role: "admin", status: "active" } } + # @param [OpenAPIParser::Schemas::Parameter] param_def + # @param [Hash] raw_params + # @return [Hash, nil] Deserialized object + def deserialize_deep_object(param_def, raw_params) + param_name = param_def.name + prefix = "#{param_name}[" + result = Committee::Utils.indifferent_hash + + raw_params.each do |key, value| + key_str = key.to_s + if key_str.start_with?(prefix) && key_str.end_with?(']') + property_name = key_str[prefix.length...-1] + result[property_name] = value + end + end + + result.empty? ? nil : result + end + + # Deserialize simple style (path and header parameters) + # @param [OpenAPIParser::Schemas::Parameter] param_def + # @param [Hash] raw_params + # @param [Boolean] explode + # @return [Object] Deserialized value + def deserialize_simple_style(param_def, raw_params, explode) + param_name = param_def.name + value = raw_params[param_name] + return nil unless value + + schema = param_def.schema + return value unless schema + + case schema.type + when 'array' + # Both explode true/false use comma separation for simple style + # Example: "1,2,3" → ["1", "2", "3"] + value.is_a?(String) ? value.split(',') : Array(value) + when 'object' + if explode + # explode=true: key=value,key2=value2 + parse_key_value_pairs(value, ',', '=') + else + # explode=false: key,value,key2,value2 + parse_comma_separated_object(value) + end + else + value + end + end + + # Deserialize label style (path parameters) + # @param [OpenAPIParser::Schemas::Parameter] param_def + # @param [Hash] raw_params + # @param [Boolean] explode + # @return [Object] Deserialized value + def deserialize_label_style(param_def, raw_params, explode) + param_name = param_def.name + value = raw_params[param_name] + return nil unless value + + # Remove leading dot + value = value[1..-1] if value.start_with?('.') + + schema = param_def.schema + return value unless schema + + case schema.type + when 'array' + if explode + # explode=true: .3.4.5 → ["3", "4", "5"] + value.split('.') + else + # explode=false: .3,4,5 → ["3", "4", "5"] + value.split(',') + end + when 'object' + if explode + # explode=true: .role=admin.status=active + parse_key_value_pairs(value, '.', '=') + else + # explode=false: .role,admin,status,active + parse_comma_separated_object(value) + end + else + value + end + end + + # Deserialize matrix style (path parameters) + # @param [OpenAPIParser::Schemas::Parameter] param_def + # @param [Hash] raw_params + # @param [Boolean] explode + # @return [Object] Deserialized value + def deserialize_matrix_style(param_def, raw_params, explode) + param_name = param_def.name + value = raw_params[param_name] + return nil unless value + + schema = param_def.schema + return value unless schema + + case schema.type + when 'array' + if explode + # explode=true: ;id=3;id=4 (multiple occurrences) + # Rack should have already collected these into an array + Array(value) + else + # explode=false: ;id=3,4,5 + extract_matrix_array_compact(value, param_name) + end + when 'object' + if explode + # explode=true: ;role=admin;status=active + extract_matrix_object_exploded(value, param_name) + else + # explode=false: ;id=role,admin,status,active + extract_matrix_object_compact(value, param_name) + end + else + # Primitive: ;id=5 + extract_matrix_primitive(value, param_name) + end + end + + # Extract matrix-style array (explode=false) + # @param [String] value Matrix-style string + # @param [String] param_name Parameter name + # @return [Array] Extracted array + def extract_matrix_array_compact(value, param_name) + # ;id=3,4,5 → ["3", "4", "5"] + prefix = ";#{param_name}=" + if value.start_with?(prefix) + value[prefix.length..-1].split(',') + else + [] + end + end + + # Extract matrix-style object (explode=true) + # @param [String] value Matrix-style string + # @param [String] param_name Parameter name + # @return [Hash] Extracted object + def extract_matrix_object_exploded(value, param_name) + # ;role=admin;status=active → { "role" => "admin", "status" => "active" } + result = Committee::Utils.indifferent_hash + pairs = value.split(';').reject(&:empty?) + + pairs.each do |pair| + next unless pair.include?('=') + key, val = pair.split('=', 2) + result[key] = val + end + + result + end + + # Extract matrix-style object (explode=false) + # @param [String] value Matrix-style string + # @param [String] param_name Parameter name + # @return [Hash] Extracted object + def extract_matrix_object_compact(value, param_name) + # ;id=role,admin,status,active → { "role" => "admin", "status" => "active" } + prefix = ";#{param_name}=" + if value.start_with?(prefix) + parse_comma_separated_object(value[prefix.length..-1]) + else + {} + end + end + + # Extract matrix-style primitive value + # @param [String] value Matrix-style string + # @param [String] param_name Parameter name + # @return [String] Extracted value + def extract_matrix_primitive(value, param_name) + # ;id=5 → "5" + prefix = ";#{param_name}=" + if value.start_with?(prefix) + value[prefix.length..-1] + else + value + end + end + + # Deserialize space-delimited style (query parameters, arrays only) + # @param [OpenAPIParser::Schemas::Parameter] param_def + # @param [Hash] raw_params + # @param [Boolean] explode + # @return [Array, nil] Deserialized array + def deserialize_space_delimited(param_def, raw_params, explode) + param_name = param_def.name + value = raw_params[param_name] + return nil unless value + + # Example: "1 2 3" or "1%202%203" → ["1", "2", "3"] + value.is_a?(String) ? value.split(' ') : Array(value) + end + + # Deserialize pipe-delimited style (query parameters, arrays only) + # @param [OpenAPIParser::Schemas::Parameter] param_def + # @param [Hash] raw_params + # @param [Boolean] explode + # @return [Array, nil] Deserialized array + def deserialize_pipe_delimited(param_def, raw_params, explode) + param_name = param_def.name + value = raw_params[param_name] + return nil unless value + + # Example: "1|2|3" → ["1", "2", "3"] + value.is_a?(String) ? value.split('|') : Array(value) + end + + # Parse key-value pairs with custom delimiters + # @param [String] value String containing key-value pairs + # @param [String] pair_delimiter Delimiter between pairs + # @param [String] kv_delimiter Delimiter between key and value + # @return [Hash] Parsed object + def parse_key_value_pairs(value, pair_delimiter, kv_delimiter) + result = Committee::Utils.indifferent_hash + pairs = value.split(pair_delimiter).reject(&:empty?) + + pairs.each do |pair| + next unless pair.include?(kv_delimiter) + key, val = pair.split(kv_delimiter, 2) + result[key] = val + end + + result + end + end + end + end +end diff --git a/lib/committee/schema_validator/option.rb b/lib/committee/schema_validator/option.rb index 0cfdff97..606b2ba7 100644 --- a/lib/committee/schema_validator/option.rb +++ b/lib/committee/schema_validator/option.rb @@ -4,7 +4,7 @@ module Committee module SchemaValidator class Option # Boolean Options - attr_reader :allow_blank_structures, :allow_empty_date_and_datetime, :allow_form_params, :allow_get_body, :allow_query_params, :allow_non_get_query_params, :check_content_type, :check_header, :coerce_date_times, :coerce_form_params, :coerce_path_params, :coerce_query_params, :coerce_recursive, :coerce_response_values, :optimistic_json, :validate_success_only, :parse_response_by_content_type, :parameter_overwrite_by_rails_rule + attr_reader :allow_blank_structures, :allow_empty_date_and_datetime, :allow_form_params, :allow_get_body, :allow_query_params, :allow_non_get_query_params, :check_content_type, :check_header, :coerce_date_times, :coerce_form_params, :coerce_path_params, :coerce_query_params, :coerce_recursive, :coerce_response_values, :deserialize_parameters, :optimistic_json, :validate_success_only, :parse_response_by_content_type, :parameter_overwrite_by_rails_rule # Non-boolean options: attr_reader :headers_key, :params_key, :query_hash_key, :request_body_hash_key, :path_hash_key, :prefix @@ -46,6 +46,9 @@ def initialize(options, schema, schema_type) @coerce_form_params = options.fetch(:coerce_form_params, schema.driver.default_coerce_form_params) @coerce_path_params = options.fetch(:coerce_path_params, schema.driver.default_path_params) @coerce_query_params = options.fetch(:coerce_query_params, schema.driver.default_query_params) + @deserialize_parameters = options.fetch(:deserialize_parameters, + schema.driver.respond_to?(:default_deserialize_parameters) ? + schema.driver.default_deserialize_parameters : false) @validate_success_only = options.fetch(:validate_success_only, schema.driver.default_validate_success_only) end end diff --git a/test/schema_validator/open_api_3/parameter_deserializer_test.rb b/test/schema_validator/open_api_3/parameter_deserializer_test.rb new file mode 100644 index 00000000..fa5cb227 --- /dev/null +++ b/test/schema_validator/open_api_3/parameter_deserializer_test.rb @@ -0,0 +1,457 @@ +# frozen_string_literal: true + +require "test_helper" + +describe Committee::SchemaValidator::OpenAPI3::ParameterDeserializer do + before do + @validator_option = Committee::SchemaValidator::Option.new({}, open_api_3_schema, :open_api_3) + end + + describe 'deserialize_parameters option' do + it 'is enabled by default for OpenAPI 3' do + option = Committee::SchemaValidator::Option.new({}, open_api_3_schema, :open_api_3) + assert_equal true, option.deserialize_parameters + end + + it 'can be disabled' do + option = Committee::SchemaValidator::Option.new({ deserialize_parameters: false }, open_api_3_schema, :open_api_3) + assert_equal false, option.deserialize_parameters + end + end + + describe 'parameter deserialization' do + it 'deserializes form style object with explode=true' do + raw_params = { 'role' => 'admin', 'status' => 'active' } + param = create_param('filter', 'query', 'form', true, 'object', { 'role' => 'string', 'status' => 'string' }) + deserializer = create_deserializer([param]) + result = deserializer.deserialize_query_params(raw_params) + + assert_equal({ 'filter' => { 'role' => 'admin', 'status' => 'active' } }, result) + assert_equal 'admin', result[:filter][:role] + end + + it 'deserializes form style object with explode=false' do + raw_params = { 'filter' => 'role,admin,status,active' } + param = create_param('filter', 'query', 'form', false, 'object', { 'role' => 'string', 'status' => 'string' }) + deserializer = create_deserializer([param]) + result = deserializer.deserialize_query_params(raw_params) + + assert_equal({ 'filter' => { 'role' => 'admin', 'status' => 'active' } }, result) + end + + it 'deserializes form style array with explode=true' do + raw_params = { 'ids' => ['1', '2', '3'] } + param = create_param('ids', 'query', 'form', true, 'array', 'integer') + deserializer = create_deserializer([param]) + result = deserializer.deserialize_query_params(raw_params) + + assert_equal({ 'ids' => ['1', '2', '3'] }, result) + end + + it 'deserializes form style array with explode=false' do + raw_params = { 'ids' => '1,2,3' } + param = create_param('ids', 'query', 'form', false, 'array', 'integer') + deserializer = create_deserializer([param]) + result = deserializer.deserialize_query_params(raw_params) + + assert_equal({ 'ids' => ['1', '2', '3'] }, result) + end + + it 'deserializes deepObject style' do + raw_params = { 'filter[role]' => 'admin', 'filter[status]' => 'active' } + param = create_param('filter', 'query', 'deepObject', true, 'object', { 'role' => 'string', 'status' => 'string' }) + deserializer = create_deserializer([param]) + result = deserializer.deserialize_query_params(raw_params) + + assert_equal({ 'filter' => { 'role' => 'admin', 'status' => 'active' } }, result) + end + + it 'deserializes spaceDelimited style' do + raw_params = { 'ids' => '1 2 3' } + param = create_param('ids', 'query', 'spaceDelimited', false, 'array', 'integer') + deserializer = create_deserializer([param]) + result = deserializer.deserialize_query_params(raw_params) + + assert_equal({ 'ids' => ['1', '2', '3'] }, result) + end + + it 'deserializes pipeDelimited style' do + raw_params = { 'ids' => '1|2|3' } + param = create_param('ids', 'query', 'pipeDelimited', false, 'array', 'integer') + deserializer = create_deserializer([param]) + result = deserializer.deserialize_query_params(raw_params) + + assert_equal({ 'ids' => ['1', '2', '3'] }, result) + end + + it 'deserializes simple style array with explode=false for path params' do + raw_params = { 'ids' => '1,2,3' } + param = create_param('ids', 'path', 'simple', false, 'array', 'integer') + deserializer = create_deserializer([param]) + result = deserializer.deserialize_path_params(raw_params) + + assert_equal({ 'ids' => ['1', '2', '3'] }, result) + end + + it 'deserializes simple style array with explode=true for path params' do + raw_params = { 'ids' => ['1', '2', '3'] } + param = create_param('ids', 'path', 'simple', true, 'array', 'integer') + deserializer = create_deserializer([param]) + result = deserializer.deserialize_path_params(raw_params) + + assert_equal({ 'ids' => ['1', '2', '3'] }, result) + end + + it 'deserializes simple style object with explode=false for path params' do + raw_params = { 'filter' => 'role,admin,status,active' } + param = create_param('filter', 'path', 'simple', false, 'object', { 'role' => 'string', 'status' => 'string' }) + deserializer = create_deserializer([param]) + result = deserializer.deserialize_path_params(raw_params) + + assert_equal({ 'filter' => { 'role' => 'admin', 'status' => 'active' } }, result) + end + + it 'deserializes simple style object with explode=true for path params' do + raw_params = { 'filter' => 'role=admin,status=active' } + param = create_param('filter', 'path', 'simple', true, 'object', { 'role' => 'string', 'status' => 'string' }) + deserializer = create_deserializer([param]) + result = deserializer.deserialize_path_params(raw_params) + + assert_equal({ 'filter' => { 'role' => 'admin', 'status' => 'active' } }, result) + end + + it 'deserializes label style array with explode=false' do + raw_params = { 'ids' => '.1,2,3' } + param = create_param('ids', 'path', 'label', false, 'array', 'integer') + deserializer = create_deserializer([param]) + result = deserializer.deserialize_path_params(raw_params) + + assert_equal({ 'ids' => ['1', '2', '3'] }, result) + end + + it 'deserializes label style array with explode=true' do + raw_params = { 'ids' => '.1.2.3' } + param = create_param('ids', 'path', 'label', true, 'array', 'integer') + deserializer = create_deserializer([param]) + result = deserializer.deserialize_path_params(raw_params) + + assert_equal({ 'ids' => ['1', '2', '3'] }, result) + end + + it 'deserializes label style object with explode=false' do + raw_params = { 'filter' => '.role,admin,status,active' } + param = create_param('filter', 'path', 'label', false, 'object', { 'role' => 'string', 'status' => 'string' }) + deserializer = create_deserializer([param]) + result = deserializer.deserialize_path_params(raw_params) + + assert_equal({ 'filter' => { 'role' => 'admin', 'status' => 'active' } }, result) + end + + it 'deserializes label style object with explode=true' do + raw_params = { 'filter' => '.role=admin.status=active' } + param = create_param('filter', 'path', 'label', true, 'object', { 'role' => 'string', 'status' => 'string' }) + deserializer = create_deserializer([param]) + result = deserializer.deserialize_path_params(raw_params) + + assert_equal({ 'filter' => { 'role' => 'admin', 'status' => 'active' } }, result) + end + + it 'deserializes matrix style array with explode=false' do + raw_params = { 'ids' => ';ids=1,2,3' } + param = create_param('ids', 'path', 'matrix', false, 'array', 'integer') + deserializer = create_deserializer([param]) + result = deserializer.deserialize_path_params(raw_params) + + assert_equal({ 'ids' => ['1', '2', '3'] }, result) + end + + it 'deserializes matrix style array with explode=true' do + raw_params = { 'ids' => ['1', '2', '3'] } + param = create_param('ids', 'path', 'matrix', true, 'array', 'integer') + deserializer = create_deserializer([param]) + result = deserializer.deserialize_path_params(raw_params) + + assert_equal({ 'ids' => ['1', '2', '3'] }, result) + end + + it 'deserializes matrix style object with explode=false' do + raw_params = { 'filter' => ';filter=role,admin,status,active' } + param = create_param('filter', 'path', 'matrix', false, 'object', { 'role' => 'string', 'status' => 'string' }) + deserializer = create_deserializer([param]) + result = deserializer.deserialize_path_params(raw_params) + + assert_equal({ 'filter' => { 'role' => 'admin', 'status' => 'active' } }, result) + end + + it 'deserializes matrix style object with explode=true' do + raw_params = { 'filter' => ';role=admin;status=active' } + param = create_param('filter', 'path', 'matrix', true, 'object', { 'role' => 'string', 'status' => 'string' }) + deserializer = create_deserializer([param]) + result = deserializer.deserialize_path_params(raw_params) + + assert_equal({ 'filter' => { 'role' => 'admin', 'status' => 'active' } }, result) + end + + it 'deserializes matrix style primitive' do + raw_params = { 'id' => ';id=5' } + param = create_param('id', 'path', 'matrix', false, 'string', nil) + deserializer = create_deserializer([param]) + result = deserializer.deserialize_path_params(raw_params) + + assert_equal({ 'id' => '5' }, result) + end + + it 'deserializes headers with simple style' do + raw_params = { 'X-Filter' => 'role,admin,status,active' } + param = create_param('X-Filter', 'header', 'simple', false, 'object', { 'role' => 'string', 'status' => 'string' }) + deserializer = create_deserializer([param]) + result = deserializer.deserialize_headers(raw_params) + + assert_equal({ 'X-Filter' => { 'role' => 'admin', 'status' => 'active' } }, result) + end + + it 'handles empty params gracefully' do + raw_params = {} + param = create_param('filter', 'query', 'form', true, 'object', { 'role' => 'string' }) + deserializer = create_deserializer([param]) + result = deserializer.deserialize_query_params(raw_params) + + assert_equal({}, result) + end + + it 'handles nil param value' do + raw_params = { 'filter' => nil } + param = create_param('filter', 'query', 'form', false, 'object', { 'role' => 'string' }) + deserializer = create_deserializer([param]) + result = deserializer.deserialize_query_params(raw_params) + + assert_equal({ 'filter' => nil }, result) + end + + it 'preserves unknown parameters' do + raw_params = { 'role' => 'admin', 'unknown' => 'value' } + param = create_param('filter', 'query', 'form', true, 'object', { 'role' => 'string' }) + deserializer = create_deserializer([param]) + result = deserializer.deserialize_query_params(raw_params) + + assert_equal 'admin', result[:filter][:role] + assert_equal 'value', result[:unknown] + end + + it 'handles empty object with form style explode=true' do + raw_params = {} + param = create_param('filter', 'query', 'form', true, 'object', { 'role' => 'string', 'status' => 'string' }) + deserializer = create_deserializer([param]) + result = deserializer.deserialize_query_params(raw_params) + + assert_equal({}, result) + end + + it 'returns indifferent hashes' do + raw_params = { 'role' => 'admin' } + param = create_param('filter', 'query', 'form', true, 'object', { 'role' => 'string' }) + deserializer = create_deserializer([param]) + result = deserializer.deserialize_query_params(raw_params) + + assert_equal({ 'role' => 'admin' }, result['filter']) + assert_equal 'admin', result['filter']['role'] + assert_equal({ 'role' => 'admin' }, result[:filter]) + assert_equal 'admin', result[:filter][:role] + end + + it 'handles primitive values with form style' do + raw_params = { 'id' => '123' } + param = create_param('id', 'query', 'form', false, 'string', nil) + deserializer = create_deserializer([param]) + result = deserializer.deserialize_query_params(raw_params) + + assert_equal({ 'id' => '123' }, result) + end + + it 'handles primitive values with simple style' do + raw_params = { 'id' => '123' } + param = create_param('id', 'path', 'simple', false, 'string', nil) + deserializer = create_deserializer([param]) + result = deserializer.deserialize_path_params(raw_params) + + assert_equal({ 'id' => '123' }, result) + end + + it 'handles unsupported style gracefully' do + raw_params = { 'id' => '123' } + param = Struct.new(:name, :in, :style, :explode, :schema, :required).new('id', 'query', 'unsupported', false, create_schema('string', nil), false) + deserializer = create_deserializer([param]) + result = deserializer.deserialize_query_params(raw_params) + + assert_equal({ 'id' => '123' }, result) + end + + it 'handles form array when value is already array' do + raw_params = { 'ids' => ['1', '2', '3'] } + param = create_param('ids', 'query', 'form', false, 'array', 'integer') + deserializer = create_deserializer([param]) + result = deserializer.deserialize_query_params(raw_params) + + assert_equal({ 'ids' => ['1', '2', '3'] }, result) + end + + it 'deserializes label style primitive value' do + raw_params = { 'id' => '.123' } + param = create_param('id', 'path', 'label', false, 'string', nil) + deserializer = create_deserializer([param]) + result = deserializer.deserialize_path_params(raw_params) + + assert_equal({ 'id' => '123' }, result) + end + + it 'handles label style without leading dot' do + raw_params = { 'id' => '123' } + param = create_param('id', 'path', 'label', false, 'string', nil) + deserializer = create_deserializer([param]) + result = deserializer.deserialize_path_params(raw_params) + + assert_equal({ 'id' => '123' }, result) + end + + it 'handles matrix style with missing prefix' do + raw_params = { 'ids' => 'invalid' } + param = create_param('ids', 'path', 'matrix', false, 'array', 'integer') + deserializer = create_deserializer([param]) + result = deserializer.deserialize_path_params(raw_params) + + assert_equal({ 'ids' => [] }, result) + end + + it 'handles matrix style object with missing prefix' do + raw_params = { 'filter' => 'invalid' } + param = create_param('filter', 'path', 'matrix', false, 'object', { 'role' => 'string' }) + deserializer = create_deserializer([param]) + result = deserializer.deserialize_path_params(raw_params) + + assert_equal({ 'filter' => {} }, result) + end + + it 'handles matrix style primitive without prefix' do + raw_params = { 'id' => 'invalid' } + param = create_param('id', 'path', 'matrix', false, 'string', nil) + deserializer = create_deserializer([param]) + result = deserializer.deserialize_path_params(raw_params) + + assert_equal({ 'id' => 'invalid' }, result) + end + + it 'handles space delimited when value is already array' do + raw_params = { 'ids' => ['1', '2', '3'] } + param = create_param('ids', 'query', 'spaceDelimited', false, 'array', 'integer') + deserializer = create_deserializer([param]) + result = deserializer.deserialize_query_params(raw_params) + + assert_equal({ 'ids' => ['1', '2', '3'] }, result) + end + + it 'handles pipe delimited when value is already array' do + raw_params = { 'ids' => ['1', '2', '3'] } + param = create_param('ids', 'query', 'pipeDelimited', false, 'array', 'integer') + deserializer = create_deserializer([param]) + result = deserializer.deserialize_query_params(raw_params) + + assert_equal({ 'ids' => ['1', '2', '3'] }, result) + end + + it 'handles simple array when value is already array' do + raw_params = { 'ids' => ['1', '2', '3'] } + param = create_param('ids', 'path', 'simple', false, 'array', 'integer') + deserializer = create_deserializer([param]) + result = deserializer.deserialize_path_params(raw_params) + + assert_equal({ 'ids' => ['1', '2', '3'] }, result) + end + + it 'handles comma-separated object with odd number of elements' do + raw_params = { 'filter' => 'role,admin,status' } + param = create_param('filter', 'query', 'form', false, 'object', { 'role' => 'string', 'status' => 'string' }) + deserializer = create_deserializer([param]) + result = deserializer.deserialize_query_params(raw_params) + + assert_equal({ 'filter' => { 'role' => 'admin' } }, result) + end + + it 'handles matrix object exploded with malformed pairs' do + raw_params = { 'filter' => ';role=admin;invalid;status=active' } + param = create_param('filter', 'path', 'matrix', true, 'object', { 'role' => 'string', 'status' => 'string' }) + deserializer = create_deserializer([param]) + result = deserializer.deserialize_path_params(raw_params) + + assert_equal({ 'filter' => { 'role' => 'admin', 'status' => 'active' } }, result) + end + + it 'handles parameter without schema in simple style' do + raw_params = { 'id' => '123' } + param = Struct.new(:name, :in, :style, :explode, :schema, :required).new('id', 'path', 'simple', false, nil, false) + deserializer = create_deserializer([param]) + result = deserializer.deserialize_path_params(raw_params) + + assert_equal({ 'id' => '123' }, result) + end + + it 'handles parameter without schema in label style' do + raw_params = { 'id' => '.123' } + param = Struct.new(:name, :in, :style, :explode, :schema, :required).new('id', 'path', 'label', false, nil, false) + deserializer = create_deserializer([param]) + result = deserializer.deserialize_path_params(raw_params) + + assert_equal({ 'id' => '123' }, result) + end + + it 'handles parameter without schema in matrix style' do + raw_params = { 'id' => ';id=123' } + param = Struct.new(:name, :in, :style, :explode, :schema, :required).new('id', 'path', 'matrix', false, nil, false) + deserializer = create_deserializer([param]) + result = deserializer.deserialize_path_params(raw_params) + + assert_equal({ 'id' => ';id=123' }, result) + end + + it 'handles parameter without schema in form style' do + raw_params = { 'id' => '123' } + param = Struct.new(:name, :in, :style, :explode, :schema, :required).new('id', 'query', 'form', false, nil, false) + deserializer = create_deserializer([param]) + result = deserializer.deserialize_query_params(raw_params) + + assert_equal({ 'id' => '123' }, result) + end + + it 'handles form explode with symbol property keys' do + raw_params = { 'role' => 'admin', 'status' => 'active' } + # Create schema with symbol keys for properties + schema = Struct.new(:type, :properties, :items).new('object', { role: Struct.new(:type).new('string'), status: Struct.new(:type).new('string') }, nil) + param = Struct.new(:name, :in, :style, :explode, :schema, :required).new('filter', 'query', 'form', true, schema, false) + deserializer = create_deserializer([param]) + result = deserializer.deserialize_query_params(raw_params) + + assert_equal({ 'filter' => { role: 'admin', status: 'active' } }, result) + end + end + + private + + def create_deserializer(params) + operation = Struct.new(:operation_object).new(Struct.new(:parameters).new(params)) + Committee::SchemaValidator::OpenAPI3::ParameterDeserializer.new(operation) + end + + def create_param(name, location, style, explode, type, properties_or_items) + Struct.new(:name, :in, :style, :explode, :schema, :required).new(name, location, style, explode, create_schema(type, properties_or_items), false) + end + + def create_schema(type, properties_or_items) + if type == 'object' + props = properties_or_items.transform_values { |v| Struct.new(:type).new(v) } + Struct.new(:type, :properties, :items).new(type, props, nil) + elsif type == 'array' + Struct.new(:type, :properties, :items).new(type, nil, Struct.new(:type).new(properties_or_items)) + else + Struct.new(:type, :properties, :items).new(type, nil, nil) + end + end +end