committee_firetail 5.0.0
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 +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"
|