committee 5.6.2 → 5.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6f5e2c05d7caa0327f1f9aab8eb4e70fde475df991c89d2969cf1e64fbaf2ab4
4
- data.tar.gz: b8e55ce938ae8276b2469cef2a5f451b326fcbd94ab10a0957a0c8908307707d
3
+ metadata.gz: 59078886ccd7f7a330d9249a0804394504f31e4db513a71f1b0226c4445c5873
4
+ data.tar.gz: 1029ac7aa696d256bf8c9e588c24aa4c39e2a8ac906481a78d89f6d9490a5222
5
5
  SHA512:
6
- metadata.gz: 48819e9df56d4ad78afc3e4ccd6992eba3570a3e3ec39570354b8ab3777e73d4ef75f5554cfb2e3bf173b91d645c0015173c465e64712f87d4ef22ad02738b7e
7
- data.tar.gz: f3e72af3ca96804db1b07f0b28002367bee84d6557f1725c0244fee839757a940c7aa89e6373952559109c273f5ec721759be89d8f98517234bd3d00bdba17ce
6
+ metadata.gz: ce35b10aa816fd3514674a710e2ac6b71164bb00a483de716efa66bc5a3fef64e1c566e9535372fd3971c0ec00989cc95b746cdd04892344f375e181b7c35e05
7
+ data.tar.gz: 7f4adcf8fd1e0c8113b49b9f09f67425219f796f8ea4f34f2e94704b23d7b9e2c932fce33d9d40f261b1b1183b441fce527871940adacb7d26643d78cf615c7d
@@ -8,7 +8,8 @@ module Committee
8
8
  true
9
9
  end
10
10
 
11
- # Whether parameters that were form-encoded will be coerced by default.
11
+ # Historically named for form params, but in OpenAPI 3 this flag controls
12
+ # request body coercion more generally.
12
13
  def default_coerce_form_params
13
14
  true
14
15
  end
@@ -10,9 +10,10 @@ module Committee
10
10
  attr_reader :parse_response_by_content_type
11
11
  attr_reader :coerce_response_values
12
12
  attr_reader :streaming_content_parsers
13
+ attr_reader :strict_response_content_type
13
14
 
14
15
  # Default values
15
- DEFAULTS = { strict: false, validate_success_only: true, parse_response_by_content_type: true, coerce_response_values: false }.freeze
16
+ DEFAULTS = { strict: false, validate_success_only: true, parse_response_by_content_type: true, coerce_response_values: false, strict_response_content_type: false }.freeze
16
17
 
17
18
  def initialize(options = {})
18
19
  super(options)
@@ -22,6 +23,7 @@ module Committee
22
23
  @validate_success_only = options.fetch(:validate_success_only, DEFAULTS[:validate_success_only])
23
24
  @parse_response_by_content_type = options.fetch(:parse_response_by_content_type, DEFAULTS[:parse_response_by_content_type])
24
25
  @coerce_response_values = options.fetch(:coerce_response_values, DEFAULTS[:coerce_response_values])
26
+ @strict_response_content_type = options.fetch(:strict_response_content_type, DEFAULTS[:strict_response_content_type])
25
27
 
26
28
  # Streaming
27
29
  @streaming_content_parsers = options[:streaming_content_parsers] || {}
@@ -38,7 +40,7 @@ module Committee
38
40
  end
39
41
 
40
42
  def build_hash
41
- 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)
43
+ 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)
42
44
  end
43
45
  end
44
46
  end
@@ -28,8 +28,19 @@ module Committee
28
28
  end
29
29
  end
30
30
  else
31
+ validator = response_validator(request, status)
32
+ if validator
33
+ original_response = response
34
+ response = []
35
+ begin
36
+ original_response.each { |chunk| response << chunk }
37
+ ensure
38
+ original_response.close if original_response.respond_to?(:close)
39
+ end
40
+ end
41
+
31
42
  begin
32
- validate(request, status, headers, response)
43
+ validator&.response_validate(status, headers, response, @strict)
33
44
  rescue Committee::InvalidResponse
34
45
  handle_exception($!, request.env)
35
46
 
@@ -68,10 +79,12 @@ module Committee
68
79
  end
69
80
 
70
81
  def validate(request, status, headers, response, streaming_content_parser = nil)
71
- v = build_schema_validator(request)
72
- if v.link_exist? && self.class.validate?(status, validate_success_only)
73
- v.response_validate(status, headers, response, @strict, streaming_content_parser)
74
- end
82
+ response_validator(request, status)&.response_validate(status, headers, response, @strict, streaming_content_parser)
83
+ end
84
+
85
+ def response_validator(request, status)
86
+ validator = build_schema_validator(request)
87
+ validator if validator.link_exist? && self.class.validate?(status, validate_success_only)
75
88
  end
76
89
 
77
90
  def retrieve_streaming_content_parser(headers)
@@ -15,7 +15,7 @@ module Committee
15
15
  @validators = {}
16
16
  if link.is_a? Drivers::OpenAPI2::Link
17
17
  link.target_schemas.each do |status, schema|
18
- @validators[status] = JsonSchema::Validator.new(target_schema(link))
18
+ @validators[status] = JsonSchema::Validator.new(schema)
19
19
  end
20
20
  else
21
21
  @validator = JsonSchema::Validator.new(target_schema(link))
@@ -31,7 +31,7 @@ module Committee
31
31
  elsif !full_body.empty?
32
32
  parse_to_json = if validator_option.parse_response_by_content_type
33
33
  content_type_key = headers.keys.detect { |k| k.casecmp?('Content-Type') }
