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
6 changes: 4 additions & 2 deletions lib/committee/middleware/options/response_validation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ class ResponseValidation < Base
attr_reader :parse_response_by_content_type
attr_reader :coerce_response_values
attr_reader :streaming_content_parsers
attr_reader :strict_response_content_type

# Default values
DEFAULTS = { strict: false, validate_success_only: true, parse_response_by_content_type: true, coerce_response_values: false }.freeze
DEFAULTS = { strict: false, validate_success_only: true, parse_response_by_content_type: true, coerce_response_values: false, strict_response_content_type: false }.freeze

def initialize(options = {})
super(options)
Expand All @@ -22,6 +23,7 @@ def initialize(options = {})
@validate_success_only = options.fetch(:validate_success_only, DEFAULTS[:validate_success_only])
@parse_response_by_content_type = options.fetch(:parse_response_by_content_type, DEFAULTS[:parse_response_by_content_type])
@coerce_response_values = options.fetch(:coerce_response_values, DEFAULTS[:coerce_response_values])
@strict_response_content_type = options.fetch(:strict_response_content_type, DEFAULTS[:strict_response_content_type])

# Streaming
@streaming_content_parsers = options[:streaming_content_parsers] || {}
Expand All @@ -38,7 +40,7 @@ def validate_response_validation_options!
end

def build_hash
super.merge(strict: @strict, validate_success_only: @validate_success_only, parse_response_by_content_type: @parse_response_by_content_type, coerce_response_values: @coerce_response_values, streaming_content_parsers: @streaming_content_parsers)
super.merge(strict: @strict, validate_success_only: @validate_success_only, parse_response_by_content_type: @parse_response_by_content_type, coerce_response_values: @coerce_response_values, streaming_content_parsers: @streaming_content_parsers, strict_response_content_type: @strict_response_content_type)
end
end
end
Expand Down
28 changes: 27 additions & 1 deletion lib/committee/schema_validator/open_api_3/operation_wrapper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,23 @@ def coerce_path_parameter(validator_option)
end

# @param [Boolean] strict when not content_type or status code definition, raise error
def validate_response_params(status_code, headers, response_data, strict, check_header, validator_options: {})
def validate_response_params(status_code, headers, response_data, strict, check_header, strict_response_content_type: false, validator_options: {})
response_body = OpenAPIParser::RequestOperation::ValidatableResponseBody.new(status_code, response_data, headers)

# When strict_response_content_type is enabled, reject responses whose Content-Type is not
# declared in the spec's content map for this status code. Responses with no content map
# (e.g. bare 204s) are skipped — openapi_parser's validate_response_body handles those.
if strict_response_content_type
response_object = find_response_object_for_status(request_operation.operation_object&.responses, status_code)
if response_object
content_type = Rack::MediaType.type(response_body.content_type)
matched = response_object.select_media_type(content_type)
if matched.nil? && response_object.content && !response_object.content.empty?
raise Committee::InvalidResponse, "Response Content-Type '#{content_type}' is not declared in the OpenAPI spec for this operation. Declared types: #{response_object.content.keys.join(', ')}"
end
end
end

return request_operation.validate_response_body(response_body, response_validate_options(strict, check_header, validator_options: validator_options))
rescue OpenAPIParser::OpenAPIError => e
raise Committee::InvalidResponse.new(e.message, original_error: e)
Expand Down Expand Up @@ -163,6 +177,18 @@ def validate_no_unknown_query_params(query_params)
raise Committee::InvalidRequest.new("Unknown query parameter(s): #{unknown_params.join(', ')}")
end

def find_response_object_for_status(responses, status_code)
return nil unless responses&.response

response_hash = responses.response
return response_hash[status_code.to_s] if response_hash[status_code.to_s]

wild_card = "#{status_code.to_i / 100}XX"
return response_hash[wild_card] if response_hash[wild_card]

responses.default
end

def response_validate_options(strict, check_header, validator_options: {})
options = { strict: strict, validate_header: check_header }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,15 @@ def initialize(operation_wrapper, validator_option)
@check_header = validator_option.check_header
@allow_empty_date_and_datetime = validator_option.allow_empty_date_and_datetime
@coerce_response_values = validator_option.coerce_response_values
@strict_response_content_type = validator_option.strict_response_content_type
end

