diff --git a/lib/openapi_first/builder.rb b/lib/openapi_first/builder.rb index b2cc430a..0623fcb2 100644 --- a/lib/openapi_first/builder.rb +++ b/lib/openapi_first/builder.rb @@ -141,11 +141,13 @@ def build_requests(path:, request_method:, operation_object:, parameters:) configuration: schemer_configuration, after_property_validation: config.hooks[:after_request_body_property_validation] ) + encoding = content_object['encoding']&.resolved || {} Request.new(path:, request_method:, parameters:, operation_object: operation_object.resolved, content_type:, content_schema:, required_body:, + encoding:, key: [path, request_method, content_type].join(':')) end end @@ -156,6 +158,7 @@ def request_without_body(path:, request_method:, parameters:, operation_object:) content_type: nil, content_schema: nil, required_body: false, + encoding: nil, key: [path, request_method, nil].join(':')) end diff --git a/lib/openapi_first/request.rb b/lib/openapi_first/request.rb index cf57bf36..5a6aaf03 100644 --- a/lib/openapi_first/request.rb +++ b/lib/openapi_first/request.rb @@ -12,7 +12,7 @@ module OpenapiFirst class Request # rubocop:disable Metrics/MethodLength def initialize(path:, request_method:, operation_object:, - parameters:, content_type:, content_schema:, required_body:, key:) + parameters:, content_type:, content_schema:, required_body:, key:, encoding: nil) @path = path @request_method = request_method @content_type = content_type @@ -20,12 +20,14 @@ def initialize(path:, request_method:, operation_object:, @operation = operation_object @allow_empty_content = content_type.nil? || required_body == false @key = key + @encoding = encoding @request_parser = RequestParser.new( query_parameters: parameters.query, path_parameters: parameters.path, header_parameters: parameters.header, cookie_parameters: parameters.cookie, - content_type: + content_type:, + encoding: ) @validator = RequestValidator.new( content_schema:, @@ -38,7 +40,7 @@ def initialize(path:, request_method:, operation_object:, end # rubocop:enable Metrics/MethodLength - attr_reader :content_type, :content_schema, :operation, :request_method, :path, :key + attr_reader :content_type, :content_schema, :operation, :request_method, :path, :key, :encoding def allow_empty_content? @allow_empty_content diff --git a/lib/openapi_first/request_body_parsers.rb b/lib/openapi_first/request_body_parsers.rb index 5e665679..04fa2a4a 100644 --- a/lib/openapi_first/request_body_parsers.rb +++ b/lib/openapi_first/request_body_parsers.rb @@ -41,8 +41,40 @@ def self.read_body(request) # The uploaded file should not be read during request validation. module MultipartBodyParser def self.call(request) - request.POST.transform_values do |value| - unpack_value(value) + call_with_encoding(request, {}) + end + + def self.call_with_encoding(request, encoding) + request.POST.each_with_object({}) do |(key, value), result| + parsed_value = unpack_value(value) + + # Apply encoding-specific parsing if specified + if encoding.key?(key) + part_encoding = encoding[key] + content_type = part_encoding['contentType'] + + if content_type && parsed_value.is_a?(String) + parsed_value = parse_content_by_type(parsed_value, content_type, key) + end + end + + result[key] = parsed_value + end + end + + def self.parse_content_by_type(content, content_type, field_name) + case content_type.downcase + when 'application/json' + begin + JSON.parse(content) + rescue JSON::ParserError => e + # Log the parsing failure for debugging, but return original content to maintain compatibility + warn "Warning: Failed to parse JSON for field '#{field_name}': #{e.message}. Returning original content." + content + end + else + # For other content types (like text/csv), return as-is for now + content end end diff --git a/lib/openapi_first/request_parser.rb b/lib/openapi_first/request_parser.rb index baee9a87..3ad7c51a 100644 --- a/lib/openapi_first/request_parser.rb +++ b/lib/openapi_first/request_parser.rb @@ -13,13 +13,15 @@ def initialize( path_parameters:, header_parameters:, cookie_parameters:, - content_type: + content_type:, + encoding: nil ) @query_parser = OpenapiParameters::Query.new(query_parameters) if query_parameters @path_parser = OpenapiParameters::Path.new(path_parameters) if path_parameters @headers_parser = OpenapiParameters::Header.new(header_parameters) if header_parameters @cookies_parser = OpenapiParameters::Cookie.new(cookie_parameters) if cookie_parameters @body_parsers = RequestBodyParsers[content_type] if content_type + @encoding = encoding end attr_reader :query, :path, :headers, :cookies @@ -30,12 +32,23 @@ def parse(request, route_params:) query: parse_query(request.env[Rack::QUERY_STRING]), headers: @headers_parser&.unpack_env(request.env), cookies: @cookies_parser&.unpack(request.env[Rack::HTTP_COOKIE]), - body: @body_parsers&.call(request) + body: parse_body(request) ) end private + def parse_body(request) + return unless @body_parsers + + # Use encoding-aware parsing for multipart requests when encoding is specified + if @encoding && @body_parsers == RequestBodyParsers::MultipartBodyParser + @body_parsers.call_with_encoding(request, @encoding) + else + @body_parsers.call(request) + end + end + def parse_query(query_string) @query_parser&.unpack(query_string) rescue OpenapiParameters::InvalidParameterError diff --git a/spec/data/request-body-validation.yaml b/spec/data/request-body-validation.yaml index deb0474f..530e0393 100644 --- a/spec/data/request-body-validation.yaml +++ b/spec/data/request-body-validation.yaml @@ -208,6 +208,41 @@ paths: responses: "200": description: ok + /multipart-with-encoding: + post: + requestBody: + description: Contains a file and data attributes with different encodings + required: true + content: + multipart/form-data: + schema: + type: object + required: + - data + - file + properties: + data: + type: object + required: + - name + - description + properties: + name: + type: string + description: + type: string + file: + type: string + format: binary + description: Sample file + encoding: + file: + contentType: text/csv + data: + contentType: application/json + responses: + "200": + description: ok components: schemas: Pet: diff --git a/spec/data/test.csv b/spec/data/test.csv new file mode 100644 index 00000000..a437e689 --- /dev/null +++ b/spec/data/test.csv @@ -0,0 +1,3 @@ +name,age,city +John,30,New York +Jane,25,Los Angeles \ No newline at end of file diff --git a/spec/middlewares/request_validation/request_body_validation_spec.rb b/spec/middlewares/request_validation/request_body_validation_spec.rb index fd073cfe..c6de4fba 100644 --- a/spec/middlewares/request_validation/request_body_validation_spec.rb +++ b/spec/middlewares/request_validation/request_body_validation_spec.rb @@ -84,6 +84,21 @@ def fixture_path(name) expect(names).to eq(['Quentin']) end + it 'succeeds with multipart form data with encoding specification' do + csv_file = Rack::Test::UploadedFile.new(fixture_path('test.csv'), 'text/csv') + json_data = { name: 'Test Product', description: 'A sample product' } + + post '/multipart-with-encoding', + 'file' => csv_file, + 'data' => json_data.to_json + + expect(last_response.status).to eq(200), last_response.body + + parsed_body = last_request.env[OpenapiFirst::REQUEST].parsed_body + expect(parsed_body['file']).to eq File.read(fixture_path('test.csv')) + expect(parsed_body['data']).to eq(json_data.transform_keys(&:to_s)) + end + it 'succeeds without optional file upload' do header Rack::CONTENT_TYPE, 'multipart/form-data' post '/multipart-with-file', 'petId' => '12'