34
- headers.fetch(content_type_key, nil)&.start_with?('application/json')
34
+ Committee::SchemaValidator.json_media_type?(headers.fetch(content_type_key, nil))
35
35
  else
36
36
  true
37
37
  end
@@ -32,20 +32,34 @@ module Committee
32
32
  end
33
33
 
34
34
  # @param [Boolean] strict when not content_type or status code definition, raise error
35
- def validate_response_params(status_code, headers, response_data, strict, check_header, validator_options: {})
35
+ def validate_response_params(status_code, headers, response_data, strict, check_header, strict_response_content_type: false, validator_options: {})
36
36
  response_body = OpenAPIParser::RequestOperation::ValidatableResponseBody.new(status_code, response_data, headers)
37
37
 
38
+ # When strict_response_content_type is enabled, reject responses whose Content-Type is not
39
+ # declared in the spec's content map for this status code. Responses with no content map
40
+ # (e.g. bare 204s) are skipped — openapi_parser's validate_response_body handles those.
41
+ if strict_response_content_type
42
+ response_object = find_response_object_for_status(request_operation.operation_object&.responses, status_code)
43
+ if response_object
44
+ content_type = Rack::MediaType.type(response_body.content_type)
45
+ matched = response_object.select_media_type(content_type)
46
+ if matched.nil? && response_object.content && !response_object.content.empty?
47
+ 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(', ')}"
48
+ end
49
+ end
50
+ end
51
+
38
52
  return request_operation.validate_response_body(response_body, response_validate_options(strict, check_header, validator_options: validator_options))
39
53
  rescue OpenAPIParser::OpenAPIError => e
40
54
  raise Committee::InvalidResponse.new(e.message, original_error: e)
41
55
  end
42
56
 
43
- def validate_request_params(path_params, query_params, body_params, headers, validator_option)
57
+ def validate_request_params(_path_params, query_params, body_params, headers, validator_option)
44
58
  ret, err = case request_operation.http_method
45
59
  when 'get', 'delete', 'head'
46
- validate_get_request_params(path_params, query_params, headers, validator_option)
60
+ validate_get_request_params(query_params, headers, validator_option)
47
61
  when 'post', 'put', 'patch', 'options'
48
- validate_post_request_params(path_params, query_params, body_params, headers, validator_option)
62
+ validate_post_request_params(query_params, body_params, headers, validator_option)
49
63
  else
50
64
  raise "Committee OpenAPI3 not support #{request_operation.http_method} method"
51
65
  end
@@ -112,42 +126,27 @@ module Committee
112
126
  OpenAPIParser::SchemaValidator::Options.new(**parser_options)
113
127
  end
114
128
 
115
- def validate_get_request_params(path_params, query_params, headers, validator_option)
116
- # bad performance because when we coerce value, same check
117
- validate_path_and_query_params(path_params, query_params, headers, validator_option)
129
+ def validate_get_request_params(query_params, headers, validator_option)
130
+ validate_query_params(query_params, headers, validator_option)
118
131
  rescue OpenAPIParser::OpenAPIError => e
119
132
  raise Committee::InvalidRequest.new(e.message, original_error: e)
120
133
  end
121
134
 
122
- def validate_post_request_params(path_params, query_params, body_params, headers, validator_option)
135
+ def validate_post_request_params(query_params, body_params, headers, validator_option)
123
136
  content_type_key = headers.keys.detect { |k| k.casecmp?('Content-Type') }
124
137
  content_type = Rack::MediaType.type(headers[content_type_key])
125
138
 
126
- # bad performance because when we coerce value, same check
127
- validate_path_and_query_params(path_params, query_params, headers, validator_option)
139
+ validate_query_params(query_params, headers, validator_option)
128
140
  request_operation.validate_request_body(content_type, body_params, build_openapi_parser_body_option(validator_option))
129
141
  rescue => e
130
142
  raise Committee::InvalidRequest.new(e.message, original_error: e)
131
143
  end
132
144
 
133
- def validate_path_and_query_params(path_params, query_params, headers, validator_option)
134
- path_params ||= {}
145
+ def validate_query_params(query_params, headers, validator_option)
135
146
  query_params ||= {}
136
147
 
137
- # it's currently impossible to validate path params and query params separately
138
- # so we have to resort to this workaround
139
-
140
- path_keys = path_params.keys.to_set
141
- query_keys = query_params.keys.to_set
142
-
143
- merged_params = query_params.merge(path_params)
144
-
145
- request_operation.validate_request_parameter(merged_params, headers, build_openapi_parser_request_parameter_option(validator_option))
146
-
147
- merged_params.each do |k, v|
148
- path_params[k] = v if path_keys.include?(k)
149
- query_params[k] = v if query_keys.include?(k)
150
- end
148
+ # Path parameters have already been validated by coerce_path_parameter.
149
+ request_operation.validate_request_parameter(query_params, headers, build_openapi_parser_request_parameter_option(validator_option))
151
150
 
152
151
  validate_no_unknown_query_params(query_params) if validator_option.strict_query_params
153
152
  end
@@ -163,6 +162,18 @@ module Committee
163
162
  raise Committee::InvalidRequest.new("Unknown query parameter(s): #{unknown_params.join(', ')}")
164
163
  end
165
164
 
165
+ def find_response_object_for_status(responses, status_code)
166
+ return nil unless responses&.response
167
+
168
+ response_hash = responses.response
169
+ return response_hash[status_code.to_s] if response_hash[status_code.to_s]
170
+
171
+ wild_card = "#{status_code.to_i / 100}XX"
172
+ return response_hash[wild_card] if response_hash[wild_card]
173
+
174
+ responses.default
175
+ end
176
+
166
177
  def response_validate_options(strict, check_header, validator_options: {})
