committee 5.6.1 → 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 +4 -4
- data/lib/committee/drivers/open_api_3/driver.rb +7 -1
- data/lib/committee/drivers.rb +2 -3
- data/lib/committee/errors.rb +11 -0
- data/lib/committee/middleware/base.rb +11 -5
- data/lib/committee/middleware/options/base.rb +107 -0
- data/lib/committee/middleware/options/request_validation.rb +80 -0
- data/lib/committee/middleware/options/response_validation.rb +46 -0
- data/lib/committee/middleware/options.rb +12 -0
- data/lib/committee/middleware/request_validation.rb +7 -1
- data/lib/committee/middleware/response_validation.rb +6 -2
- data/lib/committee/middleware.rb +1 -0
- data/lib/committee/schema_validator/hyper_schema/response_generator.rb +1 -3
- data/lib/committee/schema_validator/hyper_schema/response_validator.rb +14 -1
- data/lib/committee/schema_validator/hyper_schema/router.rb +1 -1
- data/lib/committee/schema_validator/hyper_schema.rb +3 -14
- data/lib/committee/schema_validator/open_api_3/operation_wrapper.rb +43 -13
- data/lib/committee/schema_validator/open_api_3/parameter_deserializer.rb +556 -0
- data/lib/committee/schema_validator/open_api_3/response_validator.rb +16 -2
- data/lib/committee/schema_validator/open_api_3.rb +19 -13
- data/lib/committee/schema_validator/option.rb +6 -17
- data/lib/committee/schema_validator.rb +12 -1
- data/lib/committee/test/except_parameter.rb +416 -0
- data/lib/committee/test/methods.rb +38 -2
- data/lib/committee/version.rb +1 -1
- data/lib/committee.rb +1 -1
- data/test/drivers/open_api_2/driver_test.rb +4 -16
- data/test/drivers/open_api_2/parameter_schema_builder_test.rb +4 -50
- data/test/drivers_test.rb +35 -21
- data/test/middleware/options/base_test.rb +120 -0
- data/test/middleware/options/request_validation_test.rb +177 -0
- data/test/middleware/options/response_validation_test.rb +121 -0
- data/test/middleware/request_validation_open_api_3_test.rb +200 -80
- data/test/middleware/request_validation_test.rb +13 -70
- data/test/middleware/response_validation_open_api_3_test.rb +40 -17
- data/test/middleware/response_validation_test.rb +3 -14
- data/test/request_unpacker_test.rb +2 -10
- data/test/schema_validator/hyper_schema/parameter_coercer_test.rb +1 -37
- data/test/schema_validator/hyper_schema/request_validator_test.rb +6 -30
- data/test/schema_validator/hyper_schema/router_test.rb +5 -0
- data/test/schema_validator/hyper_schema/string_params_coercer_test.rb +1 -37
- data/test/schema_validator/open_api_3/operation_wrapper_test.rb +58 -43
- data/test/schema_validator/open_api_3/parameter_deserializer_test.rb +457 -0
- data/test/schema_validator/open_api_3/request_validator_test.rb +1 -2
- data/test/schema_validator/open_api_3/response_validator_test.rb +3 -11
- data/test/schema_validator_test.rb +41 -0
- data/test/test/methods_test.rb +238 -105
- data/test/test/schema_coverage_test.rb +8 -155
- metadata +11 -1
|
@@ -4,23 +4,7 @@ module Committee
|
|
|
4
4
|
module SchemaValidator
|
|
5
5
|
class Option
|
|
6
6
|
# Boolean Options
|
|
7
|
-
attr_reader :allow_blank_structures,
|
|
8
|
-
:allow_empty_date_and_datetime,
|
|
9
|
-
:allow_form_params,
|
|
10
|
-
:allow_get_body,
|
|
11
|
-
:allow_query_params,
|
|
12
|
-
:allow_non_get_query_params,
|
|
13
|
-
:check_content_type,
|
|
14
|
-
:check_header,
|
|
15
|
-
:coerce_date_times,
|
|
16
|
-
:coerce_form_params,
|
|
17
|
-
:coerce_path_params,
|
|
18
|
-
:coerce_query_params,
|
|
19
|
-
:coerce_recursive,
|
|
20
|
-
:optimistic_json,
|
|
21
|
-
:validate_success_only,
|
|
22
|
-
:parse_response_by_content_type,
|
|
23
|
-
:parameter_overwrite_by_rails_rule
|
|
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
|
|
24
8
|
|
|
25
9
|
# Non-boolean options:
|
|
26
10
|
attr_reader :headers_key, :params_key, :query_hash_key, :request_body_hash_key, :path_hash_key, :prefix
|
|
@@ -44,8 +28,10 @@ module Committee
|
|
|
44
28
|
@check_content_type = options.fetch(:check_content_type, true)
|
|
45
29
|
@check_header = options.fetch(:check_header, true)
|
|
46
30
|
@coerce_recursive = options.fetch(:coerce_recursive, true)
|
|
31
|
+
@coerce_response_values = options.fetch(:coerce_response_values, false)
|
|
47
32
|
@optimistic_json = options.fetch(:optimistic_json, false)
|
|
48
33
|
@parse_response_by_content_type = options.fetch(:parse_response_by_content_type, true)
|
|
34
|
+
@strict_query_params = options.fetch(:strict_query_params, false)
|
|
49
35
|
|
|
50
36
|
@parameter_overwrite_by_rails_rule =
|
|
51
37
|
if options.key?(:parameter_overwite_by_rails_rule)
|
|
@@ -61,6 +47,9 @@ module Committee
|
|
|
61
47
|
@coerce_form_params = options.fetch(:coerce_form_params, schema.driver.default_coerce_form_params)
|
|
62
48
|
@coerce_path_params = options.fetch(:coerce_path_params, schema.driver.default_path_params)
|
|
63
49
|
@coerce_query_params = options.fetch(:coerce_query_params, schema.driver.default_query_params)
|
|
50
|
+
@deserialize_parameters = options.fetch(:deserialize_parameters,
|
|
51
|
+
schema.driver.respond_to?(:default_deserialize_parameters) ?
|
|
52
|
+
schema.driver.default_deserialize_parameters : false)
|
|
64
53
|
@validate_success_only = options.fetch(:validate_success_only, schema.driver.default_validate_success_only)
|
|
65
54
|
end
|
|
66
55
|
end
|
|
@@ -2,17 +2,28 @@
|
|
|
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)
|
|
13
20
|
return nil unless prefix
|
|
14
21
|
|
|
15
|
-
|
|
22
|
+
if prefix == "/" || prefix.end_with?("/")
|
|
23
|
+
/\A#{Regexp.escape(prefix)}/.freeze
|
|
24
|
+
else
|
|
25
|
+
/\A#{Regexp.escape(prefix)}(?=\/|\z)/.freeze
|
|
26
|
+
end
|
|
16
27
|
end
|
|
17
28
|
end
|
|
18
29
|
end
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Committee
|
|
4
|
+
module Test
|
|
5
|
+
# Handles temporary parameter exclusion during schema validation.
|
|
6
|
+
# Allows testing error responses without validation failures for intentionally missing parameters.
|
|
7
|
+
class ExceptParameter
|
|
8
|
+
FORMAT_DUMMIES = { 'date-time': '2000-01-01T00:00:00Z', date: '2000-01-01', email: 'dummy@example.com', uuid: '00000000-0000-0000-0000-000000000000' }.freeze
|
|
9
|
+
|
|
10
|
+
# @param [Rack::Request] request The request object
|
|
11
|
+
# @param [Hash] committee_options Committee options hash
|
|
12
|
+
def initialize(request, committee_options)
|
|
13
|
+
@request = request
|
|
14
|
+
@committee_options = committee_options
|
|
15
|
+
@handlers = {}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Apply dummy values to excepted parameters
|
|
19
|
+
# @param [Hash] except Hash of parameter types to parameter names
|
|
20
|
+
# (e.g., { headers: ['authorization'], query: ['page'] })
|
|
21
|
+
# @return [void]
|
|
22
|
+
def apply(except)
|
|
23
|
+
except.each do |param_type, param_names|
|
|
24
|
+
handler = (@handlers[param_type] ||= handler_for(param_type))
|
|
25
|
+
handler&.apply(param_names)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Restore original parameter values
|
|
30
|
+
# @return [void]
|
|
31
|
+
def restore
|
|
32
|
+
@handlers.each_value(&:restore)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def handler_for(param_type)
|
|
38
|
+
case param_type
|
|
39
|
+
when :headers then HeaderHandler.new(@request, @committee_options)
|
|
40
|
+
when :query then QueryHandler.new(@request, @committee_options)
|
|
41
|
+
when :body then BodyHandler.new(@request, @committee_options)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Shared helpers for looking up OpenAPI3 parameter schemas and generating
|
|
46
|
+
# type/format/enum-aware dummy values encoded as HTTP strings.
|
|
47
|
+
#
|
|
48
|
+
# Included by BaseHashParameterHandler, HeaderHandler, and BodyHandler.
|
|
49
|
+
module StringDummyLookup
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
# Resolve the OpenAPI3 operation object for the current request.
|
|
53
|
+
# Returns nil for non-OpenAPI3 schemas or any lookup failure.
|
|
54
|
+
def resolve_operation
|
|
55
|
+
schema = Committee::Middleware::Base.get_schema(@committee_options)
|
|
56
|
+
return nil unless schema.is_a?(Committee::Drivers::OpenAPI3::Schema)
|
|
57
|
+
|
|
58
|
+
path = @request.path_info
|
|
59
|
+
if (prefix = @committee_options[:prefix])
|
|
60
|
+
path = path.gsub(Regexp.new("\\A#{Regexp.escape(prefix)}"), '')
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
schema.operation_object(path, @request.request_method.downcase)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Find the OpenAPI3 schema object for a parameter by name and location.
|
|
67
|
+
# Returns nil for non-OpenAPI3 schemas or any lookup failure.
|
|
68
|
+
#
|
|
69
|
+
# Searches both operation-level and path item-level parameters (OpenAPI 3 allows
|
|
70
|
+
# parameters to be declared on the path item and shared across operations).
|
|
71
|
+
# Operation-level parameters take precedence per the OpenAPI spec.
|
|
72
|
+
def find_parameter_schema(key, location)
|
|
73
|
+
operation = resolve_operation
|
|
74
|
+
return nil unless operation
|
|
75
|
+
|
|
76
|
+
op_object = operation.request_operation.operation_object
|
|
77
|
+
# Merge operation-level and path item-level parameters; operation level first
|
|
78
|
+
# so that overrides are respected when both define the same parameter.
|
|
79
|
+
params = Array(op_object&.parameters) + Array(op_object&.parent&.parameters)
|
|
80
|
+
params.find { |p| p.name&.casecmp?(key.to_s) && p.in == location }&.schema
|
|
81
|
+
rescue StandardError
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Return a type-appropriate dummy value encoded as a String (or Array of strings
|
|
86
|
+
# for array-typed query/path params). The value must be coerceable to the
|
|
87
|
+
# expected schema type by openapi_parser's coerce_value option.
|
|
88
|
+
def string_dummy_for_schema(key, param_schema)
|
|
89
|
+
return "dummy-#{key}" unless param_schema
|
|
90
|
+
return param_schema.enum.first.to_s if param_schema.enum&.any?
|
|
91
|
+
|
|
92
|
+
case param_schema.type
|
|
93
|
+
when 'integer', 'number' then '0'
|
|
94
|
+
when 'boolean' then 'true'
|
|
95
|
+
when 'array' then ['0']
|
|
96
|
+
when 'string' then string_format_dummy(key, param_schema.format)
|
|
97
|
+
else "dummy-#{key}"
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Return a string that satisfies common OpenAPI3 string format constraints.
|
|
102
|
+
def string_format_dummy(key, format)
|
|
103
|
+
FORMAT_DUMMIES.fetch(format&.to_sym, "dummy-#{key}")
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Base handler for parameters stored in hash-like structures.
|
|
108
|
+
# Subclasses implement #get_storage and optionally #dummy_value_for.
|
|
109
|
+
class BaseHashParameterHandler
|
|
110
|
+
include StringDummyLookup
|
|
111
|
+
|
|
112
|
+
# @param [Rack::Request] request The request object
|
|
113
|
+
# @param [Hash] committee_options Committee options hash
|
|
114
|
+
def initialize(request, committee_options)
|
|
115
|
+
@request = request
|
|
116
|
+
@committee_options = committee_options
|
|
117
|
+
@original_values = {}
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Apply dummy values to parameters
|
|
121
|
+
# @param [Array<String, Symbol>] param_names Parameter names to except
|
|
122
|
+
# @return [void]
|
|
123
|
+
def apply(param_names)
|
|
124
|
+
storage = get_storage
|
|
125
|
+
return unless storage
|
|
126
|
+
|
|
127
|
+
param_names.each do |param_name|
|
|
128
|
+
key = param_name.to_s
|
|
129
|
+
@original_values[key] = storage[key]
|
|
130
|
+
storage[key] ||= dummy_value_for(key)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Restore original parameter values
|
|
135
|
+
# @return [void]
|
|
136
|
+
def restore
|
|
137
|
+
storage = get_storage
|
|
138
|
+
return unless storage
|
|
139
|
+
|
|
140
|
+
@original_values.each do |key, value|
|
|
141
|
+
value.nil? ? storage.delete(key) : storage[key] = value
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private
|
|
146
|
+
|
|
147
|
+
# Override in subclasses to specify the storage location
|
|
148
|
+
# @return [Hash, nil]
|
|
149
|
+
def get_storage
|
|
150
|
+
raise NotImplementedError, "#{self.class} must implement #get_storage"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def dummy_value_for(key)
|
|
154
|
+
"dummy-#{key}"
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Handler for request headers
|
|
159
|
+
class HeaderHandler
|
|
160
|
+
include StringDummyLookup
|
|
161
|
+
|
|
162
|
+
# In Rack/CGI, Content-Type and Content-Length are stored without the
|
|
163
|
+
# HTTP_ prefix. All other headers use the HTTP_ prefix convention.
|
|
164
|
+
SPECIAL_RACK_HEADERS = { 'content-type' => 'CONTENT_TYPE', 'content-length' => 'CONTENT_LENGTH', }.freeze
|
|
165
|
+
private_constant :SPECIAL_RACK_HEADERS
|
|
166
|
+
|
|
167
|
+
# @param [Rack::Request] request The request object
|
|
168
|
+
# @param [Hash] committee_options Committee options hash
|
|
169
|
+
def initialize(request, committee_options)
|
|
170
|
+
@request = request
|
|
171
|
+
@committee_options = committee_options
|
|
172
|
+
@original_values = {}
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Apply dummy values to header parameters
|
|
176
|
+
# @param [Array<String, Symbol>] param_names Header names to except
|
|
177
|
+
# @return [void]
|
|
178
|
+
def apply(param_names)
|
|
179
|
+
param_names.each do |param_name|
|
|
180
|
+
key = rack_header_key(param_name.to_s)
|
|
181
|
+
@original_values[key] = @request.env[key]
|
|
182
|
+
@request.env[key] ||= string_dummy_for_schema(param_name.to_s, find_parameter_schema(param_name.to_s, 'header'))
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Restore original header values
|
|
187
|
+
# @return [void]
|
|
188
|
+
def restore
|
|
189
|
+
@original_values.each do |key, value|
|
|
190
|
+
value.nil? ? @request.env.delete(key) : @request.env[key] = value
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
private
|
|
195
|
+
|
|
196
|
+
def rack_header_key(name)
|
|
197
|
+
SPECIAL_RACK_HEADERS[name.downcase] || "HTTP_#{name.upcase.tr('-', '_')}"
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Handler for query parameters
|
|
202
|
+
class QueryHandler < BaseHashParameterHandler
|
|
203
|
+
private
|
|
204
|
+
|
|
205
|
+
def get_storage
|
|
206
|
+
# Calling request.GET ensures Rack parses the query string into
|
|
207
|
+
# rack.request.query_hash before we inject dummy values, so that
|
|
208
|
+
# any existing query parameters are preserved.
|
|
209
|
+
@request.GET
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def dummy_value_for(key)
|
|
213
|
+
string_dummy_for_schema(key, find_parameter_schema(key, 'query'))
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Handler for request body parameters
|
|
218
|
+
#
|
|
219
|
+
# Supports three content types:
|
|
220
|
+
# - application/json (and variants): replaces rack.input stream so that
|
|
221
|
+
# request_unpack re-parses the modified body during validation.
|
|
222
|
+
# - application/x-www-form-urlencoded / multipart/form-data: pre-populates
|
|
223
|
+
# rack.request.form_hash, which Rack returns directly from request.POST
|
|
224
|
+
# without re-parsing the raw body.
|
|
225
|
+
# - Other content types (e.g. binary): no-op, as RequestUnpacker does not
|
|
226
|
+
# extract named parameters from them.
|
|
227
|
+
class BodyHandler
|
|
228
|
+
include StringDummyLookup
|
|
229
|
+
|
|
230
|
+
# @param [Rack::Request] request The request object
|
|
231
|
+
# @param [Hash] committee_options Committee options hash
|
|
232
|
+
def initialize(request, committee_options)
|
|
233
|
+
@request = request
|
|
234
|
+
@committee_options = committee_options
|
|
235
|
+
@original_body = nil
|
|
236
|
+
@original_form_values = nil
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Apply dummy values to body parameters based on content type.
|
|
240
|
+
# @param [Array<String, Symbol>] param_names Body parameter names to except
|
|
241
|
+
# @return [void]
|
|
242
|
+
def apply(param_names)
|
|
243
|
+
if json_content_type?
|
|
244
|
+
apply_json(param_names)
|
|
245
|
+
elsif form_content_type?
|
|
246
|
+
apply_form(param_names)
|
|
247
|
+
end
|
|
248
|
+
# Other content types: no-op
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Restore original body content
|
|
252
|
+
# @return [void]
|
|
253
|
+
def restore
|
|
254
|
+
restore_json if @original_body
|
|
255
|
+
restore_form if @original_form_values
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
private
|
|
259
|
+
|
|
260
|
+
def json_content_type?
|
|
261
|
+
mt = @request.media_type
|
|
262
|
+
mt.nil? || mt.match?(%r{application/(?:.*\+)?json})
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def form_content_type?
|
|
266
|
+
mt = @request.media_type
|
|
267
|
+
mt == 'application/x-www-form-urlencoded' || mt&.start_with?('multipart/form-data')
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# --- JSON body handling ---
|
|
271
|
+
#
|
|
272
|
+
# Replaces rack.input with a modified JSON body. Also clears any stale
|
|
273
|
+
# rack.request.form_hash / rack.request.form_pairs caches so that Rack
|
|
274
|
+
# does not return outdated form data if POST is called after the swap.
|
|
275
|
+
|
|
276
|
+
def apply_json(param_names)
|
|
277
|
+
original_body = read_body
|
|
278
|
+
body_hash = parse_body(original_body)
|
|
279
|
+
|
|
280
|
+
# Non-Hash bodies (arrays, scalars) cannot have named fields injected.
|
|
281
|
+
# Skip injection and let the schema validator report the type mismatch.
|
|
282
|
+
return unless body_hash.is_a?(Hash)
|
|
283
|
+
|
|
284
|
+
# Commit state only after successful parse so that restore_json is not
|
|
285
|
+
# triggered unnecessarily when parse_body raises (e.g. invalid JSON).
|
|
286
|
+
@original_body = original_body
|
|
287
|
+
@saved_form_hash = @request.env.delete('rack.request.form_hash')
|
|
288
|
+
@saved_form_pairs = @request.env.delete('rack.request.form_pairs')
|
|
289
|
+
|
|
290
|
+
param_names.each do |param_name|
|
|
291
|
+
key = param_name.to_s
|
|
292
|
+
body_hash[key] = dummy_value_for(key) if body_hash[key].nil?
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
replace_body(JSON.generate(body_hash))
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def restore_json
|
|
299
|
+
replace_body(@original_body)
|
|
300
|
+
@request.env.delete(body_hash_key)
|
|
301
|
+
@request.env['rack.request.form_hash'] = @saved_form_hash if @saved_form_hash
|
|
302
|
+
@request.env['rack.request.form_pairs'] = @saved_form_pairs if @saved_form_pairs
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# --- Form body handling ---
|
|
306
|
+
#
|
|
307
|
+
# Calls request.POST to trigger Rack's form parsing (populating
|
|
308
|
+
# rack.request.form_hash from rack.input), then injects dummy values
|
|
309
|
+
# for missing params into the live hash. In Rack 3.x, request.POST
|
|
310
|
+
# returns rack.request.form_hash directly on subsequent calls, so the
|
|
311
|
+
# injected values are visible during validation.
|
|
312
|
+
|
|
313
|
+
def apply_form(param_names)
|
|
314
|
+
@request.body&.rewind
|
|
315
|
+
form_hash = @request.POST
|
|
316
|
+
@original_form_values = {}
|
|
317
|
+
param_names.each do |param_name|
|
|
318
|
+
key = param_name.to_s
|
|
319
|
+
@original_form_values[key] = form_hash[key]
|
|
320
|
+
form_hash[key] ||= form_dummy_for(key)
|
|
321
|
+
end
|
|
322
|
+
rescue StandardError
|
|
323
|
+
# If form parsing fails (e.g. malformed body), skip remaining injection.
|
|
324
|
+
# Use ||= so that any originals already saved in the loop are preserved
|
|
325
|
+
# and can be correctly restored by restore_form.
|
|
326
|
+
@original_form_values ||= {}
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def restore_form
|
|
330
|
+
form_hash = @request.env['rack.request.form_hash']
|
|
331
|
+
return unless form_hash
|
|
332
|
+
|
|
333
|
+
@original_form_values.each do |key, value|
|
|
334
|
+
value.nil? ? form_hash.delete(key) : form_hash[key] = value
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# --- Dummy value helpers ---
|
|
339
|
+
|
|
340
|
+
def body_hash_key
|
|
341
|
+
@committee_options.fetch(:request_body_hash_key, 'committee.request_body_hash')
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def read_body
|
|
345
|
+
return '' unless @request.body
|
|
346
|
+
|
|
347
|
+
@request.body.rewind
|
|
348
|
+
body = @request.body.read
|
|
349
|
+
@request.body.rewind
|
|
350
|
+
body || ''
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def parse_body(body_str)
|
|
354
|
+
return {} if body_str.nil? || body_str.empty?
|
|
355
|
+
|
|
356
|
+
JSON.parse(body_str)
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def replace_body(body_str)
|
|
360
|
+
@request.env['rack.input'] = StringIO.new(body_str)
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# Returns a native-typed dummy value for JSON bodies.
|
|
364
|
+
def dummy_value_for(key)
|
|
365
|
+
prop_schema = body_param_schema(key)
|
|
366
|
+
return "dummy-#{key}" unless prop_schema
|
|
367
|
+
|
|
368
|
+
return prop_schema.enum.first if prop_schema.enum&.any?
|
|
369
|
+
|
|
370
|
+
case prop_schema.type
|
|
371
|
+
when 'integer' then 0
|
|
372
|
+
when 'number' then 0.0
|
|
373
|
+
when 'boolean' then false
|
|
374
|
+
when 'array' then []
|
|
375
|
+
when 'object' then {}
|
|
376
|
+
when 'string' then FORMAT_DUMMIES.fetch(prop_schema.format&.to_sym, "dummy-#{key}")
|
|
377
|
+
else "dummy-#{key}"
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# Returns a string-encoded dummy value for form bodies.
|
|
382
|
+
# Form data is always transmitted as strings; openapi_parser's coerce_value
|
|
383
|
+
# handles conversion to the declared type during validation.
|
|
384
|
+
def form_dummy_for(key)
|
|
385
|
+
prop_schema = body_param_schema(key)
|
|
386
|
+
return "dummy-#{key}" unless prop_schema
|
|
387
|
+
|
|
388
|
+
return prop_schema.enum.first.to_s if prop_schema.enum&.any?
|
|
389
|
+
|
|
390
|
+
case prop_schema.type
|
|
391
|
+
when 'integer', 'number' then '0'
|
|
392
|
+
when 'boolean' then 'true'
|
|
393
|
+
when 'array' then ['0']
|
|
394
|
+
when 'string' then FORMAT_DUMMIES.fetch(prop_schema.format&.to_sym, "dummy-#{key}").to_s
|
|
395
|
+
else "dummy-#{key}"
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def body_param_schema(key)
|
|
400
|
+
operation = resolve_operation
|
|
401
|
+
return nil unless operation
|
|
402
|
+
|
|
403
|
+
request_body = operation.request_operation.operation_object&.request_body
|
|
404
|
+
return nil unless request_body
|
|
405
|
+
|
|
406
|
+
content = request_body.content
|
|
407
|
+
return nil unless content
|
|
408
|
+
|
|
409
|
+
content[@request.media_type]&.schema&.properties&.[](key)
|
|
410
|
+
rescue StandardError
|
|
411
|
+
nil
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'except_parameter'
|
|
4
|
+
|
|
3
5
|
module Committee
|
|
4
6
|
module Test
|
|
5
7
|
module Methods
|
|
@@ -8,13 +10,21 @@ module Committee
|
|
|
8
10
|
assert_response_schema_confirm(expected_status)
|
|
9
11
|
end
|
|
10
12
|
|
|
11
|
-
def assert_request_schema_confirm
|
|
13
|
+
def assert_request_schema_confirm(except: {})
|
|
12
14
|
unless schema_validator.link_exist?
|
|
13
15
|
request = "`#{request_object.request_method} #{request_object.path_info}` undefined in schema (prefix: #{committee_options[:prefix].inspect})."
|
|
14
16
|
raise Committee::InvalidRequest.new(request)
|
|
15
17
|
end
|
|
16
18
|
|
|
17
|
-
|
|
19
|
+
if except.empty?
|
|
20
|
+
schema_validator.request_validate(request_object)
|
|
21
|
+
else
|
|
22
|
+
with_except_params(except) do
|
|
23
|
+
schema_validator.request_validate(request_object)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
increment_assertion_count
|
|
18
28
|
end
|
|
19
29
|
|
|
20
30
|
def assert_response_schema_confirm(expected_status = nil)
|
|
@@ -38,6 +48,8 @@ module Committee
|
|
|
38
48
|
end
|
|
39
49
|
|
|
40
50
|
schema_validator.response_validate(status, headers, [body], true) if validate_response?(status)
|
|
51
|
+
|
|
52
|
+
increment_assertion_count
|
|
41
53
|
end
|
|
42
54
|
|
|
43
55
|
def committee_options
|
|
@@ -79,6 +91,30 @@ module Committee
|
|
|
79
91
|
def old_behavior
|
|
80
92
|
committee_options.fetch(:old_assert_behavior, false)
|
|
81
93
|
end
|
|
94
|
+
|
|
95
|
+
private
|
|
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
|
+
|
|
104
|
+
# Temporarily adds dummy values for excepted parameters during validation
|
|
105
|
+
# @see ExceptParameter
|
|
106
|
+
def with_except_params(except)
|
|
107
|
+
return yield if except.empty?
|
|
108
|
+
|
|
109
|
+
except_handler = ExceptParameter.new(request_object, committee_options)
|
|
110
|
+
|
|
111
|
+
begin
|
|
112
|
+
except_handler.apply(except)
|
|
113
|
+
yield
|
|
114
|
+
ensure
|
|
115
|
+
except_handler.restore
|
|
116
|
+
end
|
|
117
|
+
end
|
|
82
118
|
end
|
|
83
119
|
end
|
|
84
120
|
end
|
data/lib/committee/version.rb
CHANGED
data/lib/committee.rb
CHANGED
|
@@ -30,10 +30,10 @@ end
|
|
|
30
30
|
require_relative "committee/utils"
|
|
31
31
|
require_relative "committee/drivers"
|
|
32
32
|
require_relative "committee/errors"
|
|
33
|
+
require_relative "committee/validation_error"
|
|
33
34
|
require_relative "committee/middleware"
|
|
34
35
|
require_relative "committee/request_unpacker"
|
|
35
36
|
require_relative "committee/schema_validator"
|
|
36
|
-
require_relative "committee/validation_error"
|
|
37
37
|
|
|
38
38
|
require_relative "committee/bin/committee_stub"
|
|
39
39
|
require_relative "committee/test/methods"
|
|
@@ -50,10 +50,7 @@ describe Committee::Drivers::OpenAPI2::Driver do
|
|
|
50
50
|
end
|
|
51
51
|
|
|
52
52
|
it "prefers a 200 response first" do
|
|
53
|
-
schema_data = schema_data_with_responses({
|
|
54
|
-
'201' => { 'schema' => { 'description' => '201 response' } },
|
|
55
|
-
'200' => { 'schema' => { 'description' => '200 response' } },
|
|
56
|
-
})
|
|
53
|
+
schema_data = schema_data_with_responses({ '201' => { 'schema' => { 'description' => '201 response' } }, '200' => { 'schema' => { 'description' => '200 response' } }, })
|
|
57
54
|
|
|
58
55
|
schema = @driver.parse(schema_data)
|
|
59
56
|
link = schema.routes['GET'][0][1]
|
|
@@ -62,10 +59,7 @@ describe Committee::Drivers::OpenAPI2::Driver do
|
|
|
62
59
|
end
|
|
63
60
|
|
|
64
61
|
it "prefers a 201 response next" do
|
|
65
|
-
schema_data = schema_data_with_responses({
|
|
66
|
-
'302' => { 'schema' => { 'description' => '302 response' } },
|
|
67
|
-
'201' => { 'schema' => { 'description' => '201 response' } },
|
|
68
|
-
})
|
|
62
|
+
schema_data = schema_data_with_responses({ '302' => { 'schema' => { 'description' => '302 response' } }, '201' => { 'schema' => { 'description' => '201 response' } }, })
|
|
69
63
|
|
|
70
64
|
schema = @driver.parse(schema_data)
|
|
71
65
|
link = schema.routes['GET'][0][1]
|
|
@@ -74,10 +68,7 @@ describe Committee::Drivers::OpenAPI2::Driver do
|
|
|
74
68
|
end
|
|
75
69
|
|
|
76
70
|
it "prefers any three-digit response next" do
|
|
77
|
-
schema_data = schema_data_with_responses({
|
|
78
|
-
'default' => { 'schema' => { 'description' => 'default response' } },
|
|
79
|
-
'302' => { 'schema' => { 'description' => '302 response' } },
|
|
80
|
-
})
|
|
71
|
+
schema_data = schema_data_with_responses({ 'default' => { 'schema' => { 'description' => 'default response' } }, '302' => { 'schema' => { 'description' => '302 response' } }, })
|
|
81
72
|
|
|
82
73
|
schema = @driver.parse(schema_data)
|
|
83
74
|
link = schema.routes['GET'][0][1]
|
|
@@ -86,10 +77,7 @@ describe Committee::Drivers::OpenAPI2::Driver do
|
|
|
86
77
|
end
|
|
87
78
|
|
|
88
79
|
it "prefers any numeric three-digit response next" do
|
|
89
|
-
schema_data = schema_data_with_responses({
|
|
90
|
-
'default' => { 'schema' => { 'description' => 'default response' } },
|
|
91
|
-
302 => { 'schema' => { 'description' => '302 response' } },
|
|
92
|
-
})
|
|
80
|
+
schema_data = schema_data_with_responses({ 'default' => { 'schema' => { 'description' => 'default response' } }, 302 => { 'schema' => { 'description' => '302 response' } }, })
|
|
93
81
|
|
|
94
82
|
schema = @driver.parse(schema_data)
|
|
95
83
|
link = schema.routes['GET'][0][1]
|