def call(status, headers, response_data, strict)
return unless validate?(status)

validator_options = { allow_empty_date_and_datetime: @allow_empty_date_and_datetime, coerce_value: @coerce_response_values }

operation_wrapper.validate_response_params(status, headers, response_data, strict, check_header, validator_options: validator_options)
operation_wrapper.validate_response_params(status, headers, response_data, strict, check_header, strict_response_content_type: @strict_response_content_type, validator_options: validator_options)
end

def validate?(status)
Expand Down
3 changes: 2 additions & 1 deletion lib/committee/schema_validator/option.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ module Committee
module SchemaValidator
class Option
# Boolean Options
attr_reader :allow_blank_structures, :allow_empty_date_and_datetime, :allow_form_params, :allow_get_body, :allow_query_params, :allow_non_get_query_params, :check_content_type, :check_header, :coerce_date_times, :coerce_form_params, :coerce_path_params, :coerce_query_params, :coerce_recursive, :coerce_response_values, :deserialize_parameters, :optimistic_json, :validate_success_only, :parse_response_by_content_type, :parameter_overwrite_by_rails_rule, :strict_query_params
attr_reader :allow_blank_structures, :allow_empty_date_and_datetime, :allow_form_params, :allow_get_body, :allow_query_params, :allow_non_get_query_params, :check_content_type, :check_header, :coerce_date_times, :coerce_form_params, :coerce_path_params, :coerce_query_params, :coerce_recursive, :coerce_response_values, :deserialize_parameters, :optimistic_json, :validate_success_only, :parse_response_by_content_type, :parameter_overwrite_by_rails_rule, :strict_query_params, :strict_response_content_type

# Non-boolean options:
attr_reader :headers_key, :params_key, :query_hash_key, :request_body_hash_key, :path_hash_key, :prefix
Expand Down Expand Up @@ -32,6 +32,7 @@ def initialize(options, schema, schema_type)
@optimistic_json = options.fetch(:optimistic_json, false)
@parse_response_by_content_type = options.fetch(:parse_response_by_content_type, true)
@strict_query_params = options.fetch(:strict_query_params, false)
@strict_response_content_type = options.fetch(:strict_response_content_type, false)

@parameter_overwrite_by_rails_rule =
if options.key?(:parameter_overwite_by_rails_rule)
Expand Down
29 changes: 29 additions & 0 deletions test/data/openapi3/normal.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,35 @@ paths:
type: string
format: binary

/no_content_schema:
get:
description: endpoint with no content schema on its response
responses:
'200':
description: success

/wildcard_response:
get:
description: endpoint with wildcard status code response
responses:
'4XX':
description: client error
content:
application/json:
schema:
type: object

/default_response:
get:
description: endpoint with default response only
responses:
default:
description: any status
content:
application/json:
schema:
type: object

/validate_no_parameter:
patch:
description: validate no body
Expand Down
15 changes: 15 additions & 0 deletions test/middleware/options/response_validation_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,16 @@
end
assert_equal "streaming_content_parsers must be a Hash", e.message
end

it "sets strict_response_content_type option with default false" do
options = Committee::Middleware::Options::ResponseValidation.new(schema: hyper_schema)
assert_equal false, options.strict_response_content_type
end

it "sets strict_response_content_type option when provided" do
options = Committee::Middleware::Options::ResponseValidation.new(schema: hyper_schema, strict_response_content_type: true)
assert_equal true, options.strict_response_content_type
end
end

describe "#to_h" do
Expand All @@ -103,6 +113,11 @@
assert_equal true, hash[:strict]
assert_equal false, hash[:validate_success_only]
end

it "includes strict_response_content_type in hash" do
options = Committee::Middleware::Options::ResponseValidation.new(schema: hyper_schema, strict_response_content_type: true)
assert_equal true, options.to_h[:strict_response_content_type]
end
end

describe ".from" do
Expand Down
62 changes: 62 additions & 0 deletions test/middleware/response_validation_open_api_3_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,68 @@ def app
end
end

describe "strict_response_content_type option" do
it "passes through with undeclared content type when strict_response_content_type: false (default)" do
@app = new_response_rack(JSON.generate(CHARACTERS_RESPONSE), { "Content-Type" => "application/vnd.api+json" }, schema: open_api_3_schema)
get "/characters"
assert_equal 200, last_response.status
end