167
178
  options = { strict: strict, validate_header: check_header }
168
179
 
@@ -52,6 +52,8 @@ module Committee
52
52
  # If no parameters are defined for this location, return raw params as-is
53
53
  return raw_params if params_for_location.empty?
54
54
 
55
+ raw_params = normalize_raw_params(raw_params, location, params_for_location)
56
+
55
57
  # Collect parameter names that will be deserialized
56
58
  # This includes both the parameter name and any properties (for exploded objects)
57
59
  deserialized_keys = Set.new
@@ -105,6 +107,65 @@ module Committee
105
107
  Committee::Utils.indifferent_hash.merge(hash)
106
108
  end
107
109
 
110
+ # Normalize Rack-style nested query hashes into bracket notation when the
111
+ # schema expects bracket-named params or deepObject query params.
112
+ # Example: { "filter" => { "slug" => "/test" } } => { "filter[slug]" => "/test" }
113
+ # @param [Hash] raw_params
114
+ # @param [String] location
115
+ # @param [Array<OpenAPIParser::Schemas::Parameter>] params_for_location
116
+ # @return [Hash]
117
+ def normalize_raw_params(raw_params, location, params_for_location)
118
+ return raw_params unless location == 'query'
119
+ return raw_params unless raw_params.values.any? { |value| value.is_a?(Hash) }
120
+ return raw_params unless requires_query_param_flattening?(params_for_location)
121
+
122
+ normalized = Committee::Utils.indifferent_hash
123
+
124
+ raw_params.each do |key, value|
125
+ if should_flatten_query_param?(key, value, params_for_location)
126
+ flatten_nested_query_param(normalized, key.to_s, value)
127
+ else
128
+ normalized[key] = value
129
+ end
130
+ end
131
+
132
+ normalized
133
+ end
134
+
135
+ # @param [Array<OpenAPIParser::Schemas::Parameter>] params_for_location
136
+ # @return [Boolean]
137
+ def requires_query_param_flattening?(params_for_location)
138
+ params_for_location.any? { |param_def| param_def.style == 'deepObject' || param_def.name.include?('[') }
139
+ end
140
+
141
+ # @param [String, Symbol] key
142
+ # @param [Object] value
143
+ # @param [Array<OpenAPIParser::Schemas::Parameter>] params_for_location
144
+ # @return [Boolean]
145
+ def should_flatten_query_param?(key, value, params_for_location)
146
+ return false unless value.is_a?(Hash)
147
+
148
+ key_name = key.to_s
149
+ params_for_location.any? do |param_def|
150
+ param_def.name == key_name || param_def.name.start_with?("#{key_name}[")
151
+ end
152
+ end
153
+
154
+ # @param [Hash] result
155
+ # @param [String] prefix
156
+ # @param [Object] value
157
+ # @return [void]
158
+ def flatten_nested_query_param(result, prefix, value)
159
+ case value
160
+ when Hash
161
+ value.each do |child_key, child_value|
162
+ flatten_nested_query_param(result, "#{prefix}[#{child_key}]", child_value)
163
+ end
164
+ else
165
+ result[prefix] = value
166
+ end
167
+ end
168
+
108
169
  # Extract and deserialize a single parameter
109
170
  # @param [OpenAPIParser::Schemas::Parameter] param_def Parameter definition
110
171
  # @param [Hash] raw_params Raw parameters
@@ -14,6 +14,7 @@ module Committee
14
14
  @check_header = validator_option.check_header
15
15
  @allow_empty_date_and_datetime = validator_option.allow_empty_date_and_datetime
16
16
  @coerce_response_values = validator_option.coerce_response_values
17
+ @strict_response_content_type = validator_option.strict_response_content_type
17
18
  end
18
19
 
19
20
  def call(status, headers, response_data, strict)
@@ -21,7 +22,7 @@ module Committee
21
22
 
22
23
  validator_options = { allow_empty_date_and_datetime: @allow_empty_date_and_datetime, coerce_value: @coerce_response_values }
23
24
 
24
- operation_wrapper.validate_response_params(status, headers, response_data, strict, check_header, validator_options: validator_options)
25
+ operation_wrapper.validate_response_params(status, headers, response_data, strict, check_header, strict_response_content_type: @strict_response_content_type, validator_options: validator_options)
25
26
  end
26
27
 
27
28
  def validate?(status)
@@ -28,7 +28,7 @@ module Committee
28
28
 
29
29
  parse_to_json = if validator_option.parse_response_by_content_type
30
30
  content_type_key = headers.keys.detect { |k| k.casecmp?('Content-Type') }
31
- headers.fetch(content_type_key, nil)&.start_with?('application/json')
31
+ Committee::SchemaValidator.json_media_type?(headers.fetch(content_type_key, nil))
32
32
  else
33
33
  true
34
34
  end
@@ -50,6 +50,10 @@ module Committee
50
50
  !@operation_object.nil?
51
51
  end
52
52
 
53
+ # Expose the operation matched for the request, or nil if none matched
54
+ # @return [Committee::SchemaValidator::OpenAPI3::OperationWrapper, nil]
55
+ attr_reader :operation_object
56
+
53
57
  private
54
58
 
55
59
  attr_reader :validator_option
@@ -4,7 +4,7 @@ module Committee
4
4
  module SchemaValidator
