diff --git a/CHANGELOG.md b/CHANGELOG.md index 553fecba..95efee87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ # Changelog ## Unreleased - +- Support `encoding[].contentType` on `multipart/form-data` request bodies. Fields whose encoding declares a JSON content type are JSON-parsed before schema validation. Fixes [#398](https://github.com/ahx/openapi_first/issues/398). - Deprecated `TerminalFormatter#format`. Use `#report` instead. - `OpenapiFirst::Test.logger` is now configurable via the setup block: `OpenapiFirst::Test.setup { |test| test.logger = Logger.new($stderr) }`. The logger defaults to `Logger.new($stdout)`. diff --git a/lib/openapi_first/builder.rb b/lib/openapi_first/builder.rb index 952a6aa4..40da7480 100644 --- a/lib/openapi_first/builder.rb +++ b/lib/openapi_first/builder.rb @@ -130,7 +130,7 @@ def build_parameter_schema(parameters) after_property_validation: config.after_request_parameter_property_validation) end - def build_requests(path:, request_method:, operation_object:, parameters:) + def build_requests(path:, request_method:, operation_object:, parameters:) # rubocop:disable Metrics/MethodLength content_objects = operation_object.dig('requestBody', 'content') if content_objects.nil? return [ @@ -143,10 +143,12 @@ def build_requests(path:, request_method:, operation_object:, parameters:) configuration: schemer_configuration, after_property_validation: config.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:, + encoding:, required_body:, key: [path, request_method, content_type].join(':')) end @@ -157,6 +159,7 @@ def request_without_body(path:, request_method:, parameters:, operation_object:) operation_object: operation_object.resolved, content_type: nil, content_schema: nil, + encoding: nil, required_body: false, key: [path, request_method, nil].join(':')) end diff --git a/lib/openapi_first/request.rb b/lib/openapi_first/request.rb index 55c19a3f..34be0dc2 100644 --- a/lib/openapi_first/request.rb +++ b/lib/openapi_first/request.rb @@ -12,8 +12,8 @@ module OpenapiFirst # An 3.x Operation object can accept multiple requests, because it can handle multiple content-types. # This class represents one of those requests. class Request - def initialize(path:, request_method:, operation_object:, # rubocop:disable Metrics/MethodLength - parameters:, content_type:, content_schema:, required_body:, key:) + def initialize(path:, request_method:, operation_object:, # rubocop:disable Metrics/MethodLength,Metrics/ParameterLists + parameters:, content_type:, content_schema:, required_body:, key:, encoding: nil) @path = path @request_method = request_method @content_type = content_type @@ -25,7 +25,7 @@ def initialize(path:, request_method:, operation_object:, # rubocop:disable Metr @path_parser = parameters.path&.then { |params| OpenapiParameters::Path.new(params) } @headers_parser = parameters.header&.then { |params| OpenapiParameters::Header.new(params) } @cookies_parser = parameters.cookie&.then { |params| OpenapiParameters::Cookie.new(params) } - @body_parsers = RequestBodyParsers[content_type] if content_type + @body_parsers = build_body_parser(content_type, encoding) if content_type @validator = RequestValidator.new( content_schema:, required_request_body: required_body == true, @@ -58,6 +58,9 @@ def operation_id @operation['operationId'] end + MULTIPART_CONTENT_TYPE = %r{\Amultipart/form-data\b}i + private_constant :MULTIPART_CONTENT_TYPE + private def parse_request(request, route_params:) @@ -75,5 +78,13 @@ def parse_query(query_string) rescue OpenapiParameters::InvalidParameterError Failure.fail!(:invalid_query, message: 'Invalid query parameter.') end + + def build_body_parser(content_type, encoding) + if content_type.match?(MULTIPART_CONTENT_TYPE) + RequestBodyParsers::MultipartBodyParser.new(encoding: encoding || {}) + else + RequestBodyParsers[content_type] + end + end end end diff --git a/lib/openapi_first/request_body_parsers.rb b/lib/openapi_first/request_body_parsers.rb index 5e665679..c171d0c8 100644 --- a/lib/openapi_first/request_body_parsers.rb +++ b/lib/openapi_first/request_body_parsers.rb @@ -39,21 +39,53 @@ def self.read_body(request) # Parses multipart/form-data requests and currently puts the contents of a file upload at the parsed hash values. # NOTE: This behavior will probably change in the next major version. # The uploaded file should not be read during request validation. - module MultipartBodyParser + # + # Honors the OpenAPI `encoding` map: when a top-level field has + # `contentType: application/json` (or any */json), the field's raw value + # is JSON-parsed before schema validation. + class MultipartBodyParser + def initialize(encoding: {}) + @encoding = encoding || {} + end + def self.call(request) - request.POST.transform_values do |value| - unpack_value(value) + new.call(request) + end + + def call(request) + result = {} + request.POST.each do |name, value| + decoded = decode_field(name, value) + return decoded if decoded.is_a?(Failure) + + result[name] = decoded end + result + end + + private + + def decode_field(name, value) + raw = unpack_value(value) + content_type = @encoding.dig(name, 'contentType') + return raw unless content_type && raw.is_a?(String) && json?(content_type) + + JSON.parse(raw) + rescue JSON::ParserError => e + Failure.fail!(:invalid_body, + message: %(Failed to parse multipart field "#{name}" as JSON: #{e.message})) end - def self.unpack_value(value) + def json?(content_type) + content_type.match?(%r{[/+]json\b}i) + end + + def unpack_value(value) return value.map { unpack_value(_1) } if value.is_a?(Array) return value unless value.is_a?(Hash) return value[:tempfile]&.read if value.key?(:tempfile) - value.transform_values do |v| - unpack_value(v) - end + value.transform_values { unpack_value(_1) } end end diff --git a/spec/data/request-body-validation.yaml b/spec/data/request-body-validation.yaml index deb0474f..8413ce03 100644 --- a/spec/data/request-body-validation.yaml +++ b/spec/data/request-body-validation.yaml @@ -17,18 +17,18 @@ paths: /optional-request-body: post: requestBody: - # required: false # false is the default \o/ - content: - application/json: - schema: - type: object - required: - - say - properties: - say: - type: string - enum: - - 'yes' + # required: false # false is the default \o/ + content: + application/json: + schema: + type: object + required: + - say + properties: + say: + type: string + enum: + - "yes" /pets: post: description: Creates a new pet in the store. Duplicates are allowed @@ -196,6 +196,34 @@ paths: responses: "200": description: ok + /multipart-with-encoding: + post: + requestBody: + 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 /with-form-urlencoded: post: requestBody: diff --git a/spec/middlewares/request_validation/request_body_validation_spec.rb b/spec/middlewares/request_validation/request_body_validation_spec.rb index 0494b0fb..436f4c32 100644 --- a/spec/middlewares/request_validation/request_body_validation_spec.rb +++ b/spec/middlewares/request_validation/request_body_validation_spec.rb @@ -84,6 +84,38 @@ def fixture_path(name) expect(names).to eq(['Quentin']) end + context 'when raise_error is true and a multipart JSON-encoded part is malformed' do + let(:raise_error_option) { true } + + it 'raises with a message naming the field' do + data_part = Rack::Test::UploadedFile.new( + StringIO.new('{not valid json'), + 'application/json', original_filename: 'data.json' + ) + csv_part = Rack::Test::UploadedFile.new(fixture_path('foo.txt'), 'text/csv') + + expect do + post '/multipart-with-encoding', 'data' => data_part, 'file' => csv_part + end.to raise_error(OpenapiFirst::RequestInvalidError, + /Failed to parse multipart field "data" as JSON/) + end + end + + it 'parses a multipart part declared as application/json via encoding' do + data_part = Rack::Test::UploadedFile.new( + StringIO.new(JSON.generate(name: 'Quentin', description: 'Cat')), + 'application/json', original_filename: 'data.json' + ) + csv_part = Rack::Test::UploadedFile.new(fixture_path('foo.txt'), 'text/csv') + + post '/multipart-with-encoding', 'data' => data_part, 'file' => csv_part + + expect(last_response.status).to eq(200), last_response.body + parsed = last_request.env[OpenapiFirst::REQUEST].parsed_body + expect(parsed['data']).to eq('name' => 'Quentin', 'description' => 'Cat') + expect(parsed['file']).to eq(File.read(fixture_path('foo.txt'))) + end + it 'succeeds without optional file upload' do header Rack::CONTENT_TYPE, 'multipart/form-data' post '/multipart-with-file', 'petId' => '12' diff --git a/spec/request_body_parsers_spec.rb b/spec/request_body_parsers_spec.rb index 6576de09..de8c0de5 100644 --- a/spec/request_body_parsers_spec.rb +++ b/spec/request_body_parsers_spec.rb @@ -26,6 +26,47 @@ def app = ->(_env) { Rack::Response.new.finish } body = parser.call(last_request) expect(body['file']).to eq(File.read('./spec/data/foo.txt')) end + + context 'with an encoding map' do + subject(:parser) do + OpenapiFirst::RequestBodyParsers::MultipartBodyParser.new( + encoding: { 'data' => { 'contentType' => 'application/json' } } + ) + end + + it 'parses fields whose encoding contentType is JSON' do + json_part = Rack::Test::UploadedFile.new( + StringIO.new(JSON.generate(name: 'Quentin')), + 'application/json', original_filename: 'data.json' + ) + post '/', 'data' => json_part + + body = parser.call(last_request) + expect(body['data']).to eq('name' => 'Quentin') + end + + it 'throws a Failure when a JSON-encoded field is malformed' do + json_part = Rack::Test::UploadedFile.new( + StringIO.new('{not valid'), + 'application/json', original_filename: 'data.json' + ) + post '/', 'data' => json_part + + expect do + parser.call(last_request) + end.to throw_symbol(OpenapiFirst::FAILURE) + end + + it 'leaves fields without encoding untouched' do + post '/', 'data' => Rack::Test::UploadedFile.new( + StringIO.new(JSON.generate(name: 'Q')), + 'application/json', original_filename: 'data.json' + ), 'other' => 'plain' + + body = parser.call(last_request) + expect(body['other']).to eq('plain') + end + end end context 'with application/x-www-form-urlencoded' do