From 0fe93e9edbdef96277c41f2d8ed0af9ef1f1e3e6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Oct 2025 20:43:12 +0000 Subject: [PATCH 1/3] Initial plan From fb6afcde5efe0892c787b02088eccf052de076e3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Oct 2025 20:54:57 +0000 Subject: [PATCH 2/3] Implement multipart encoding support for different content types Co-authored-by: ahx <8669+ahx@users.noreply.github.com> --- lib/openapi_first/builder.rb | 3 ++ lib/openapi_first/request.rb | 8 +++-- lib/openapi_first/request_body_parsers.rb | 33 +++++++++++++++++ lib/openapi_first/request_parser.rb | 16 +++++++-- spec/data/request-body-validation.yaml | 35 +++++++++++++++++++ spec/data/test.csv | 3 ++ .../request_body_validation_spec.rb | 15 ++++++++ 7 files changed, 108 insertions(+), 5 deletions(-) create mode 100644 spec/data/test.csv 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..40907452 100644 --- a/lib/openapi_first/request_body_parsers.rb +++ b/lib/openapi_first/request_body_parsers.rb @@ -46,6 +46,39 @@ def self.call(request) end 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) + end + end + + result[key] = parsed_value + end + end + + def self.parse_content_by_type(content, content_type) + case content_type.downcase + when 'application/json' + begin + JSON.parse(content) + rescue JSON::ParserError + # If JSON parsing fails, return the original content + content + end + else + # For other content types (like text/csv), return as-is for now + content + end + end + def self.unpack_value(value) return value.map { unpack_value(_1) } if value.is_a?(Array) return value unless value.is_a?(Hash) diff --git a/lib/openapi_first/request_parser.rb b/lib/openapi_first/request_parser.rb index baee9a87..a3682a91 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,22 @@ 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 + + if @encoding && @body_parsers.respond_to?(:call_with_encoding) + @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' From c16c31a13945a400b3b7f213fc2697d61bf499a5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Oct 2025 20:59:12 +0000 Subject: [PATCH 3/3] Refactor multipart encoding implementation based on code review Co-authored-by: ahx <8669+ahx@users.noreply.github.com> --- lib/openapi_first/request_body_parsers.rb | 13 ++++++------- lib/openapi_first/request_parser.rb | 3 ++- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/openapi_first/request_body_parsers.rb b/lib/openapi_first/request_body_parsers.rb index 40907452..04fa2a4a 100644 --- a/lib/openapi_first/request_body_parsers.rb +++ b/lib/openapi_first/request_body_parsers.rb @@ -41,9 +41,7 @@ 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) - end + call_with_encoding(request, {}) end def self.call_with_encoding(request, encoding) @@ -56,7 +54,7 @@ def self.call_with_encoding(request, encoding) content_type = part_encoding['contentType'] if content_type && parsed_value.is_a?(String) - parsed_value = parse_content_by_type(parsed_value, content_type) + parsed_value = parse_content_by_type(parsed_value, content_type, key) end end @@ -64,13 +62,14 @@ def self.call_with_encoding(request, encoding) end end - def self.parse_content_by_type(content, content_type) + 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 - # If JSON parsing fails, return the original 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 diff --git a/lib/openapi_first/request_parser.rb b/lib/openapi_first/request_parser.rb index a3682a91..3ad7c51a 100644 --- a/lib/openapi_first/request_parser.rb +++ b/lib/openapi_first/request_parser.rb @@ -41,7 +41,8 @@ def parse(request, route_params:) def parse_body(request) return unless @body_parsers - if @encoding && @body_parsers.respond_to?(:call_with_encoding) + # 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)