5
5
  class Option
6
6
  # Boolean Options
7
- 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
7
+ 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
8
8
 
9
9
  # Non-boolean options:
10
10
  attr_reader :headers_key, :params_key, :query_hash_key, :request_body_hash_key, :path_hash_key, :prefix
@@ -32,6 +32,7 @@ module Committee
32
32
  @optimistic_json = options.fetch(:optimistic_json, false)
33
33
  @parse_response_by_content_type = options.fetch(:parse_response_by_content_type, true)
34
34
  @strict_query_params = options.fetch(:strict_query_params, false)
35
+ @strict_response_content_type = options.fetch(:strict_response_content_type, false)
35
36
 
36
37
  @parameter_overwrite_by_rails_rule =
37
38
  if options.key?(:parameter_overwite_by_rails_rule)
@@ -2,11 +2,18 @@
2
2
 
3
3
  module Committee
4
4
  module SchemaValidator
5
+ JSON_MEDIA_TYPE_PATTERN = %r{\Aapplication/(?:.+\+)?json\z}.freeze
6
+
5
7
  class << self
6
8
  def request_media_type(request)
7
9
  Rack::MediaType.type(request.env['CONTENT_TYPE'])
8
10
  end
9
11
 
12
+ def json_media_type?(content_type)
13
+ normalized_content_type = Rack::MediaType.type(content_type)
14
+ normalized_content_type&.match?(JSON_MEDIA_TYPE_PATTERN) || false
15
+ end
16
+
10
17
  # @param [String] prefix
11
18
  # @return [Regexp]
12
19
  def build_prefix_regexp(prefix)
@@ -23,6 +23,8 @@ module Committee
23
23
  schema_validator.request_validate(request_object)
24
24
  end
25
25
  end
26
+
27
+ increment_assertion_count
26
28
  end
27
29
 
28
30
  def assert_response_schema_confirm(expected_status = nil)
@@ -46,6 +48,8 @@ module Committee
46
48
  end
47
49
 
48
50
  schema_validator.response_validate(status, headers, [body], true) if validate_response?(status)
51
+
52
+ increment_assertion_count
49
53
  end
50
54
 
51
55
  def committee_options
@@ -73,7 +77,7 @@ module Committee
73
77
  end
74
78
 
75
79
  def schema_validator
76
- @schema_validator ||= router.build_schema_validator(request_object)
80
+ router.build_schema_validator(request_object)
77
81
  end
78
82
 
79
83
  def schema_coverage
@@ -90,6 +94,13 @@ module Committee
90
94
 
91
95
  private
92
96
 
97
+ # assert_*_schema_confirm signal failure by raising, not via `assert`,
98
+ # so Minitest would report "Test is missing assertions" on success.
99
+ # Bump the counter explicitly; no-op outside Minitest (e.g. RSpec).
100
+ def increment_assertion_count
101
+ assert true if respond_to?(:assertions)
102
+ end
103
+
93
104
  # Temporarily adds dummy values for excepted parameters during validation
94
105
  # @see ExceptParameter
95
106
  def with_except_params(except)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Committee
4
- VERSION = '5.6.2'.freeze
4
+ VERSION = '5.6.4'.freeze
5
5
  end
@@ -94,6 +94,16 @@ describe Committee::Middleware::Options::ResponseValidation do
94
94
  end
95
95
  assert_equal "streaming_content_parsers must be a Hash", e.message
96
96
  end
97
+
98
+ it "sets strict_response_content_type option with default false" do
99
+ options = Committee::Middleware::Options::ResponseValidation.new(schema: hyper_schema)
100
+ assert_equal false, options.strict_response_content_type
101
+ end
102
+
103
+ it "sets strict_response_content_type option when provided" do
104
+ options = Committee::Middleware::Options::ResponseValidation.new(schema: hyper_schema, strict_response_content_type: true)
105
+ assert_equal true, options.strict_response_content_type
106
+ end
97
107
  end
98
108
 
99
109
  describe "#to_h" do
@@ -103,6 +113,11 @@ describe Committee::Middleware::Options::ResponseValidation do
103
113
  assert_equal true, hash[:strict]
104
114
  assert_equal false, hash[:validate_success_only]
105
115
  end
116
+
117
+ it "includes strict_response_content_type in hash" do
118
+ options = Committee::Middleware::Options::ResponseValidation.new(schema: hyper_schema, strict_response_content_type: true)
119
+ assert_equal true, options.to_h[:strict_response_content_type]
120
+ end
106
121
  end
107
122
 
108
123
  describe ".from" do
@@ -424,7 +424,7 @@ describe Committee::Middleware::RequestValidation do
424
424
  assert_equal env['committee.path_hash'][:integer], 84
425
425
  assert_equal env['committee.request_body_hash']['integer'], 21
426
426
  assert_equal env['committee.request_body_hash'][:integer], 21
427
- assert_equal env['committee.query_hash']['integer'], 84 # we can't use query_parameter :(
427
+ assert_equal env['committee.query_hash']['integer'], 42
428
428
  # assert_equal env['rack.request.query_hash'][:integer], 21 # this isn't hash indifferent hash because we use rack.request.query_hash
429
429
  [204, {}, []]
430
430
  end, schema: open_api_3_schema, parameter_overwrite_by_rails_rule: false)
@@ -478,7 +478,7 @@ describe Committee::Middleware::RequestValidation do
478
478
  assert_equal env['committee.params'][:integer], 84
479
479
  assert_equal env['committee.request_body_hash']['integer'], 21
