committee 5.6.2 → 5.6.3

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: 694da9f92c8670bbe39fa64e209c43da029c58262ebbc249cff4512dee7e7aed
4
+ data.tar.gz: 2c3ffa46d5c827471545b585421a45e5c7781b9d1ddb1d861ba9bfd12a1fddd8
5
5
  SHA512:
6
- metadata.gz: 48819e9df56d4ad78afc3e4ccd6992eba3570a3e3ec39570354b8ab3777e73d4ef75f5554cfb2e3bf173b91d645c0015173c465e64712f87d4ef22ad02738b7e
7
- data.tar.gz: f3e72af3ca96804db1b07f0b28002367bee84d6557f1725c0244fee839757a940c7aa89e6373952559109c273f5ec721759be89d8f98517234bd3d00bdba17ce
6
+ metadata.gz: fc906473afe3beef06fe6f34b96ad3c29566761a1806dff3ed5fabe5d5b477bdbebb49216b5039e533316e0271957a30812f791c5e4ea8a306cf8613abdb8772
7
+ data.tar.gz: ffa5ad6a180270f703d13fa2dacebe534c0a9c76bc5be9d4a27eac1f07e9592813bfa81d74712b63d5ff9502ebfde97cd171cca3abb2376f4f2a9865d4f11614
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.3'.freeze
5
5
  end
@@ -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
@@ -33,6 +33,13 @@ describe Committee::Middleware::ResponseValidation do
33
33
  assert_equal 200, last_response.status
34
34
  end
35
35
 
36
+ it "passes through a valid response with a +json content-type" do
37
+ @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,)
38
+
39
+ get "/characters"
40
+ assert_equal 200, last_response.status
41
+ end
42
+
36
43
  it "passes through a invalid json" do
37
44
  @app = new_response_rack("not_json", {}, schema: open_api_3_schema)
38
45
 
@@ -204,6 +204,24 @@ describe Committee::SchemaValidator::OpenAPI3::OperationWrapper do
204
204
  @path = '/characters'
205
205
  operation_object.validate_request_params({}, { "limit" => "1" }, {}, HEADER, query_coercion_enabled)
206
206
  end
207
+
208
+ it 'uses coerce_form_params for JSON request bodies too' do
209
+ @path = '/validate'
210
+ @method = 'post'
211
+
212
+ body_coercion_disabled = Committee::SchemaValidator::Option.new({ coerce_form_params: false }, open_api_3_schema, :open_api_3)
213
+ body_coercion_enabled = Committee::SchemaValidator::Option.new({ coerce_form_params: true }, open_api_3_schema, :open_api_3)
214
+
215
+ error = assert_raises(Committee::InvalidRequest) do
216
+ operation_object.validate_request_params({}, {}, { "integer" => "1" }, HEADER, body_coercion_disabled)
217
+ end
218
+ assert_match(/expected integer, but received String: "1"/i, error.message)
219
+
220
+ body_params = { "integer" => "1" }
221
+ operation_object.validate_request_params({}, {}, body_params, HEADER, body_coercion_enabled)
222
+
223
+ assert_kind_of(Integer, body_params["integer"])
224
+ end
207
225
  end
208
226
  end
209
227
  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
 
@@ -151,6 +151,14 @@ describe Committee::Test::Methods do
151
151
  assert_request_schema_confirm
152
152
  end
153
153
 
154
+ it "increments the Minitest assertion count on success" do
155
+ @app = new_rack_app
156
+ get "/characters"
157
+ before_count = assertions
158
+ assert_request_schema_confirm
159
+ assert_equal before_count + 1, assertions
160
+ end
161
+
154
162
  it "not exist required" do
155
163
  @app = new_rack_app
156
164
  get "/validate", { "query_string" => "query", "query_integer_list" => [1, 2] }
@@ -397,6 +405,14 @@ describe Committee::Test::Methods do
397
405
  assert_response_schema_confirm(200)
398
406
  end
399
407
 
408
+ it "increments the Minitest assertion count on success" do
409
+ @app = new_rack_app(JSON.generate(@correct_response))
410
+ get "/characters"
411
+ before_count = assertions
412
+ assert_response_schema_confirm(200)
413
+ assert_equal before_count + 1, assertions
414
+ end
415
+
400
416
  it "detects an invalid response Content-Type" do
401
417
  @app = new_rack_app(JSON.generate([@correct_response]), {})
402
418
  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.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brandur
@@ -277,7 +277,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
277
277
  - !ruby/object:Gem::Version
278
278
  version: '0'
279
279
  requirements: []
280
- rubygems_version: 4.0.3
280
+ rubygems_version: 3.6.9
281
281
  specification_version: 4
282
282
  summary: A collection of Rack middleware to support JSON Schema.
283
283
  test_files: []