committee 1.15.0 → 5.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (89) hide show
  1. checksums.yaml +5 -5
  2. data/bin/committee-stub +11 -38
  3. data/lib/committee/bin/committee_stub.rb +67 -0
  4. data/lib/committee/drivers/driver.rb +47 -0
  5. data/lib/committee/drivers/hyper_schema/driver.rb +105 -0
  6. data/lib/committee/drivers/hyper_schema/link.rb +68 -0
  7. data/lib/committee/drivers/hyper_schema/schema.rb +22 -0
  8. data/lib/committee/drivers/hyper_schema.rb +12 -0
  9. data/lib/committee/drivers/open_api_2/driver.rb +252 -0
  10. data/lib/committee/drivers/open_api_2/header_schema_builder.rb +33 -0
  11. data/lib/committee/drivers/open_api_2/link.rb +36 -0
  12. data/lib/committee/drivers/open_api_2/parameter_schema_builder.rb +83 -0
  13. data/lib/committee/drivers/open_api_2/schema.rb +26 -0
  14. data/lib/committee/drivers/open_api_2/schema_builder.rb +33 -0
  15. data/lib/committee/drivers/open_api_2.rb +13 -0
  16. data/lib/committee/drivers/open_api_3/driver.rb +51 -0
  17. data/lib/committee/drivers/open_api_3/schema.rb +41 -0
  18. data/lib/committee/drivers/open_api_3.rb +11 -0
  19. data/lib/committee/drivers/schema.rb +23 -0
  20. data/lib/committee/drivers.rb +84 -0
  21. data/lib/committee/errors.rb +17 -0
  22. data/lib/committee/middleware/base.rb +46 -29
  23. data/lib/committee/middleware/request_validation.rb +31 -49
  24. data/lib/committee/middleware/response_validation.rb +48 -25
  25. data/lib/committee/middleware/stub.rb +62 -37
  26. data/lib/committee/middleware.rb +11 -0
  27. data/lib/committee/request_unpacker.rb +58 -50
  28. data/lib/committee/schema_validator/hyper_schema/parameter_coercer.rb +79 -0
  29. data/lib/committee/schema_validator/hyper_schema/request_validator.rb +55 -0
  30. data/lib/committee/schema_validator/hyper_schema/response_generator.rb +102 -0
  31. data/lib/committee/schema_validator/hyper_schema/response_validator.rb +89 -0
  32. data/lib/committee/schema_validator/hyper_schema/router.rb +46 -0
  33. data/lib/committee/schema_validator/hyper_schema/string_params_coercer.rb +105 -0
  34. data/lib/committee/schema_validator/hyper_schema.rb +119 -0
  35. data/lib/committee/schema_validator/open_api_3/operation_wrapper.rb +139 -0
  36. data/lib/committee/schema_validator/open_api_3/request_validator.rb +52 -0
  37. data/lib/committee/schema_validator/open_api_3/response_validator.rb +29 -0
  38. data/lib/committee/schema_validator/open_api_3/router.rb +45 -0
  39. data/lib/committee/schema_validator/open_api_3.rb +120 -0
  40. data/lib/committee/schema_validator/option.rb +60 -0
  41. data/lib/committee/schema_validator.rb +23 -0
  42. data/lib/committee/test/methods.rb +68 -38
  43. data/lib/committee/test/schema_coverage.rb +101 -0
  44. data/lib/committee/utils.rb +28 -0
  45. data/lib/committee/validation_error.rb +5 -2
  46. data/lib/committee/version.rb +5 -0
  47. data/lib/committee.rb +31 -18
  48. data/test/bin/committee_stub_test.rb +57 -0
  49. data/test/bin_test.rb +25 -0
  50. data/test/committee_test.rb +77 -0
  51. data/test/drivers/hyper_schema/driver_test.rb +49 -0
  52. data/test/drivers/hyper_schema/link_test.rb +56 -0
  53. data/test/drivers/open_api_2/driver_test.rb +156 -0
  54. data/test/drivers/open_api_2/header_schema_builder_test.rb +26 -0
  55. data/test/drivers/open_api_2/link_test.rb +52 -0
  56. data/test/drivers/open_api_2/parameter_schema_builder_test.rb +195 -0
  57. data/test/drivers/open_api_3/driver_test.rb +84 -0
  58. data/test/drivers_test.rb +154 -0
  59. data/test/middleware/base_test.rb +96 -7
  60. data/test/middleware/request_validation_open_api_3_test.rb +626 -0
  61. data/test/middleware/request_validation_test.rb +423 -32
  62. data/test/middleware/response_validation_open_api_3_test.rb +291 -0
  63. data/test/middleware/response_validation_test.rb +125 -23
  64. data/test/middleware/stub_test.rb +81 -20
  65. data/test/request_unpacker_test.rb +126 -52
  66. data/test/schema_validator/hyper_schema/parameter_coercer_test.rb +111 -0
  67. data/test/schema_validator/hyper_schema/request_validator_test.rb +151 -0
  68. data/test/schema_validator/hyper_schema/response_generator_test.rb +142 -0
  69. data/test/{response_validator_test.rb → schema_validator/hyper_schema/response_validator_test.rb} +43 -6
  70. data/test/schema_validator/hyper_schema/router_test.rb +88 -0
  71. data/test/schema_validator/hyper_schema/string_params_coercer_test.rb +137 -0
  72. data/test/schema_validator/open_api_3/operation_wrapper_test.rb +218 -0
  73. data/test/schema_validator/open_api_3/request_validator_test.rb +110 -0
  74. data/test/schema_validator/open_api_3/response_validator_test.rb +92 -0
  75. data/test/test/methods_new_version_test.rb +97 -0
  76. data/test/test/methods_test.rb +334 -27
  77. data/test/test/schema_coverage_test.rb +216 -0
  78. data/test/test_helper.rb +108 -1
  79. data/test/validation_error_test.rb +3 -1
  80. metadata +190 -27
  81. data/lib/committee/query_params_coercer.rb +0 -45
  82. data/lib/committee/request_validator.rb +0 -44
  83. data/lib/committee/response_generator.rb +0 -35
  84. data/lib/committee/response_validator.rb +0 -59
  85. data/lib/committee/router.rb +0 -62
  86. data/test/query_params_coercer_test.rb +0 -70
  87. data/test/request_validator_test.rb +0 -103
  88. data/test/response_generator_test.rb +0 -61
  89. data/test/router_test.rb +0 -38
