committee_firetail 5.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/bin/committee-stub +23 -0
- data/lib/committee/bin/committee_stub.rb +67 -0
- data/lib/committee/drivers/driver.rb +47 -0
- data/lib/committee/drivers/hyper_schema/driver.rb +105 -0
- data/lib/committee/drivers/hyper_schema/link.rb +68 -0
- data/lib/committee/drivers/hyper_schema/schema.rb +22 -0
- data/lib/committee/drivers/hyper_schema.rb +12 -0
- data/lib/committee/drivers/open_api_2/driver.rb +252 -0
- data/lib/committee/drivers/open_api_2/header_schema_builder.rb +33 -0
- data/lib/committee/drivers/open_api_2/link.rb +36 -0
- data/lib/committee/drivers/open_api_2/parameter_schema_builder.rb +83 -0
- data/lib/committee/drivers/open_api_2/schema.rb +26 -0
- data/lib/committee/drivers/open_api_2/schema_builder.rb +33 -0
- data/lib/committee/drivers/open_api_2.rb +13 -0
- data/lib/committee/drivers/open_api_3/driver.rb +51 -0
- data/lib/committee/drivers/open_api_3/schema.rb +41 -0
- data/lib/committee/drivers/open_api_3.rb +11 -0
- data/lib/committee/drivers/schema.rb +23 -0
- data/lib/committee/drivers.rb +84 -0
- data/lib/committee/errors.rb +36 -0
- data/lib/committee/middleware/base.rb +57 -0
- data/lib/committee/middleware/request_validation.rb +41 -0
- data/lib/committee/middleware/response_validation.rb +58 -0
- data/lib/committee/middleware/stub.rb +75 -0
- data/lib/committee/middleware.rb +11 -0
- data/lib/committee/request_unpacker.rb +91 -0
- data/lib/committee/schema_validator/hyper_schema/parameter_coercer.rb +79 -0
- data/lib/committee/schema_validator/hyper_schema/request_validator.rb +55 -0
- data/lib/committee/schema_validator/hyper_schema/response_generator.rb +102 -0
- data/lib/committee/schema_validator/hyper_schema/response_validator.rb +89 -0
- data/lib/committee/schema_validator/hyper_schema/router.rb +46 -0
- data/lib/committee/schema_validator/hyper_schema/string_params_coercer.rb +105 -0
- data/lib/committee/schema_validator/hyper_schema.rb +119 -0
- data/lib/committee/schema_validator/open_api_3/operation_wrapper.rb +139 -0
- data/lib/committee/schema_validator/open_api_3/request_validator.rb +52 -0
- data/lib/committee/schema_validator/open_api_3/response_validator.rb +29 -0
- data/lib/committee/schema_validator/open_api_3/router.rb +45 -0
- data/lib/committee/schema_validator/open_api_3.rb +120 -0
- data/lib/committee/schema_validator/option.rb +60 -0
- data/lib/committee/schema_validator.rb +23 -0
- data/lib/committee/test/methods.rb +84 -0
- data/lib/committee/test/schema_coverage.rb +101 -0
- data/lib/committee/utils.rb +28 -0
- data/lib/committee/validation_error.rb +26 -0
- data/lib/committee/version.rb +5 -0
- data/lib/committee.rb +40 -0
- data/test/bin/committee_stub_test.rb +57 -0
- data/test/bin_test.rb +25 -0
- data/test/committee_test.rb +77 -0
- data/test/drivers/hyper_schema/driver_test.rb +49 -0
- data/test/drivers/hyper_schema/link_test.rb +56 -0
- data/test/drivers/open_api_2/driver_test.rb +156 -0
- data/test/drivers/open_api_2/header_schema_builder_test.rb +26 -0
- data/test/drivers/open_api_2/link_test.rb +52 -0
- data/test/drivers/open_api_2/parameter_schema_builder_test.rb +195 -0
- data/test/drivers/open_api_3/driver_test.rb +84 -0
- data/test/drivers_test.rb +154 -0
- data/test/middleware/base_test.rb +130 -0
- data/test/middleware/request_validation_open_api_3_test.rb +626 -0
- data/test/middleware/request_validation_test.rb +516 -0
- data/test/middleware/response_validation_open_api_3_test.rb +291 -0
- data/test/middleware/response_validation_test.rb +189 -0
- data/test/middleware/stub_test.rb +145 -0
- data/test/request_unpacker_test.rb +200 -0
- data/test/schema_validator/hyper_schema/parameter_coercer_test.rb +111 -0
- data/test/schema_validator/hyper_schema/request_validator_test.rb +151 -0
- data/test/schema_validator/hyper_schema/response_generator_test.rb +142 -0
- data/test/schema_validator/hyper_schema/response_validator_test.rb +118 -0
- data/test/schema_validator/hyper_schema/router_test.rb +88 -0
- data/test/schema_validator/hyper_schema/string_params_coercer_test.rb +137 -0
- data/test/schema_validator/open_api_3/operation_wrapper_test.rb +218 -0
- data/test/schema_validator/open_api_3/request_validator_test.rb +110 -0
- data/test/schema_validator/open_api_3/response_validator_test.rb +92 -0
- data/test/test/methods_new_version_test.rb +97 -0
- data/test/test/methods_test.rb +363 -0
- data/test/test/schema_coverage_test.rb +216 -0
- data/test/test_helper.rb +120 -0
- data/test/validation_error_test.rb +25 -0
- metadata +328 -0
@@ -0,0 +1,91 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Committee
|
4
|
+
class RequestUnpacker
|
5
|
+
class << self
|
6
|
+
# Enable string or symbol key access to the nested params hash.
|
7
|
+
#
|
8
|
+
# (Copied from Sinatra)
|
9
|
+
def indifferent_params(object)
|
10
|
+
case object
|
11
|
+
when Hash
|
12
|
+
new_hash = Committee::Utils.indifferent_hash
|
13
|
+
object.each { |key, value| new_hash[key] = indifferent_params(value) }
|
14
|
+
new_hash
|
15
|
+
when Array
|
16
|
+
object.map { |item| indifferent_params(item) }
|
17
|
+
else
|
18
|
+
object
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def initialize(options={})
|
24
|
+
@allow_form_params = options[:allow_form_params]
|
25
|
+
@allow_get_body = options[:allow_get_body]
|
26
|
+
@allow_query_params = options[:allow_query_params]
|
27
|
+
@optimistic_json = options[:optimistic_json]
|
28
|
+
end
|
29
|
+
|
30
|
+
# return params and is_form_params
|
31
|
+
def unpack_request_params(request)
|
32
|
+
# if Content-Type is empty or JSON, and there was a request body, try to
|
33
|
+
# interpret it as JSON
|
34
|
+
params = if !request.media_type || request.media_type =~ %r{application/(?:.*\+)?json}
|
35
|
+
parse_json(request)
|
36
|
+
elsif @optimistic_json
|
37
|
+
begin
|
38
|
+
parse_json(request)
|
39
|
+
rescue JSON::ParserError
|
40
|
+
nil
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
return [params, false] if params
|
45
|
+
|
46
|
+
if @allow_form_params && %w[application/x-www-form-urlencoded multipart/form-data].include?(request.media_type)
|
47
|
+
# Actually, POST means anything in the request body, could be from
|
48
|
+
# PUT or PATCH too. Silly Rack.
|
49
|
+
return [request.POST, true] if request.POST
|
50
|
+
end
|
51
|
+
|
52
|
+
[{}, false]
|
53
|
+
end
|
54
|
+
|
55
|
+
def unpack_query_params(request)
|
56
|
+
@allow_query_params ? self.class.indifferent_params(request.GET) : {}
|
57
|
+
end
|
58
|
+
|
59
|
+
def unpack_headers(request)
|
60
|
+
env = request.env
|
61
|
+
base = env.keys.grep(/HTTP_/).inject({}) do |headers, key|
|
62
|
+
headerized_key = key.gsub(/^HTTP_/, '').gsub(/_/, '-')
|
63
|
+
headers[headerized_key] = env[key]
|
64
|
+
headers
|
65
|
+
end
|
66
|
+
|
67
|
+
base['Content-Type'] = env['CONTENT_TYPE'] if env['CONTENT_TYPE']
|
68
|
+
base
|
69
|
+
end
|
70
|
+
|
71
|
+
private
|
72
|
+
|
73
|
+
def parse_json(request)
|
74
|
+
return nil if request.request_method == "GET" && !@allow_get_body
|
75
|
+
|
76
|
+
body = request.body.read
|
77
|
+
# if request body is empty, we just have empty params
|
78
|
+
return nil if body.length == 0
|
79
|
+
|
80
|
+
request.body.rewind
|
81
|
+
hash = JSON.parse(body)
|
82
|
+
# We want a hash specifically. '42', 42, and [42] will all be
|
83
|
+
# decoded properly, but we can't use them here.
|
84
|
+
if !hash.is_a?(Hash)
|
85
|
+
raise BadRequest,
|
86
|
+
"Invalid JSON input. Require object with parameters as keys."
|
87
|
+
end
|
88
|
+
self.class.indifferent_params(hash)
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
@@ -0,0 +1,79 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Committee
|
4
|
+
module SchemaValidator
|
5
|
+
class HyperSchema
|
6
|
+
class ParameterCoercer
|
7
|
+
def initialize(params, schema, options = {})
|
8
|
+
@params = params
|
9
|
+
@schema = schema
|
10
|
+
|
11
|
+
@coerce_date_times = options.fetch(:coerce_date_times, false)
|
12
|
+
@coerce_recursive = options.fetch(:coerce_recursive, false)
|
13
|
+
end
|
14
|
+
|
15
|
+
def call!
|
16
|
+
coerce_object!(@params, @schema)
|
17
|
+
end
|
18
|
+
|
19
|
+
private
|
20
|
+
|
21
|
+
def coerce_object!(hash, schema)
|
22
|
+
return false unless schema.respond_to?(:properties)
|
23
|
+
|
24
|
+
is_coerced = false
|
25
|
+
schema.properties.each do |k, s|
|
26
|
+
original_val = hash[k]
|
27
|
+
unless original_val.nil?
|
28
|
+
new_value, is_changed = coerce_value!(original_val, s)
|
29
|
+
if is_changed
|
30
|
+
hash[k] = new_value
|
31
|
+
is_coerced = true
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
is_coerced
|
37
|
+
end
|
38
|
+
|
39
|
+
def coerce_value!(original_val, s)
|
40
|
+
s.type.each do |to_type|
|
41
|
+
if @coerce_date_times && to_type == "string" && s.format == "date-time"
|
42
|
+
coerced_val = parse_date_time(original_val)
|
43
|
+
return coerced_val, true if coerced_val
|
44
|
+
end
|
45
|
+
|
46
|
+
return original_val, true if @coerce_recursive && (to_type == "array") && coerce_array_data!(original_val, s)
|
47
|
+
|
48
|
+
return original_val, true if @coerce_recursive && (to_type == "object") && coerce_object!(original_val, s)
|
49
|
+
end
|
50
|
+
return nil, false
|
51
|
+
end
|
52
|
+
|
53
|
+
def coerce_array_data!(original_val, schema)
|
54
|
+
return false unless schema.respond_to?(:items)
|
55
|
+
return false unless original_val.is_a?(Array)
|
56
|
+
|
57
|
+
is_coerced = false
|
58
|
+
original_val.each_with_index do |d, index|
|
59
|
+
new_value, is_changed = coerce_value!(d, schema.items)
|
60
|
+
if is_changed
|
61
|
+
original_val[index] = new_value
|
62
|
+
is_coerced = true
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
is_coerced
|
67
|
+
end
|
68
|
+
|
69
|
+
def parse_date_time(original_val)
|
70
|
+
begin
|
71
|
+
DateTime.parse(original_val)
|
72
|
+
rescue ArgumentError => e
|
73
|
+
raise ::Committee::InvalidResponse unless e.message =~ /invalid date/
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Committee
|
4
|
+
module SchemaValidator
|
5
|
+
class HyperSchema
|
6
|
+
class RequestValidator
|
7
|
+
def initialize(link, options = {})
|
8
|
+
@link = link
|
9
|
+
@check_content_type = options.fetch(:check_content_type, true)
|
10
|
+
@check_header = options.fetch(:check_header, true)
|
11
|
+
end
|
12
|
+
|
13
|
+
def call(request, params, headers)
|
14
|
+
check_content_type!(request, params) if @check_content_type
|
15
|
+
if @link.schema
|
16
|
+
valid, errors = @link.schema.validate(params)
|
17
|
+
if !valid
|
18
|
+
errors = JsonSchema::SchemaError.aggregate(errors).join("\n")
|
19
|
+
raise InvalidRequest, "Invalid request.\n\n#{errors}"
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
if @check_header && @link.respond_to?(:header_schema) && @link.header_schema
|
24
|
+
valid, errors = @link.header_schema.validate(headers)
|
25
|
+
if !valid
|
26
|
+
errors = JsonSchema::SchemaError.aggregate(errors).join("\n")
|
27
|
+
raise InvalidRequest, "Invalid request.\n\n#{errors}"
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
private
|
33
|
+
|
34
|
+
def check_content_type!(request, data)
|
35
|
+
content_type = ::Committee::SchemaValidator.request_media_type(request)
|
36
|
+
if content_type && @link.enc_type && !empty_request?(request)
|
37
|
+
unless Rack::Mime.match?(content_type, @link.enc_type)
|
38
|
+
raise Committee::InvalidRequest,
|
39
|
+
%{"Content-Type" request header must be set to "#{@link.enc_type}".}
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def empty_request?(request)
|
45
|
+
# small optimization: assume GET and DELETE don't have bodies
|
46
|
+
return true if request.get? || request.delete? || !request.body
|
47
|
+
|
48
|
+
data = request.body.read
|
49
|
+
request.body.rewind
|
50
|
+
data.empty?
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
@@ -0,0 +1,102 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Committee
|
4
|
+
module SchemaValidator
|
5
|
+
class HyperSchema
|
6
|
+
class ResponseGenerator
|
7
|
+
def call(link)
|
8
|
+
schema = target_schema(link)
|
9
|
+
data = generate_properties(link, schema)
|
10
|
+
|
11
|
+
# List is a special case; wrap data in an array.
|
12
|
+
#
|
13
|
+
# This is poor form that's here so as not to introduce breaking behavior.
|
14
|
+
# The "instances" value of "rel" is a Heroku-ism and was originally
|
15
|
+
# introduced before we understood how to use "targetSchema". It's not
|
16
|
+
# meaningful with the context of the hyper-schema specification and
|
17
|
+
# should be eventually be removed.
|
18
|
+
if legacy_hyper_schema_rel?(link)
|
19
|
+
data = [data]
|
20
|
+
end
|
21
|
+
|
22
|
+
[data, schema]
|
23
|
+
end
|
24
|
+
|
25
|
+
private
|
26
|
+
|
27
|
+
# These are basic types that are part of the JSON schema for which we'll
|
28
|
+
# emit zero values when generating a response. For a schema that allows
|
29
|
+
# multiple of the types in the list, types are preferred in the order in
|
30
|
+
# which they're defined.
|
31
|
+
SCALAR_TYPES = {
|
32
|
+
"boolean" => false,
|
33
|
+
"integer" => 0,
|
34
|
+
"number" => 0.0,
|
35
|
+
"string" => "",
|
36
|
+
|
37
|
+
# Prefer null last.
|
38
|
+
"null" => nil,
|
39
|
+
}.freeze
|
40
|
+
|
41
|
+
def generate_properties(link, schema)
|
42
|
+
# special example attribute was included; use its value
|
43
|
+
if schema.data && !schema.data["example"].nil?
|
44
|
+
schema.data["example"]
|
45
|
+
|
46
|
+
elsif !schema.all_of.empty? || !schema.properties.empty?
|
47
|
+
data = {}
|
48
|
+
schema.all_of.each do |subschema|
|
49
|
+
data.merge!(generate_properties(link, subschema))
|
50
|
+
end
|
51
|
+
schema.properties.map do |key, value|
|
52
|
+
data[key] = generate_properties(link, value)
|
53
|
+
end
|
54
|
+
data
|
55
|
+
|
56
|
+
elsif schema.type.include?("array") && !schema.items.nil?
|
57
|
+
[generate_properties(link, schema.items)]
|
58
|
+
|
59
|
+
elsif schema.enum
|
60
|
+
schema.enum.first
|
61
|
+
|
62
|
+
elsif schema.type.any? { |t| SCALAR_TYPES.include?(t) }
|
63
|
+
SCALAR_TYPES.each do |k, v|
|
64
|
+
break(v) if schema.type.include?(k)
|
65
|
+
end
|
66
|
+
|
67
|
+
# Generate an empty array for arrays.
|
68
|
+
elsif schema.type == ["array"]
|
69
|
+
[]
|
70
|
+
|
71
|
+
# Schema is an object with no properties: just generate an empty object.
|
72
|
+
elsif schema.type == ["object"]
|
73
|
+
{}
|
74
|
+
|
75
|
+
else
|
76
|
+
raise(%{At "#{link.method} #{link.href}" "#{schema.pointer}": no } +
|
77
|
+
%{"example" attribute and "null" } +
|
78
|
+
%{is not allowed; don't know how to generate property.})
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
def legacy_hyper_schema_rel?(link)
|
83
|
+
link.is_a?(Committee::Drivers::HyperSchema::Link) &&
|
84
|
+
link.rel == "instances" &&
|
85
|
+
!link.target_schema
|
86
|
+
end
|
87
|
+
|
88
|
+
# Gets the target schema of a link. This is normally just the standard
|
89
|
+
# response schema, but we allow some legacy behavior for hyper-schema links
|
90
|
+
# tagged with rel=instances to instead use the schema of their parent
|
91
|
+
# resource.
|
92
|
+
def target_schema(link)
|
93
|
+
if link.target_schema
|
94
|
+
link.target_schema
|
95
|
+
elsif legacy_hyper_schema_rel?(link)
|
96
|
+
link.parent
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
@@ -0,0 +1,89 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Committee
|
4
|
+
module SchemaValidator
|
5
|
+
class HyperSchema
|
6
|
+
class ResponseValidator
|
7
|
+
attr_reader :validate_success_only
|
8
|
+
|
9
|
+
def initialize(link, options = {})
|
10
|
+
@link = link
|
11
|
+
@validate_success_only = options[:validate_success_only]
|
12
|
+
|
13
|
+
@validator = JsonSchema::Validator.new(target_schema(link))
|
14
|
+
end
|
15
|
+
|
16
|
+
def call(status, headers, data)
|
17
|
+
unless [204, 304].include?(status) # 204 No Content or 304 Not Modified
|
18
|
+
response = Rack::Response.new(data, status, headers)
|
19
|
+
check_content_type!(response)
|
20
|
+
end
|
21
|
+
|
22
|
+
# List is a special case; expect data in an array.
|
23
|
+
#
|
24
|
+
# This is poor form that's here so as not to introduce breaking behavior.
|
25
|
+
# The "instances" value of "rel" is a Heroku-ism and was originally
|
26
|
+
# introduced before we understood how to use "targetSchema". It's not
|
27
|
+
# meaningful with the context of the hyper-schema specification and
|
28
|
+
# should be eventually be removed.
|
29
|
+
if legacy_hyper_schema_rel?(@link)
|
30
|
+
if !data.is_a?(Array)
|
31
|
+
raise InvalidResponse, "List endpoints must return an array of objects."
|
32
|
+
end
|
33
|
+
|
34
|
+
# only consider the first object during the validation from here on
|
35
|
+
# (but only in cases where `targetSchema` is not set)
|
36
|
+
data = data[0]
|
37
|
+
|
38
|
+
# if the array was empty, allow it through
|
39
|
+
return if data == nil
|
40
|
+
end
|
41
|
+
|
42
|
+
if Committee::Middleware::ResponseValidation.validate?(status, validate_success_only) && !@validator.validate(data)
|
43
|
+
errors = JsonSchema::SchemaError.aggregate(@validator.errors).join("\n")
|
44
|
+
raise InvalidResponse, "Invalid response.\n\n#{errors}"
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
private
|
49
|
+
|
50
|
+
def response_media_type(response)
|
51
|
+
if response.respond_to?(:media_type)
|
52
|
+
response.media_type.to_s
|
53
|
+
else
|
54
|
+
# for rack compatibility. In rack v 1.5.0, Rack::Response doesn't have media_type
|
55
|
+
response.content_type.to_s.split(";").first.to_s
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
|
60
|
+
def check_content_type!(response)
|
61
|
+
if @link.media_type
|
62
|
+
unless Rack::Mime.match?(response_media_type(response), @link.media_type)
|
63
|
+
raise Committee::InvalidResponse,
|
64
|
+
%{"Content-Type" response header must be set to "#{@link.media_type}".}
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
def legacy_hyper_schema_rel?(link)
|
70
|
+
link.is_a?(Committee::Drivers::HyperSchema::Link) &&
|
71
|
+
link.rel == "instances" &&
|
72
|
+
!link.target_schema
|
73
|
+
end
|
74
|
+
|
75
|
+
# Gets the target schema of a link. This is normally just the standard
|
76
|
+
# response schema, but we allow some legacy behavior for hyper-schema links
|
77
|
+
# tagged with rel=instances to instead use the schema of their parent
|
78
|
+
# resource.
|
79
|
+
def target_schema(link)
|
80
|
+
if link.target_schema
|
81
|
+
link.target_schema
|
82
|
+
elsif legacy_hyper_schema_rel?(link)
|
83
|
+
link.parent
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Committee
|
4
|
+
module SchemaValidator
|
5
|
+
class HyperSchema
|
6
|
+
class Router
|
7
|
+
def initialize(schema, validator_option)
|
8
|
+
@prefix = validator_option.prefix
|
9
|
+
@prefix_regexp = /\A#{Regexp.escape(@prefix)}/.freeze if @prefix
|
10
|
+
@schema = schema
|
11
|
+
|
12
|
+
@validator_option = validator_option
|
13
|
+
end
|
14
|
+
|
15
|
+
def includes?(path)
|
16
|
+
!@prefix || path =~ @prefix_regexp
|
17
|
+
end
|
18
|
+
|
19
|
+
def includes_request?(request)
|
20
|
+
includes?(request.path)
|
21
|
+
end
|
22
|
+
|
23
|
+
def find_link(method, path)
|
24
|
+
path = path.gsub(@prefix_regexp, "") if @prefix
|
25
|
+
link_with_matches = (@schema.routes[method] || []).map do |pattern, link|
|
26
|
+
if matches = pattern.match(path)
|
27
|
+
# prefer path which has fewer matches (eg. `/pets/dog` than `/pets/{uuid}` for path `/pets/dog` )
|
28
|
+
[matches.captures.size, link, Hash[matches.names.zip(matches.captures)]]
|
29
|
+
else
|
30
|
+
nil
|
31
|
+
end
|
32
|
+
end.compact.sort_by(&:first).first
|
33
|
+
link_with_matches.nil? ? nil : link_with_matches.slice(1, 2)
|
34
|
+
end
|
35
|
+
|
36
|
+
def find_request_link(request)
|
37
|
+
find_link(request.request_method, request.path_info)
|
38
|
+
end
|
39
|
+
|
40
|
+
def build_schema_validator(request)
|
41
|
+
Committee::SchemaValidator::HyperSchema.new(self, request, @validator_option)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,105 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Committee
|
4
|
+
module SchemaValidator
|
5
|
+
class HyperSchema
|
6
|
+
# StringParamsCoercer takes parameters that are specified over a medium that
|
7
|
+
# can only accept strings (for example in a URL path or in query parameters)
|
8
|
+
# and attempts to coerce them into known types based of a link's schema
|
9
|
+
# definition.
|
10
|
+
#
|
11
|
+
# Currently supported types: null, integer, number and boolean.
|
12
|
+
#
|
13
|
+
# +call+ returns a hash of all params which could be coerced - coercion
|
14
|
+
# errors are simply ignored and expected to be handled later by schema
|
15
|
+
# validation.
|
16
|
+
class StringParamsCoercer
|
17
|
+
def initialize(query_hash, schema, options = {})
|
18
|
+
@query_hash = query_hash
|
19
|
+
@schema = schema
|
20
|
+
|
21
|
+
@coerce_recursive = options.fetch(:coerce_recursive, false)
|
22
|
+
end
|
23
|
+
|
24
|
+
def call!
|
25
|
+
coerce_object!(@query_hash, @schema)
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
def coerce_object!(hash, schema)
|
31
|
+
return false unless schema.respond_to?(:properties)
|
32
|
+
|
33
|
+
is_coerced = false
|
34
|
+
schema.properties.each do |k, s|
|
35
|
+
original_val = hash[k]
|
36
|
+
unless original_val.nil?
|
37
|
+
new_value, is_changed = coerce_value!(original_val, s)
|
38
|
+
if is_changed
|
39
|
+
hash[k] = new_value
|
40
|
+
is_coerced = true
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
is_coerced
|
46
|
+
end
|
47
|
+
|
48
|
+
def coerce_value!(original_val, s)
|
49
|
+
unless original_val.nil?
|
50
|
+
s.type.each do |to_type|
|
51
|
+
case to_type
|
52
|
+
when "null"
|
53
|
+
return nil, true if original_val.empty?
|
54
|
+
when "integer"
|
55
|
+
begin
|
56
|
+
return Integer(original_val), true
|
57
|
+
rescue ArgumentError => e
|
58
|
+
raise e unless e.message =~ /invalid value for Integer/
|
59
|
+
end
|
60
|
+
when "number"
|
61
|
+
begin
|
62
|
+
return Float(original_val), true
|
63
|
+
rescue ArgumentError => e
|
64
|
+
raise e unless e.message =~ /invalid value for Float/
|
65
|
+
end
|
66
|
+
when "boolean"
|
67
|
+
if original_val == "true" || original_val == "1"
|
68
|
+
return true, true
|
69
|
+
end
|
70
|
+
if original_val == "false" || original_val == "0"
|
71
|
+
return false, true
|
72
|
+
end
|
73
|
+
when "array"
|
74
|
+
if @coerce_recursive && coerce_array_data!(original_val, s)
|
75
|
+
return original_val, true # change original value
|
76
|
+
end
|
77
|
+
when "object"
|
78
|
+
if @coerce_recursive && coerce_object!(original_val, s)
|
79
|
+
return original_val, true # change original value
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
return nil, false
|
85
|
+
end
|
86
|
+
|
87
|
+
def coerce_array_data!(original_val, schema)
|
88
|
+
return false unless schema.respond_to?(:items)
|
89
|
+
return false unless original_val.is_a?(Array)
|
90
|
+
|
91
|
+
is_coerced = false
|
92
|
+
original_val.each_with_index do |d, index|
|
93
|
+
new_value, is_changed = coerce_value!(d, schema.items)
|
94
|
+
if is_changed
|
95
|
+
original_val[index] = new_value
|
96
|
+
is_coerced = true
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
is_coerced
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
@@ -0,0 +1,119 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Committee
|
4
|
+
module SchemaValidator
|
5
|
+
class HyperSchema
|
6
|
+
attr_reader :link, :param_matches, :validator_option
|
7
|
+
|
8
|
+
def initialize(router, request, validator_option)
|
9
|
+
@link, @param_matches = router.find_request_link(request)
|
10
|
+
@validator_option = validator_option
|
11
|
+
end
|
12
|
+
|
13
|
+
def request_validate(request)
|
14
|
+
request_unpack(request)
|
15
|
+
|
16
|
+
request_schema_validation(request)
|
17
|
+
parameter_coerce!(request, link, validator_option.params_key)
|
18
|
+
parameter_coerce!(request, link, "rack.request.query_hash") if link_exist? && !request.GET.nil? && !link.schema.nil?
|
19
|
+
end
|
20
|
+
|
21
|
+
def response_validate(status, headers, response, _test_method = false)
|
22
|
+
return unless link_exist?
|
23
|
+
|
24
|
+
full_body = +""
|
25
|
+
response.each do |chunk|
|
26
|
+
full_body << chunk
|
27
|
+
end
|
28
|
+
|
29
|
+
data = {}
|
30
|
+
unless full_body.empty?
|
31
|
+
parse_to_json = !validator_option.parse_response_by_content_type ||
|
32
|
+
headers.fetch('Content-Type', nil)&.start_with?('application/json')
|
33
|
+
data = JSON.parse(full_body) if parse_to_json
|
34
|
+
end
|
35
|
+
|
36
|
+
Committee::SchemaValidator::HyperSchema::ResponseValidator.new(link, validate_success_only: validator_option.validate_success_only).call(status, headers, data)
|
37
|
+
end
|
38
|
+
|
39
|
+
def link_exist?
|
40
|
+
!link.nil?
|
41
|
+
end
|
42
|
+
|
43
|
+
private
|
44
|
+
|
45
|
+
def coerce_path_params
|
46
|
+
return {} unless link_exist?
|
47
|
+
|
48
|
+
Committee::SchemaValidator::HyperSchema::StringParamsCoercer.new(param_matches, link.schema, coerce_recursive: validator_option.coerce_recursive).call!
|
49
|
+
param_matches
|
50
|
+
end
|
51
|
+
|
52
|
+
def coerce_query_params(request)
|
53
|
+
return unless link_exist?
|
54
|
+
return if request.GET.nil? || link.schema.nil?
|
55
|
+
|
56
|
+
Committee::SchemaValidator::HyperSchema::StringParamsCoercer.new(request.GET, link.schema, coerce_recursive: validator_option.coerce_recursive).call!
|
57
|
+
end
|
58
|
+
|
59
|
+
def request_unpack(request)
|
60
|
+
unpacker = Committee::RequestUnpacker.new(
|
61
|
+
allow_form_params: validator_option.allow_form_params,
|
62
|
+
allow_get_body: validator_option.allow_get_body,
|
63
|
+
allow_query_params: validator_option.allow_query_params,
|
64
|
+
optimistic_json: validator_option.optimistic_json,
|
65
|
+
)
|
66
|
+
|
67
|
+
request.env[validator_option.headers_key] = unpacker.unpack_headers(request)
|
68
|
+
|
69
|
+
# Attempts to coerce parameters that appear in a link's URL to Ruby
|
70
|
+
# types that can be validated with a schema.
|
71
|
+
param_matches_hash = validator_option.coerce_path_params ? coerce_path_params : {}
|
72
|
+
|
73
|
+
# Attempts to coerce parameters that appear in a query string to Ruby
|
74
|
+
# types that can be validated with a schema.
|
75
|
+
coerce_query_params(request) if validator_option.coerce_query_params
|
76
|
+
|
77
|
+
query_param = unpacker.unpack_query_params(request)
|
78
|
+
request_param, is_form_params = unpacker.unpack_request_params(request)
|
79
|
+
coerce_form_params(request_param) if validator_option.coerce_form_params && is_form_params
|
80
|
+
request.env[validator_option.request_body_hash_key] = request_param
|
81
|
+
|
82
|
+
request.env[validator_option.params_key] = Committee::Utils.indifferent_hash
|
83
|
+
request.env[validator_option.params_key].merge!(Committee::Utils.deep_copy(query_param))
|
84
|
+
request.env[validator_option.params_key].merge!(Committee::Utils.deep_copy(request_param))
|
85
|
+
request.env[validator_option.params_key].merge!(Committee::Utils.deep_copy(param_matches_hash))
|
86
|
+
end
|
87
|
+
|
88
|
+
def coerce_form_params(parameter)
|
89
|
+
return unless link_exist?
|
90
|
+
return unless link.schema
|
91
|
+
Committee::SchemaValidator::HyperSchema::StringParamsCoercer.new(parameter, link.schema).call!
|
92
|
+
end
|
93
|
+
|
94
|
+
def request_schema_validation(request)
|
95
|
+
return unless link_exist?
|
96
|
+
validator = Committee::SchemaValidator::HyperSchema::RequestValidator.new(link, check_content_type: validator_option.check_content_type, check_header: validator_option.check_header)
|
97
|
+
validator.call(request, request.env[validator_option.params_key], request.env[validator_option.headers_key])
|
98
|
+
end
|
99
|
+
|
100
|
+
def parameter_coerce!(request, link, coerce_key)
|
101
|
+
return unless link_exist?
|
102
|
+
|
103
|
+
Committee::SchemaValidator::HyperSchema::ParameterCoercer.
|
104
|
+
new(request.env[coerce_key],
|
105
|
+
link.schema,
|
106
|
+
coerce_date_times: validator_option.coerce_date_times,
|
107
|
+
coerce_recursive: validator_option.coerce_recursive).
|
108
|
+
call!
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
require_relative "hyper_schema/request_validator"
|
115
|
+
require_relative "hyper_schema/response_generator"
|
116
|
+
require_relative "hyper_schema/response_validator"
|
117
|
+
require_relative "hyper_schema/router"
|
118
|
+
require_relative "hyper_schema/string_params_coercer"
|
119
|
+
require_relative "hyper_schema/parameter_coercer"
|