480
480
  assert_equal env['committee.request_body_hash'][:integer], 21
481
- assert_equal env['committee.query_hash']['integer'], 84 # we can't use query_parameter :(
481
+ assert_equal env['committee.query_hash']['integer'], 42
482
482
  assert_equal env['committee.path_hash']['integer'], 84
483
483
  assert_equal env['committee.path_hash'][:integer], 84
484
484
  [204, {}, []]
@@ -660,6 +660,44 @@ describe Committee::Middleware::RequestValidation do
660
660
  end
661
661
  end
662
662
 
663
+ describe 'bracket-style query params' do
664
+ it 'validates query params declared with bracket notation names' do
665
+ check_parameter = lambda { |env|
666
+ assert_equal '/test', env['committee.query_hash']['filter[slug]']
667
+ refute env['committee.query_hash'].key?('filter')
668
+ [200, {}, []]
669
+ }
670
+
671
+ @app = new_rack_app_with_lambda(check_parameter, schema: query_param_schema(bracket_notation_query_parameter))
672
+
673
+ get '/events?filter[slug]=%2Ftest'
674
+
675
+ assert_equal 200, last_response.status
676
+ end
677
+
678
+ it 'rejects unknown nested query params with strict_query_params' do
679
+ @app = new_rack_app(schema: query_param_schema(bracket_notation_query_parameter), strict_query_params: true)
680
+
681
+ get '/events?filter[slug]=%2Ftest&filter[status]=active'
682
+
683
+ assert_equal 400, last_response.status
684
+ assert_match(/filter\[status\]/, last_response.body)
685
+ end
686
+
687
+ it 'continues to support deepObject query params from Rack nested hashes' do
688
+ check_parameter = lambda { |env|
689
+ assert_equal '/test', env['committee.query_hash']['filter']['slug']
690
+ [200, {}, []]
691
+ }
692
+
693
+ @app = new_rack_app_with_lambda(check_parameter, schema: query_param_schema(deep_object_query_parameter))
694
+
695
+ get '/events?filter[slug]=%2Ftest'
696
+
697
+ assert_equal 200, last_response.status
698
+ end
699
+ end
700
+
663
701
  private
664
702
 
665
703
  def new_rack_app(options = {})
@@ -674,4 +712,53 @@ describe Committee::Middleware::RequestValidation do
674
712
  run check_lambda
675
713
  }
676
714
  end
715
+
716
+ def query_param_schema(parameter)
717
+ Committee::Drivers.load_from_data(query_param_document(parameter), nil, parser_options: { strict_reference_validation: true })
718
+ end
719
+
720
+ def bracket_notation_query_parameter
721
+ {
722
+ 'name' => 'filter[slug]',
723
+ 'in' => 'query',
724
+ 'required' => true,
725
+ 'schema' => { 'type' => 'string' },
726
+ }
727
+ end
728
+
729
+ def deep_object_query_parameter
730
+ {
731
+ 'name' => 'filter',
732
+ 'in' => 'query',
733
+ 'required' => true,
734
+ 'style' => 'deepObject',
735
+ 'explode' => true,
736
+ 'schema' => {
737
+ 'type' => 'object',
738
+ 'required' => ['slug'],
739
+ 'properties' => {
740
+ 'slug' => { 'type' => 'string' },
741
+ },
742
+ },
743
+ }
744
+ end
745
+
746
+ def query_param_document(parameter)
747
+ {
748
+ 'openapi' => '3.0.3',
749
+ 'info' => { 'title' => 'test', 'version' => '1.0.0' },
750
+ 'paths' => {
751
+ '/events' => {
752
+ 'get' => {
753
+ 'parameters' => [parameter],
754
+ 'responses' => {
755
+ '200' => {
756
+ 'description' => 'ok',
757
+ },
758
+ },
759
+ },
760
+ },
761
+ },
762
+ }
763
+ end
677
764
  end
@@ -17,6 +17,29 @@ describe Committee::Middleware::ResponseValidation do
17
17
  assert_equal 200, last_response.status
18
18
  end
19
19
 
20
+ it "preserves a one-shot response body after validation" do
21
+ content = JSON.generate(CHARACTERS_RESPONSE)
22
+ chunks = [content]
23
+ each_calls = 0
24
+ body = Rack::BodyProxy.new(Enumerator.new do |yielder|
25
+ each_calls += 1
26
+ chunks.each { |chunk| yielder << chunk }
27
+ chunks.clear
28
+ end) {}
29
+ @app = Rack::Builder.new {
30
+ use Committee::Middleware::ResponseValidation, { schema: open_api_3_schema }
31
+ run ->(_) { [200, { "Content-Type" => "application/json" }, body] }
32
+ }
33
+
34
+ status, _headers, response_body = @app.call(Rack::MockRequest.env_for("/characters"))
35
+
36
+ assert_equal 200, status
37
+ assert_equal content, response_body.each.to_a.join
38
+ assert_equal 1, each_calls
39
+ response_body.close if response_body.respond_to?(:close)
40
+ assert body.closed?
41
+ end
42
+
20
43
  it "passes through a valid response with content-type (lower-case)" do
21
44
  status = 200
22
45
  headers = { "content-type" => "application/json" }
@@ -33,6 +56,13 @@ describe Committee::Middleware::ResponseValidation do
33
56
  assert_equal 200, last_response.status
34
57
  end
35
58
 
59
+ it "passes through a valid response with a +json content-type" do
60
+ @app = new_response_rack(JSON.generate(CHARACTERS_RESPONSE), { "Content-Type" => "application/problem+json; charset=utf-8" }, schema: open_api_3_schema, parse_response_by_content_type: true,)
61
+
62
+ get "/characters"
63
+ assert_equal 200, last_response.status
64
+ end
65
+
36
66
  it "passes through a invalid json" do
37
67
  @app = new_response_rack("not_json", {}, schema: open_api_3_schema)
38
68
 
@@ -296,6 +326,68 @@ describe Committee::Middleware::ResponseValidation do
296
326
  end
297
327
  end
298
328
 
329
+ describe "strict_response_content_type option" do
330
+ it "passes through with undeclared content type when strict_response_content_type: false (default)" do
331
+ @app = new_response_rack(JSON.generate(CHARACTERS_RESPONSE), { "Content-Type" => "application/vnd.api+json" }, schema: open_api_3_schema)
332
+ get "/characters"
333
+ assert_equal 200, last_response.status
334
+ end
335
+
336
+ it "raises with undeclared content type when strict_response_content_type: true" do
337
+ @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)
338
+ assert_raises(Committee::InvalidResponse) do
339
+ get "/characters"
340
+ end
341
+ end
342
+
343
+ it "returns 500 with undeclared content type when strict_response_content_type: true without raise" do
344
+ @app = new_response_rack(JSON.generate(CHARACTERS_RESPONSE), { "Content-Type" => "application/vnd.api+json" }, schema: open_api_3_schema, strict_response_content_type: true)
345
+ get "/characters"
346
+ assert_equal 500, last_response.status
347
+ end
348
+
349
+ it "passes through when response has no content schema with strict_response_content_type: true" do
350
+ @app = new_response_rack("", { "Content-Type" => "application/vnd.api+json" }, schema: open_api_3_schema, strict_response_content_type: true)
351
+ get "/no_content_schema"
352
+ assert_equal 200, last_response.status
353
+ end
354
+
355
+ it "passes through with declared content type and strict_response_content_type: true" do
356
+ @app = new_response_rack(JSON.generate(CHARACTERS_RESPONSE), { "Content-Type" => "application/json" }, schema: open_api_3_schema, strict_response_content_type: true)
357
+ get "/characters"
358
+ assert_equal 200, last_response.status
359
+ end
360
+
361
+ it "passes through with declared content type and charset param with strict_response_content_type: true" do
362
+ @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)
363
+ get "/characters"
364
+ assert_equal 200, last_response.status
365
+ end
366
+
367
+ it "raises with undeclared content type matched via wildcard status code with strict_response_content_type: true" do
368
+ @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 })
369
+ assert_raises(Committee::InvalidResponse) do
370
+ get "/wildcard_response"
371
+ end
372
+ end
373
+
374
+ it "raises with undeclared content type matched via default response with strict_response_content_type: true" do
375
+ @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 })
376
+ assert_raises(Committee::InvalidResponse) do
377
+ get "/default_response"
378
+ end
379
+ end
380
+
381
+ it "calls error_handler with strict_response_content_type: true and raise: false" do
382
+ called_err = nil
383
+ pr = ->(e, _env) { called_err = e }
384
+ @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)
385
+ get "/characters"
386
+ assert_equal 500, last_response.status
387
+ assert_kind_of Committee::InvalidResponse, called_err
388
+ end
389
+ end
390
+
299
391
  private
300
392
 
301
393
  def new_response_rack(response, headers = {}, options = {}, rack_options = {})
@@ -112,6 +112,22 @@ describe Committee::SchemaValidator::HyperSchema::ResponseValidator do
112
112
  assert_equal message, e.message
113
113
  end
114
114
 
115
+ it "validates OpenAPI 2 responses against the schema for their status" do
116
+ link = Committee::Drivers::OpenAPI2::Link.new
117
+ link.href = "/apps"
118
+ link.media_type = "application/json"
119
+ link.status_success = 200
120
+ success_schema = JsonSchema.parse!({ "type" => "object", "required" => ["id"], "properties" => { "id" => { "type" => "integer" } } })
121
+ error_schema = JsonSchema.parse!({ "type" => "object", "required" => ["error"], "properties" => { "error" => { "type" => "string" } } })
122
+ link.target_schemas = { 200 => success_schema, 400 => error_schema }
123
+ validator = Committee::SchemaValidator::HyperSchema::ResponseValidator.new(link, validate_success_only: false)
124
+
125
+ validator.call(200, @headers, { "id" => 1 })
126
+ validator.call(400, @headers, { "error" => "invalid request" })
127
+ assert_raises(Committee::InvalidResponse) { validator.call(200, @headers, { "error" => "invalid request" }) }
128
+ assert_raises(Committee::InvalidResponse) { validator.call(400, @headers, { "id" => 1, "error" => 1 }) }
129
+ end
130
+
115
131
  private
116
132
 
117
133
  def call
@@ -156,6 +156,63 @@ describe Committee::SchemaValidator::OpenAPI3::OperationWrapper do
156
156
  assert_kind_of(OpenAPIParser::OpenAPIError, e.original_error)
157
157
  end
158
158
 