@@ -1,35 +1,58 @@
1
- module Committee::Middleware
2
- class ResponseValidation < Base
3
- attr_reader :validate_errors
1
+ # frozen_string_literal: true
4
2
 
5
- def initialize(app, options = {})
6
- super
7
- @validate_errors = options[:validate_errors]
8
- end
3
+ module Committee
4
+ module Middleware
5
+ class ResponseValidation < Base
6
+ attr_reader :validate_success_only
7
+
8
+ def initialize(app, options = {})
9
+ super
10
+ @strict = options[:strict]
11
+ @validate_success_only = @schema.validator_option.validate_success_only
12
+ end
13
+
14
+ def handle(request)
15
+ status, headers, response = @app.call(request.env)
16
+
17
+ begin
18
+ v = build_schema_validator(request)
19
+ v.response_validate(status, headers, response, @strict) if v.link_exist? && self.class.validate?(status, validate_success_only)
20
+
21
+ rescue Committee::InvalidResponse
22
+ handle_exception($!, request.env)
9
23
 
10
- def handle(request)
11
- status, headers, response = @app.call(request.env)
24
+ raise if @raise
25
+ return @error_class.new(500, :invalid_response, $!.message).render unless @ignore_error
26
+ rescue JSON::ParserError
27
+ handle_exception($!, request.env)
12
28
 
13
- if validate?(status) && link = @router.find_request_link(request)
14
- full_body = ""
15
- response.each do |chunk|
16
- full_body << chunk
29
+ raise Committee::InvalidResponse if @raise
30
+ return @error_class.new(500, :invalid_response, "Response wasn't valid JSON.").render unless @ignore_error
17
31
  end
18
- data = JSON.parse(full_body)
19
- Committee::ResponseValidator.new(link, validate_errors: validate_errors).call(status, headers, data)
32
+
33
+ [status, headers, response]
20
34
  end
21
35
 
22
- [status, headers, response]
23
- rescue Committee::InvalidResponse
24
- raise if @raise
25
- @error_class.new(500, :invalid_response, $!.message).render
26
- rescue JSON::ParserError
27
- raise Committee::InvalidResponse if @raise
28
- @error_class.new(500, :invalid_response, "Response wasn't valid JSON.").render
29
- end
36
+ class << self
37
+ def validate?(status, validate_success_only)
38
+ case status
39
+ when 204
40
+ false
41
+ when 200..299
42
+ true
43
+ when 304
44
+ false
45
+ else
46
+ !validate_success_only
47
+ end
48
+ end
49
+ end
50
+
51
+ private
30
52
 
31
- def validate?(status)
32
- Committee::ResponseValidator.validate?(status, validate_errors: validate_errors)
53
+ def handle_exception(e, env)
54
+ @error_handler.call(e, env) if @error_handler
55
+ end
33
56
  end
34
57
  end
35
58
  end
@@ -1,49 +1,74 @@
1
- module Committee::Middleware
2
- class Stub < Base
3
- def initialize(app, options={})
4
- super
5
- @cache = {}
6
- @call = options[:call]
7
- end
1
+ # frozen_string_literal: true
8
2
 
9
- def handle(request)
10
- if link = @router.find_request_link(request)
11
- headers = { "Content-Type" => "application/json" }
12
- data = cache(link.method, link.href) do
13
- Committee::ResponseGenerator.new.call(link)
14
- end
15
- if @call
16
- request.env["committee.response"] = data
17
- call_status, call_headers, call_body = @app.call(request.env)
3
+ # Stub is not yet supported in OpenAPI 3
4
+
5
+ module Committee
6
+ module Middleware
7
+ class Stub < Base
8
+ def initialize(app, options={})
9
+ super
10
+
11
+ # A bug in Committee's cache implementation meant that it wasn't working
12
+ # for a very long time, even for people who thought they were taking
13
+ # advantage of it. I repaired the caching feature, but have disable it by
14
+ # default so that we don't need to introduce any class-level variables
15
+ # that could have memory leaking implications. To enable caching, just
16
+ # pass an empty hash to this option.
17
+ @cache = options[:cache]
18
+
19
+ @call = options[:call]
20
+
21
+ raise Committee::OpenAPI3Unsupported.new("Stubs are not yet supported for OpenAPI 3") unless @schema.supports_stub?
22
+ end
23
+
24
+ def handle(request)
25
+ link, _ = @router.find_request_link(request)
26
+ if link
27
+ headers = { "Content-Type" => "application/json" }
18
28
 
19
- # a committee.suppress signal initiates a direct pass through
20
- if request.env["committee.suppress"] == true
21
- return call_status, call_headers, call_body
29
+ data, schema = cache(link) do
30
+ Committee::SchemaValidator::HyperSchema::ResponseGenerator.new.call(link)
22
31
  end
23
32
 
24
- # otherwise keep the headers and whatever data manipulations were
25
- # made, and stub normally
26
- headers.merge!(call_headers)
33
+ if @call
34
+ request.env["committee.response"] = data
35
+ request.env["committee.response_schema"] = schema
36
+ call_status, call_headers, call_body = @app.call(request.env)
27
37
 
28
- # allow allow the handler to change the data object (if unchanged, it
29
- # will be the same one that we set above)
30
- data = request.env["committee.response"]
38
+ # a committee.suppress signal initiates a direct pass through
39
+ if request.env["committee.suppress"] == true
40
+ return call_status, call_headers, call_body
41
+ end
42
+
43
+ # otherwise keep the headers and whatever data manipulations were
44
+ # made, and stub normally
45
+ headers.merge!(call_headers)
46
+
47
+ # allow the handler to change the data object (if unchanged, it
48
+ # will be the same one that we set above)
49
+ data = request.env["committee.response"]
50
+ end
51
+
52
+ [link.status_success, headers, [JSON.pretty_generate(data)]]
53
+ else
54
+ @app.call(request.env)
31
55
  end
32
- status = link.rel == "create" ? 201 : 200
33
- [status, headers, [JSON.pretty_generate(data)]]
34
- else
35
- @app.call(request.env)
36
56
  end
37
- end
38
57
 
39
- private
58
+ private
59
+
60
+ def cache(link)
61
+ return yield unless @cache
40
62
 
41
- def cache(method, href)
42
- key = "#{method}+#{href}"
43
- if @cache[key]
44
- @cache[key]
45
- else
46
- @cache[key] = yield
63
+ # Just the object ID is enough to uniquely identify the link, but store
64
+ # the method and href so that we can more easily introspect the cache if
65
+ # necessary.
66
+ key = "#{link.object_id}##{link.method}+#{link.href}"
67
+ if @cache[key]
68
+ @cache[key]
69
+ else
70
+ @cache[key] = yield
71
+ end
47
72
  end
48
73
  end
49
74
  end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Committee
4
+ module Middleware
5
+ end
6
+ end
7
+
8
+ require_relative "middleware/base"
9
+ require_relative "middleware/request_validation"
10
+ require_relative "middleware/response_validation"
11
+ require_relative "middleware/stub"
@@ -1,83 +1,91 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Committee
2
4
  class RequestUnpacker
3
- def initialize(request, options={})
4
- @request = request
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
5
22
 
23
+ def initialize(options={})
6
24
  @allow_form_params = options[:allow_form_params]
25
+ @allow_get_body = options[:allow_get_body]
7
26
  @allow_query_params = options[:allow_query_params]
8
27
  @optimistic_json = options[:optimistic_json]
9
28
  end
10
29
 
11
- def call
30
+ # return params and is_form_params
31
+ def unpack_request_params(request)
12
32
  # if Content-Type is empty or JSON, and there was a request body, try to
13
33
  # interpret it as JSON
14
- params = if !@request.media_type || @request.media_type =~ %r{application/.*json}
15
- parse_json
34
+ params = if !request.media_type || request.media_type =~ %r{application/(?:.*\+)?json}
35
+ parse_json(request)
16
36
  elsif @optimistic_json
17
37
  begin
18
- parse_json
38
+ parse_json(request)
19
39
  rescue JSON::ParserError
20
40
  nil
21
41
  end
22
42
  end
23
43
 
24
- params = if params
25
- params
26
- elsif @allow_form_params && @request.media_type == "application/x-www-form-urlencoded"
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)
27
47
  # Actually, POST means anything in the request body, could be from
28
48
  # PUT or PATCH too. Silly Rack.
29
- indifferent_params(@request.POST)
30
- else
31
- {}
49
+ return [request.POST, true] if request.POST
32
50
  end
33
51
 
34
- if @allow_query_params
35
- indifferent_params(@request.GET).merge(params)
36
- else
37
- params
38
- end
52
+ [{}, false]
39
53
  end
40
54
 
41
- private
42
-
43
- # Creates a Hash with indifferent access.
44
- #
45
- # (Copied from Sinatra)
46
- def indifferent_hash
47
- Hash.new { |hash,key| hash[key.to_s] if Symbol === key }
55
+ def unpack_query_params(request)
56
+ @allow_query_params ? self.class.indifferent_params(request.GET) : {}
48
57
  end
49
58
 
50
- # Enable string or symbol key access to the nested params hash.
51
- #
52
- # (Copied from Sinatra)
53
- def indifferent_params(object)
54
- case object
55
- when Hash
56
- new_hash = indifferent_hash
57
- object.each { |key, value| new_hash[key] = indifferent_params(value) }
58
- new_hash
59
- when Array
60
- object.map { |item| indifferent_params(item) }
61
- else
62
- object
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
63
65
  end
66
+
67
+ base['Content-Type'] = env['CONTENT_TYPE'] if env['CONTENT_TYPE']
68
+ base
64
69
  end
65
70
 
66
- def parse_json
67
- if (body = @request.body.read).length != 0
68
- @request.body.rewind
69
- hash = JSON.parse(body)
70
- # We want a hash specifically. '42', 42, and [42] will all be
71
- # decoded properly, but we can't use them here.
72
- if !hash.is_a?(Hash)
73
- raise BadRequest,
74
- "Invalid JSON input. Require object with parameters as keys."
75
- end
76
- indifferent_params(hash)
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
77
  # if request body is empty, we just have empty params
78
- else
79
- nil
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."
80
87
  end
88
+ self.class.indifferent_params(hash)
81
89
  end
82
90
  end
83
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