diff --git a/CHANGELOG.md b/CHANGELOG.md index 09435842..621cbd63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased - Update to hanami-router 2.0.0 stable. Use it's BodyParser middleware to parse the body. +- Support simple form data requests. Still have to figure out file uploads. ## 0.20.0 - You can pass a filepath to `spec:` now so you no longer have to call `OpenapiFirst.load` anymore. diff --git a/lib/openapi_first/operation.rb b/lib/openapi_first/operation.rb index ff582bd1..3765d687 100644 --- a/lib/openapi_first/operation.rb +++ b/lib/openapi_first/operation.rb @@ -88,6 +88,13 @@ def name "#{method.upcase} #{path} (#{operation_id})" end + def valid_request_content_type?(request_content_type) + content = operation_object.dig('requestBody', 'content') + return unless content + + !!find_content_for_content_type(content, request_content_type) + end + private def response_by_code(status) diff --git a/lib/openapi_first/request_validation.rb b/lib/openapi_first/request_validation.rb index 28ac075d..d6c10fde 100644 --- a/lib/openapi_first/request_validation.rb +++ b/lib/openapi_first/request_validation.rb @@ -41,10 +41,11 @@ def call(env) # rubocop:disable Metrics/AbcSize private - def parse_and_validate_request_body!(operation, request) + def parse_and_validate_request_body!(operation, request) # rubocop:disable Metrics/CyclomaticComplexity env = request.env body = env.delete(Hanami::Router::ROUTER_PARSED_BODY) if env.key?(Hanami::Router::ROUTER_PARSED_BODY) + body ||= request.POST if request.form_data? validate_request_body_presence!(body, operation) return if body.nil? @@ -58,9 +59,7 @@ def parse_and_validate_request_body!(operation, request) end def validate_request_content_type!(operation, content_type) - return if operation.request_body.dig('content', content_type) - - throw_error(415) + operation.valid_request_content_type?(content_type) || throw_error(415) end def validate_request_body_presence!(body, operation) diff --git a/lib/openapi_first/router.rb b/lib/openapi_first/router.rb index 74512477..585b13c8 100644 --- a/lib/openapi_first/router.rb +++ b/lib/openapi_first/router.rb @@ -4,7 +4,6 @@ require 'multi_json' require 'hanami/router' require 'hanami/middleware/body_parser' -require_relative 'body_parser_middleware' module OpenapiFirst class Router @@ -57,10 +56,25 @@ def call_router(env) env[ORIGINAL_PATH] = env[Rack::PATH_INFO] env[Rack::PATH_INFO] = Rack::Request.new(env).path @router.call(env) + rescue Hanami::Middleware::BodyParser::BodyParsingError => e + handle_body_parsing_error(e) ensure env[Rack::PATH_INFO] = env.delete(ORIGINAL_PATH) if env[ORIGINAL_PATH] end + def handle_body_parsing_error(exception) + err = { title: 'Failed to parse body as JSON', status: '400' } + err[:detail] = exception.cause unless ENV['RACK_ENV'] == 'production' + errors = [err] + raise RequestInvalidError, errors if @raise + + Rack::Response.new( + MultiJson.dump(errors: errors), + 400, + Rack::CONTENT_TYPE => 'application/vnd.api+json' + ).finish + end + def build_router(operations) router = Hanami::Router.new.tap do |r| operations.each do |operation| @@ -72,9 +86,8 @@ def build_router(operations) ) end end - raise_error = @raise Rack::Builder.app do - use BodyParserMiddleware, { raise_error: raise_error } + use Hanami::Middleware::BodyParser, %i[json form] run router end end diff --git a/spec/data/foo.txt b/spec/data/foo.txt new file mode 100644 index 00000000..5716ca59 --- /dev/null +++ b/spec/data/foo.txt @@ -0,0 +1 @@ +bar diff --git a/spec/data/request-body-validation.yaml b/spec/data/request-body-validation.yaml index e01e6539..8bc2c064 100644 --- a/spec/data/request-body-validation.yaml +++ b/spec/data/request-body-validation.yaml @@ -38,6 +38,34 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" + /json_api: + post: + description: Creates a new pet in the store. Duplicates are allowed + operationId: create_pet_json_api + requestBody: + description: Pet to add to the store + required: true + content: + application/vnd.api+json: + schema: + $ref: "#/components/schemas/NewPet" + responses: + "200": + description: ok + /custom-json-type: + post: + description: Creates a new pet in the store. Duplicates are allowed + operationId: create_pet_custom-json-type + requestBody: + description: Pet to add to the store + required: true + content: + application/prs.custom-json-type+json: + schema: + $ref: "#/components/schemas/NewPet" + responses: + "200": + description: ok /pets/{id}: parameters: - name: id @@ -89,6 +117,44 @@ paths: responses: 200: description: Ok + /with-form-data: + post: + requestBody: + description: Pet to add to the store + required: true + content: + multipart/form-data: + schema: + $ref: "#/components/schemas/NewPet" + responses: + "200": + description: ok + /with-form-urlencoded: + post: + requestBody: + description: Pet to add to the store + required: true + content: + application/x-www-form-urlencoded: + schema: + $ref: "#/components/schemas/NewPet" + responses: + "200": + description: ok + /with-multipart-file: + post: + requestBody: + description: Pet to add to the store + required: true + content: + multipart/form-data: + schema: + properties: + file: + description: The file + responses: + "200": + description: pet response components: schemas: Pet: diff --git a/spec/operation_spec.rb b/spec/operation_spec.rb index 0d552b6b..53353869 100644 --- a/spec/operation_spec.rb +++ b/spec/operation_spec.rb @@ -364,4 +364,32 @@ end end end + + describe '#valid_request_content_type?' do + it 'returns true for an exact match' do + operation = described_class.new('/', 'get', + { 'get' => { 'requestBody' => { 'content' => { 'application/json' => {} } } } }) + valid = operation.valid_request_content_type?('application/json') + expect(valid).to be true + end + + it 'ignores content type parameters' do + operation = described_class.new('/', 'get', + { 'get' => { 'requestBody' => { 'content' => { 'application/json' => {} } } } }) + valid = operation.valid_request_content_type?('application/json; charset=UTF8') + expect(valid).to be true + end + + it 'matches type/*' do + operation = described_class.new('/', 'get', { 'get' => { 'requestBody' => { 'content' => { 'text/*' => {} } } } }) + valid = operation.valid_request_content_type?('text/plain') + expect(valid).to be true + end + + it 'matches */*' do + operation = described_class.new('/', 'get', { 'get' => { 'requestBody' => { 'content' => { '*/*' => {} } } } }) + valid = operation.valid_request_content_type?('application/json') + expect(valid).to be true + end + end end diff --git a/spec/request_validation/request_body_validation_spec.rb b/spec/request_validation/request_body_validation_spec.rb index 851206fe..7143c967 100644 --- a/spec/request_validation/request_body_validation_spec.rb +++ b/spec/request_validation/request_body_validation_spec.rb @@ -68,6 +68,23 @@ expect(last_response.status).to be 200 end + it 'works with json:api media type' do + header Rack::CONTENT_TYPE, 'application/vnd.api+json' + post '/json_api', json_dump(request_body) + + expect(last_response.status).to be 200 + expect(last_request.env[OpenapiFirst::REQUEST_BODY]).to eq request_body + end + + pending 'works with a custom json media type' do + header Rack::CONTENT_TYPE, 'application/vnd.my-custom+json' + post '/custom-json-type', json_dump(request_body) + + expect(last_response.status).to be 200 + expect(last_request.env[OpenapiFirst::REQUEST_BODY]).to eq request_body + end + + it 'adds parsed request body to env' do header Rack::CONTENT_TYPE, 'application/json' post path, json_dump(request_body) @@ -166,6 +183,32 @@ end end + it 'ignores content type parameters' do + header Rack::CONTENT_TYPE, 'application/json; encoding=utf-8' + post '/pets', json_dump(request_body) + + expect(last_response.status).to be 200 + end + + it 'succeeds with simple multipart form data' do + header Rack::CONTENT_TYPE, 'multipart/form-data' + post '/with-form-data', request_body + + expect(last_response.status).to be(200), last_response.body + expect(last_request.env[OpenapiFirst::REQUEST_BODY]).to eq request_body + end + + + it 'succeeds with form-urlencoded data' do + header Rack::CONTENT_TYPE, 'application/x-www-form-urlencoded' + post '/with-form-urlencoded', request_body + + expect(last_response.status).to be(200), last_response.body + expect(last_request.env[OpenapiFirst::REQUEST_BODY]).to eq request_body + end + + it "handles file uploads" + it 'returns 415 if required request body is missing' do header Rack::CONTENT_TYPE, 'application/json' post path