159
+ describe 'same-named path and query parameters' do
160
+ %w[get post].each do |method|
161
+ describe method do
162
+ before do
163
+ @path = '/overwrite_same_parameter/123'
164
+ @method = method
165
+ data = open_api_3_data
166
+ path_item = data['paths']['/overwrite_same_parameter/{integer}']
167
+ path_item[method] = path_item['post']
168
+ @open_api_3_schema = Committee::Drivers.load_from_data(data, open_api_3_schema_path, parser_options: { strict_reference_validation: true })
169
+ end
170
+
171
+ it 'rejects an invalid query value without overwriting it with the path value' do
172
+ path_params = { 'integer' => 123 }
173
+ query_params = { 'integer' => 'not-an-integer' }
174
+
175
+ error = assert_raises(Committee::InvalidRequest) do
176
+ operation_object.validate_request_params(path_params, query_params, {}, HEADER, @validator_option)
177
+ end
178
+
179
+ assert_match(/expected integer, but received String: "not-an-integer"/i, error.message)
180
+ assert_kind_of(OpenAPIParser::OpenAPIError, error.original_error)
181
+ assert_equal({ 'integer' => 'not-an-integer' }, query_params)
182
+ assert_equal({ 'integer' => 123 }, path_params)
183
+ end
184
+
185
+ it 'rejects a missing required query parameter even when the path parameter is present' do
186
+ error = assert_raises(Committee::InvalidRequest) do
187
+ operation_object.validate_request_params({ 'integer' => 123 }, {}, {}, HEADER, @validator_option)
188
+ end
189
+
190
+ assert_match(/missing required parameters: integer/i, error.message)
191
+ end
192
+
193
+ it 'coerces the query value independently of the path value' do
194
+ path_params = { 'integer' => 123 }
195
+ query_params = { 'integer' => '456' }
196
+
197
+ operation_object.validate_request_params(path_params, query_params, {}, HEADER, @validator_option)
198
+
199
+ assert_equal({ 'integer' => 123 }, path_params)
200
+ assert_equal({ 'integer' => 456 }, query_params)
201
+ end
202
+
203
+ it 'honors disabled query coercion even when the path value is already an integer' do
204
+ options = Committee::SchemaValidator::Option.new({ coerce_query_params: false }, open_api_3_schema, :open_api_3)
205
+
206
+ error = assert_raises(Committee::InvalidRequest) do
207
+ operation_object.validate_request_params({ 'integer' => 123 }, { 'integer' => '456' }, {}, HEADER, options)
208
+ end
209
+
210
+ assert_match(/expected integer, but received String: "456"/i, error.message)
211
+ end
212
+ end
213
+ end
214
+ end
215
+
159
216
  describe '#content_types' do
160
217
  it 'returns supported content types' do
161
218
  @path = '/validate_content_types'
@@ -204,6 +261,62 @@ describe Committee::SchemaValidator::OpenAPI3::OperationWrapper do
204
261
  @path = '/characters'
205
262
  operation_object.validate_request_params({}, { "limit" => "1" }, {}, HEADER, query_coercion_enabled)
206
263
  end
264
+
265
+ it 'uses coerce_form_params for JSON request bodies too' do
266
+ @path = '/validate'
267
+ @method = 'post'
268
+
269
+ body_coercion_disabled = Committee::SchemaValidator::Option.new({ coerce_form_params: false }, open_api_3_schema, :open_api_3)
270
+ body_coercion_enabled = Committee::SchemaValidator::Option.new({ coerce_form_params: true }, open_api_3_schema, :open_api_3)
271
+
272
+ error = assert_raises(Committee::InvalidRequest) do
273
+ operation_object.validate_request_params({}, {}, { "integer" => "1" }, HEADER, body_coercion_disabled)
274
+ end
275
+ assert_match(/expected integer, but received String: "1"/i, error.message)
276
+
277
+ body_params = { "integer" => "1" }
278
+ operation_object.validate_request_params({}, {}, body_params, HEADER, body_coercion_enabled)
279
+
280
+ assert_kind_of(Integer, body_params["integer"])
281
+ end
282
+ end
283
+
284
+ describe '#find_response_object_for_status' do
285
+ def responses_for(path, method = 'get')
286
+ open_api_3_schema.operation_object(path, method).request_operation.operation_object.responses
287
+ end
288
+
289
+ def find(path, status, method = 'get')
290
+ wrapper = open_api_3_schema.operation_object(path, method)
291
+ wrapper.send(:find_response_object_for_status, responses_for(path, method), status)
292
+ end
293
+
294
+ it 'returns nil when responses is nil' do
295
+ wrapper = open_api_3_schema.operation_object('/characters', 'get')
296
+ assert_nil wrapper.send(:find_response_object_for_status, nil, 200)
297
+ end
298
+
299
+ it 'returns exact status code match' do
300
+ result = find('/characters', 200)
301
+ assert_kind_of OpenAPIParser::Schemas::Response, result
302
+ assert result.content.key?('application/json')
303
+ end
304
+
305
+ it 'returns wildcard match when exact status not defined' do
306
+ result = find('/wildcard_response', 400)
307
+ assert_kind_of OpenAPIParser::Schemas::Response, result
308
+ assert result.content.key?('application/json')
309
+ end
310
+
311
+ it 'returns default when no exact or wildcard match' do
312
+ result = find('/default_response', 500)
313
+ assert_kind_of OpenAPIParser::Schemas::Response, result
314
+ assert result.content.key?('application/json')
315
+ end
316
+
317
+ it 'returns nil when no exact, wildcard, or default match' do
318
+ assert_nil find('/characters', 999)
319
+ end
207
320
  end
208
321
  end
209
322
  end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ describe Committee::SchemaValidator::OpenAPI3 do
6
+ before do
7
+ @router = open_api_3_schema.build_router(schema: open_api_3_schema)
8
+ end
9
+
10
+ def validator_for(path, method)
11
+ request = Rack::Request.new({ "REQUEST_METHOD" => method, "PATH_INFO" => path, "rack.input" => StringIO.new("") })
12
+ @router.build_schema_validator(request)
13
+ end
14
+
15
+ describe "#operation_object" do
16
+ it "returns the matched operation wrapper" do
17
+ operation_object = validator_for("/validate", "POST").operation_object
18
+
19
+ assert_kind_of Committee::SchemaValidator::OpenAPI3::OperationWrapper, operation_object
20
+ assert_equal "/validate", operation_object.original_path
21
+ end
22
+
23
+ it "returns nil when the request matches no operation" do
24
+ assert_nil validator_for("/no-such-path", "GET").operation_object
25
+ end
26
+ end
27
+ end
@@ -21,6 +21,39 @@ describe Committee::SchemaValidator do
21
21
  assert_equal 'multipart/form-data', media_type
22
22
  end
23
23
 
24
+ it "detects application/json as a JSON media type" do
25
+ assert_equal true, Committee::SchemaValidator.json_media_type?("application/json")
26
+ end
27
+
28
+ it "detects application/problem+json with parameters as a JSON media type" do
29
+ assert_equal true, Committee::SchemaValidator.json_media_type?("application/problem+json; charset=utf-8")
30
+ end
31
+
32
+ it "detects application/vnd.api+json as a JSON media type" do
33
+ assert_equal true, Committee::SchemaValidator.json_media_type?("application/vnd.api+json")
34
+ end
35
+
36
+ it "does not detect application/x-ndjson as a JSON media type" do
37
+ assert_equal false, Committee::SchemaValidator.json_media_type?("application/x-ndjson")
38
+ end
39
+
40
+ it "does not detect non-JSON content types as JSON media types" do
41
+ assert_equal false, Committee::SchemaValidator.json_media_type?("test/csv")
42
+ assert_equal false, Committee::SchemaValidator.json_media_type?(nil)
43
+ end
44
+
45
+ it "parses +json responses in the HyperSchema validator" do
46
+ schema = Committee::Drivers::OpenAPI2::Driver.new.parse(open_api_2_data)
47
+ validator_option = Committee::SchemaValidator::Option.new({ parse_response_by_content_type: true }, schema, :hyper_schema)
48
+ router = Committee::SchemaValidator::HyperSchema::Router.new(schema, validator_option)
49
+ request = Rack::Request.new({ "REQUEST_METHOD" => "GET", "PATH_INFO" => "/api/pets", "rack.input" => StringIO.new("") })
50
+ validator = Committee::SchemaValidator::HyperSchema.new(router, request, validator_option)
51
+
52
+ validator.link.media_type = "application/vnd.api+json"
53
+
54
+ validator.response_validate(200, { "Content-Type" => "application/vnd.api+json" }, [JSON.generate([ValidPet])])
55
+ end
56
+
24
57
  it "builds prefix regexp with a path segment boundary" do
25
58
  regexp = Committee::SchemaValidator.build_prefix_regexp("/v1")
26
59
 
@@ -123,6 +123,18 @@ describe Committee::Test::Methods do
123
123
  assert_schema_conform(200)
124
124
  end
125
125
 
126
+ it "uses each request's operation for consecutive responses" do
127
+ responses = { "/characters" => JSON.generate(@correct_response), "/validate_response_array" => JSON.generate(["honoka"]), }
128
+ @app = Rack::Builder.new {
129
+ run ->(env) { [200, { "Content-Type" => "application/json" }, [responses.fetch(env["PATH_INFO"])]] }
130
+ }
131
+
132
+ get "/characters"
133
+ assert_schema_conform(200)
134
+ get "/validate_response_array"
135
+ assert_schema_conform(200)
136
+ end
137
+
126
138
  it "detects an invalid response Content-Type" do
127
139
  @app = new_rack_app(JSON.generate([@correct_response]), {})
128
140
  get "/characters"
@@ -151,6 +163,14 @@ describe Committee::Test::Methods do
151
163
  assert_request_schema_confirm
152
164
  end
153
165
 
166
+ it "increments the Minitest assertion count on success" do
167
+ @app = new_rack_app
168
+ get "/characters"
169
+ before_count = assertions
170
+ assert_request_schema_confirm
171
+ assert_equal before_count + 1, assertions
172
+ end
173
+
154
174
  it "not exist required" do
155
175
  @app = new_rack_app
156
176
  get "/validate", { "query_string" => "query", "query_integer_list" => [1, 2] }
@@ -397,6 +417,14 @@ describe Committee::Test::Methods do
397
417
  assert_response_schema_confirm(200)
398
418
  end
399
419
 
420
+ it "increments the Minitest assertion count on success" do
421
+ @app = new_rack_app(JSON.generate(@correct_response))
422
+ get "/characters"
423
+ before_count = assertions
424
+ assert_response_schema_confirm(200)
425
+ assert_equal before_count + 1, assertions
426
+ end
427
+
400
428
  it "detects an invalid response Content-Type" do
401
429
  @app = new_rack_app(JSON.generate([@correct_response]), {})
402
430
  get "/characters"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: committee
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.6.2
4
+ version: 5.6.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brandur
@@ -249,6 +249,7 @@ files:
249
249
  - test/schema_validator/open_api_3/parameter_deserializer_test.rb
250
250
  - test/schema_validator/open_api_3/request_validator_test.rb
251
251
  - test/schema_validator/open_api_3/response_validator_test.rb
252
+ - test/schema_validator/open_api_3_test.rb
252
253
  - test/schema_validator_test.rb
253
254
  - test/test/methods_new_version_test.rb
254
255
  - test/test/methods_test.rb