it "raises with undeclared content type when strict_response_content_type: true" do
@app = new_response_rack(JSON.generate(CHARACTERS_RESPONSE), { "Content-Type" => "application/vnd.api+json" }, schema: open_api_3_schema, strict_response_content_type: true, raise: true)
assert_raises(Committee::InvalidResponse) do
get "/characters"
end
end

it "returns 500 with undeclared content type when strict_response_content_type: true without raise" do
@app = new_response_rack(JSON.generate(CHARACTERS_RESPONSE), { "Content-Type" => "application/vnd.api+json" }, schema: open_api_3_schema, strict_response_content_type: true)
get "/characters"
assert_equal 500, last_response.status
end

it "passes through when response has no content schema with strict_response_content_type: true" do
@app = new_response_rack("", { "Content-Type" => "application/vnd.api+json" }, schema: open_api_3_schema, strict_response_content_type: true)
get "/no_content_schema"
assert_equal 200, last_response.status
end

it "passes through with declared content type and strict_response_content_type: true" do
@app = new_response_rack(JSON.generate(CHARACTERS_RESPONSE), { "Content-Type" => "application/json" }, schema: open_api_3_schema, strict_response_content_type: true)
get "/characters"
assert_equal 200, last_response.status
end

it "passes through with declared content type and charset param with strict_response_content_type: true" do
@app = new_response_rack(JSON.generate(CHARACTERS_RESPONSE), { "Content-Type" => "application/json; charset=utf-8" }, schema: open_api_3_schema, strict_response_content_type: true)
get "/characters"
assert_equal 200, last_response.status
end

it "raises with undeclared content type matched via wildcard status code with strict_response_content_type: true" do
@app = new_response_rack("{}", { "Content-Type" => "application/vnd.api+json" }, { schema: open_api_3_schema, strict_response_content_type: true, raise: true, validate_success_only: false }, { status: 400 })
assert_raises(Committee::InvalidResponse) do
get "/wildcard_response"
end
end

it "raises with undeclared content type matched via default response with strict_response_content_type: true" do
@app = new_response_rack("{}", { "Content-Type" => "application/vnd.api+json" }, { schema: open_api_3_schema, strict_response_content_type: true, raise: true, validate_success_only: false }, { status: 500 })
assert_raises(Committee::InvalidResponse) do
get "/default_response"
end
end

it "calls error_handler with strict_response_content_type: true and raise: false" do
called_err = nil
pr = ->(e, _env) { called_err = e }
@app = new_response_rack(JSON.generate(CHARACTERS_RESPONSE), { "Content-Type" => "application/vnd.api+json" }, schema: open_api_3_schema, strict_response_content_type: true, error_handler: pr)
get "/characters"
assert_equal 500, last_response.status
assert_kind_of Committee::InvalidResponse, called_err
end
end

private

def new_response_rack(response, headers = {}, options = {}, rack_options = {})
Expand Down
38 changes: 38 additions & 0 deletions test/schema_validator/open_api_3/operation_wrapper_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -223,5 +223,43 @@ def operation_object
assert_kind_of(Integer, body_params["integer"])
end
end

describe '#find_response_object_for_status' do
def responses_for(path, method = 'get')
open_api_3_schema.operation_object(path, method).request_operation.operation_object.responses
end

def find(path, status, method = 'get')
wrapper = open_api_3_schema.operation_object(path, method)
wrapper.send(:find_response_object_for_status, responses_for(path, method), status)
end

it 'returns nil when responses is nil' do
wrapper = open_api_3_schema.operation_object('/characters', 'get')
assert_nil wrapper.send(:find_response_object_for_status, nil, 200)
end

it 'returns exact status code match' do
result = find('/characters', 200)
assert_kind_of OpenAPIParser::Schemas::Response, result
assert result.content.key?('application/json')
end

it 'returns wildcard match when exact status not defined' do
result = find('/wildcard_response', 400)
assert_kind_of OpenAPIParser::Schemas::Response, result
assert result.content.key?('application/json')
end

it 'returns default when no exact or wildcard match' do
result = find('/default_response', 500)
assert_kind_of OpenAPIParser::Schemas::Response, result
assert result.content.key?('application/json')
end

it 'returns nil when no exact, wildcard, or default match' do
assert_nil find('/characters', 999)
end
end
end
end