Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Changelog

## Unreleased

- Support `encoding[<field>].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)`.

Expand Down
5 changes: 4 additions & 1 deletion lib/openapi_first/builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
Expand All @@ -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
Expand All @@ -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
Expand Down
17 changes: 14 additions & 3 deletions lib/openapi_first/request.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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:)
Expand All @@ -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
46 changes: 39 additions & 7 deletions lib/openapi_first/request_body_parsers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
52 changes: 40 additions & 12 deletions spec/data/request-body-validation.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
41 changes: 41 additions & 0 deletions spec/request_body_parsers_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading