openapi_first 0.7.0.alpha1 → 3.1.0.beta1

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.
Files changed (96) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +585 -2
  3. data/LICENSE.txt +21 -0
  4. data/README.md +360 -114
  5. data/lib/openapi_first/builder.rb +214 -0
  6. data/lib/openapi_first/child_configuration.rb +26 -0
  7. data/lib/openapi_first/configuration.rb +76 -0
  8. data/lib/openapi_first/definition.rb +98 -13
  9. data/lib/openapi_first/error_response.rb +40 -0
  10. data/lib/openapi_first/error_responses/default.rb +74 -0
  11. data/lib/openapi_first/error_responses/jsonapi.rb +60 -0
  12. data/lib/openapi_first/errors.rb +40 -0
  13. data/lib/openapi_first/failure.rb +77 -0
  14. data/lib/openapi_first/file_loader.rb +22 -0
  15. data/lib/openapi_first/header.rb +9 -0
  16. data/lib/openapi_first/json.rb +25 -0
  17. data/lib/openapi_first/middlewares/request_validation.rb +58 -0
  18. data/lib/openapi_first/middlewares/response_validation.rb +41 -0
  19. data/lib/openapi_first/parsed_request.rb +5 -0
  20. data/lib/openapi_first/ref_resolver.rb +193 -0
  21. data/lib/openapi_first/registry.rb +44 -0
  22. data/lib/openapi_first/request.rb +79 -0
  23. data/lib/openapi_first/request_body_parsers.rb +64 -0
  24. data/lib/openapi_first/request_validator.rb +32 -0
  25. data/lib/openapi_first/response.rb +43 -0
  26. data/lib/openapi_first/response_body_parsers.rb +31 -0
  27. data/lib/openapi_first/response_parser.rb +50 -0
  28. data/lib/openapi_first/response_validator.rb +16 -48
  29. data/lib/openapi_first/router/find_content.rb +17 -0
  30. data/lib/openapi_first/router/find_response.rb +53 -0
  31. data/lib/openapi_first/router/path_template.rb +48 -0
  32. data/lib/openapi_first/router.rb +84 -57
  33. data/lib/openapi_first/schema/hash.rb +39 -0
  34. data/lib/openapi_first/schema/validation_error.rb +45 -0
  35. data/lib/openapi_first/schema/validation_result.rb +30 -0
  36. data/lib/openapi_first/test/app.rb +30 -0
  37. data/lib/openapi_first/test/callable.rb +25 -0
  38. data/lib/openapi_first/test/configuration.rb +76 -0
  39. data/lib/openapi_first/test/coverage/covered_request.rb +11 -0
  40. data/lib/openapi_first/test/coverage/covered_response.rb +11 -0
  41. data/lib/openapi_first/test/coverage/plan.rb +77 -0
  42. data/lib/openapi_first/test/coverage/request_task.rb +42 -0
  43. data/lib/openapi_first/test/coverage/response_task.rb +42 -0
  44. data/lib/openapi_first/test/coverage/route_task.rb +13 -0
  45. data/lib/openapi_first/test/coverage/terminal_formatter.rb +115 -0
  46. data/lib/openapi_first/test/coverage/tracker.rb +32 -0
  47. data/lib/openapi_first/test/coverage.rb +102 -0
  48. data/lib/openapi_first/test/methods.rb +62 -0
  49. data/lib/openapi_first/test/minitest_helpers.rb +28 -0
  50. data/lib/openapi_first/test/observe.rb +37 -0
  51. data/lib/openapi_first/test/observer_middleware.rb +23 -0
  52. data/lib/openapi_first/test/plain_helpers.rb +31 -0
  53. data/lib/openapi_first/test.rb +171 -0
  54. data/lib/openapi_first/validated_request.rb +79 -0
  55. data/lib/openapi_first/validated_response.rb +53 -0
  56. data/lib/openapi_first/validators/request_body.rb +25 -0
  57. data/lib/openapi_first/validators/request_parameters.rb +49 -0
  58. data/lib/openapi_first/validators/response_body.rb +27 -0
  59. data/lib/openapi_first/validators/response_headers.rb +40 -0
  60. data/lib/openapi_first/version.rb +1 -1
  61. data/lib/openapi_first.rb +67 -35
  62. metadata +99 -133
  63. data/.github/CODEOWNERS +0 -1
  64. data/.gitignore +0 -11
  65. data/.rspec +0 -3
  66. data/.rubocop.yml +0 -14
  67. data/.travis.yml +0 -8
  68. data/Gemfile +0 -11
  69. data/Gemfile.lock +0 -126
  70. data/Rakefile +0 -15
  71. data/benchmarks/Gemfile +0 -13
  72. data/benchmarks/Gemfile.lock +0 -144
  73. data/benchmarks/apps/committee.ru +0 -30
  74. data/benchmarks/apps/grape.ru +0 -21
  75. data/benchmarks/apps/hanami_router.ru +0 -14
  76. data/benchmarks/apps/openapi.yaml +0 -86
  77. data/benchmarks/apps/openapi_first.ru +0 -22
  78. data/benchmarks/apps/openapi_first_resolve_only.ru +0 -23
  79. data/benchmarks/apps/sinatra.ru +0 -26
  80. data/benchmarks/apps/syro.ru +0 -25
  81. data/benchmarks/benchmarks.rb +0 -46
  82. data/bin/console +0 -15
  83. data/bin/setup +0 -8
  84. data/examples/README.md +0 -13
  85. data/examples/app.rb +0 -12
  86. data/examples/config.ru +0 -7
  87. data/examples/openapi.yaml +0 -29
  88. data/lib/openapi_first/app.rb +0 -27
  89. data/lib/openapi_first/coverage.rb +0 -28
  90. data/lib/openapi_first/operation.rb +0 -48
  91. data/lib/openapi_first/operation_resolver.rb +0 -43
  92. data/lib/openapi_first/request_validation.rb +0 -144
  93. data/lib/openapi_first/utils.rb +0 -31
  94. data/lib/openapi_first/validation.rb +0 -15
  95. data/lib/openapi_first/validation_format.rb +0 -31
  96. data/openapi_first.gemspec +0 -46
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'response_parser'
4
+ require_relative 'response_validator'
5
+ require_relative 'validated_response'
6
+
7
+ module OpenapiFirst
8
+ # Represents a response definition in the OpenAPI document.
9
+ # This is not a direct reflecton of the OpenAPI 3.X response definition, but a combination of
10
+ # status, content type and content schema.
11
+ class Response
12
+ def initialize(status:, headers:, content_type:, content_schema:, key:)
13
+ @status = status
14
+ @content_type = content_type
15
+ @content_schema = content_schema
16
+ @headers = headers
17
+ @key = key
18
+ @parser = ResponseParser.new(headers:, content_type:)
19
+ @validator = ResponseValidator.new(content_schema:, headers:)
20
+ end
21
+
22
+ # @attr_reader [Integer] status The HTTP status code of the response definition.
23
+ # @attr_reader [String, nil] content_type Content type of this response.
24
+ # @attr_reader [Schema, nil] content_schema the Schema of the response body.
25
+ attr_reader :status, :content_type, :content_schema, :headers, :key
26
+
27
+ def validate(response)
28
+ parsed_values = nil
29
+ error = catch FAILURE do
30
+ parsed_values = @parser.parse(response)
31
+ nil
32
+ end
33
+ error ||= @validator.call(parsed_values)
34
+ ValidatedResponse.new(response, parsed_values:, error:, response_definition: self)
35
+ end
36
+
37
+ private
38
+
39
+ def parse(request)
40
+ @parser.parse(request)
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiFirst
4
+ # @visibility private
5
+ module ResponseBodyParsers
6
+ DEFAULT = ->(body) { body }
7
+
8
+ @parsers = {}
9
+
10
+ class << self
11
+ attr_reader :parsers
12
+
13
+ def register(pattern, parser)
14
+ parsers[pattern] = parser
15
+ end
16
+
17
+ def [](content_type)
18
+ key = parsers.keys.find { content_type&.match?(_1) }
19
+ parsers.fetch(key) { DEFAULT }
20
+ end
21
+ end
22
+
23
+ register(/json/i, lambda do |body|
24
+ JSON.parse(body)
25
+ rescue JSON::ParserError
26
+ return Failure.fail!(:invalid_response_body, message: 'JSON response body must not be empty') if body.empty?
27
+
28
+ Failure.fail!(:invalid_response_body, message: 'Failed to parse response body as JSON')
29
+ end)
30
+ end
31
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'response_body_parsers'
4
+
5
+ module OpenapiFirst
6
+ ParsedResponse = Data.define(:body, :headers)
7
+
8
+ # Parse a response
9
+ class ResponseParser
10
+ def initialize(headers:, content_type:)
11
+ @headers_parser = build_headers_parser(headers)
12
+ @body_parser = ResponseBodyParsers[content_type]
13
+ end
14
+
15
+ def parse(rack_response)
16
+ ParsedResponse.new(
17
+ body: @body_parser.call(read_body(rack_response)),
18
+ headers: @headers_parser&.call(rack_response.headers) || {}
19
+ )
20
+ end
21
+
22
+ private
23
+
24
+ def read_body(rack_response)
25
+ buffered_body = +''
26
+
27
+ if rack_response.body.respond_to?(:each)
28
+ rack_response.body.each { |chunk| buffered_body.to_s << chunk }
29
+ return buffered_body
30
+ end
31
+ rack_response.body
32
+ rescue TypeError
33
+ raise Error, "Cannot not read response body. Response is not string-like, but is a #{rack_response.body.class}."
34
+ end
35
+
36
+ def build_headers_parser(headers)
37
+ return unless headers&.any?
38
+
39
+ headers_as_parameters = headers.map do |header|
40
+ {
41
+ 'name' => header.name,
42
+ 'explode' => false,
43
+ 'in' => 'header',
44
+ 'schema' => header.resolved_schema
45
+ }
46
+ end
47
+ OpenapiParameters::Header.new(headers_as_parameters).method(:unpack)
48
+ end
49
+ end
50
+ end
@@ -1,59 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'json_schemer'
4
- require 'multi_json'
5
- require_relative 'validation'
3
+ require_relative 'validators/response_headers'
4
+ require_relative 'validators/response_body'
6
5
 
7
6
  module OpenapiFirst
7
+ # Entry point for response validators
8
8
  class ResponseValidator
9
- def initialize(spec)
10
- @spec = spec
9
+ VALIDATORS = [
10
+ Validators::ResponseHeaders,
11
+ Validators::ResponseBody
12
+ ].freeze
13
+
14
+ def initialize(content_schema:, headers:)
15
+ @validators = []
16
+ @validators << Validators::ResponseBody.new(content_schema) if content_schema
17
+ @validators << Validators::ResponseHeaders.new(headers) if headers&.any?
11
18
  end
12
19
 
13
- def validate(request, response)
14
- errors = validation_errors(request, response)
15
- Validation.new(errors || [])
16
- rescue OasParser::ResponseCodeNotFound, OasParser::MethodNotFound => e
17
- Validation.new([e.message])
18
- end
19
-
20
- private
21
-
22
- def validation_errors(request, response)
23
- content = response_for(request, response)&.content
24
- return unless content
25
-
26
- content_type = content[response.content_type]
27
- unless content_type
28
- return ["Content type not found: '#{response.content_type}'"]
29
- end
30
-
31
- response_schema = content_type['schema']
32
- return unless response_schema
33
-
34
- response_data = MultiJson.load(response.body)
35
- validate_json_schema(response_schema, response_data)
36
- end
37
-
38
- def validate_json_schema(schema, data)
39
- JSONSchemer.schema(schema).validate(data).to_a.map do |error|
40
- format_error(error)
20
+ def call(parsed_response)
21
+ catch FAILURE do
22
+ @validators.each { |v| v.call(parsed_response) }
23
+ nil
41
24
  end
42
25
  end
43
-
44
- def format_error(error)
45
- ValidationFormat.error_details(error)
46
- .merge!(
47
- data_pointer: error['data_pointer'],
48
- schema_pointer: error['schema_pointer']
49
- ).tap do |formatted|
50
- end
51
- end
52
-
53
- def response_for(request, response)
54
- @spec
55
- .find_operation!(request)
56
- &.response_by_code(response.status.to_s, use_default: true)
57
- end
58
26
  end
59
27
  end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiFirst
4
+ class Router
5
+ # @visibility private
6
+ module FindContent
7
+ def self.call(contents, content_type)
8
+ return contents[nil] if content_type.nil? || content_type.empty?
9
+
10
+ contents.fetch(content_type) do
11
+ type = content_type.split(';')[0]
12
+ contents[type] || contents["#{type.split('/')[0]}/*"] || contents['*/*'] || contents[nil]
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'find_content'
4
+
5
+ module OpenapiFirst
6
+ class Router
7
+ # @visibility private
8
+ module FindResponse
9
+ Match = Data.define(:response, :error)
10
+
11
+ def self.call(responses, status, content_type, request_method:, path:)
12
+ contents = find_status(responses, status)
13
+ return response_status_not_found(status:, request_method:, path:, responses:) if contents.nil?
14
+
15
+ response = FindContent.call(contents, content_type)
16
+ return response_content_type_not_found(content_type:, contents:, request_method:, path:) unless response
17
+
18
+ Match.new(response:, error: nil)
19
+ end
20
+
21
+ def self.response_content_type_not_found(content_type:, contents:, request_method:, path:)
22
+ empty_content = content_type.nil? || content_type.empty?
23
+ message =
24
+ "Content-Type should be #{contents.keys.join(' or ')}, " \
25
+ "but was #{empty_content ? 'empty' : content_type} for " \
26
+ "#{request_method.upcase} #{path}"
27
+
28
+ Match.new(
29
+ error: Failure.new(:response_content_type_not_found, message:),
30
+ response: nil
31
+ )
32
+ end
33
+ private_class_method :response_content_type_not_found
34
+
35
+ def self.response_status_not_found(status:, request_method:, path:, responses:)
36
+ message = "Status #{status} is not defined for #{request_method.upcase} #{path}. " \
37
+ "Defined statuses are: #{responses.keys.join(', ')}."
38
+ Match.new(error: Failure.new(:response_status_not_found, message:), response: nil)
39
+ end
40
+ private_class_method :response_status_not_found
41
+
42
+ def self.find_status(responses, status)
43
+ # According to OAS status has to be a string,
44
+ # but there are a few API descriptions out there that use integers because of YAML.
45
+
46
+ responses[status] || responses[status.to_s] ||
47
+ responses["#{status / 100}XX"] ||
48
+ responses["#{status / 100}xx"] ||
49
+ responses['default']
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiFirst
4
+ class Router
5
+ # @visibility private
6
+ class PathTemplate
7
+ # See also https://spec.openapis.org/oas/v3.1.0#path-templating
8
+ TEMPLATE_EXPRESSION = /(\{[^{}]+\})/
9
+ TEMPLATE_EXPRESSION_NAME = /\{([^{}]+)\}/
10
+ ALLOWED_PARAMETER_CHARACTERS = %r{([^/?#]+)}
11
+
12
+ def self.template?(string)
13
+ string.include?('{')
14
+ end
15
+
16
+ def initialize(template)
17
+ @template = template
18
+ @names = template.scan(TEMPLATE_EXPRESSION_NAME).flatten
19
+ @pattern = build_pattern(template)
20
+ end
21
+
22
+ def to_s
23
+ @template
24
+ end
25
+
26
+ def match(path)
27
+ return {} if path == @template
28
+ return if @names.empty?
29
+
30
+ matches = path.match(@pattern)
31
+ return unless matches
32
+
33
+ values = matches.captures
34
+ @names.zip(values).to_h
35
+ end
36
+
37
+ private
38
+
39
+ def build_pattern(template)
40
+ parts = template.split(TEMPLATE_EXPRESSION).map! do |part|
41
+ part.start_with?('{') ? ALLOWED_PARAMETER_CHARACTERS : Regexp.escape(part)
42
+ end
43
+
44
+ /^#{parts.join}$/
45
+ end
46
+ end
47
+ end
48
+ end
@@ -1,84 +1,111 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'rack'
4
- require 'hanami/router'
5
- require_relative 'utils'
3
+ require_relative 'router/path_template'
4
+ require_relative 'router/find_content'
5
+ require_relative 'router/find_response'
6
6
 
7
7
  module OpenapiFirst
8
+ # Router can map requests / responses to their API definition
8
9
  class Router
9
- NOT_FOUND = Rack::Response.new('', 404).finish.freeze
10
+ # Returned by {#match}
11
+ RequestMatch = Data.define(:request_definition, :params, :error, :responses) do
12
+ def match_response(status:, content_type:)
13
+ FindResponse.call(responses, status, content_type, request_method: request_definition.request_method,
14
+ path: request_definition.path)
15
+ end
16
+ end
17
+
18
+ # Returned by {#routes} to introspect all routes
19
+ Route = Data.define(:path, :request_method, :requests, :responses)
10
20
 
11
- def initialize(app, options)
12
- @app = app
13
- @namespace = options.fetch(:namespace)
14
- @parent_app = options.fetch(:parent_app, nil)
15
- @router = build_router(options.fetch(:spec).operations)
21
+ NOT_FOUND = RequestMatch.new(request_definition: nil, params: nil, responses: nil, error: Failure.new(:not_found))
22
+ private_constant :NOT_FOUND
23
+
24
+ def initialize
25
+ @static = {}
26
+ @dynamic = {} # TODO: use a trie or similar
16
27
  end
17
28
 
18
- def call(env)
19
- route = @router.recognize(env)
20
- return route.endpoint.call(env) if route.routable?
29
+ # Returns an enumerator of all routes
30
+ def routes
31
+ @static.chain(@dynamic).lazy.flat_map do |path, request_methods|
32
+ request_methods.filter_map do |request_method, content|
33
+ next if request_method == :template
21
34
 
22
- return @parent_app.call(env) if @parent_app
35
+ Route.new(path:, request_method:, requests: content[:requests].each_value.lazy.uniq,
36
+ responses: content[:responses].each_value.lazy.flat_map(&:values))
37
+ end
38
+ end
39
+ end
23
40
 
24
- NOT_FOUND
41
+ # Add a request definition
42
+ def add_request(request, request_method:, path:, content_type: nil, allow_empty_content: false)
43
+ route = route_at(path, request_method)
44
+ requests = route[:requests]
45
+ requests[content_type] = request
46
+ requests[nil] = request if allow_empty_content
25
47
  end
26
48
 
27
- def find_handler(operation_id) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
28
- name = operation_id.match(/:*(.*)/)&.to_a&.at(1)
29
- return if name.nil?
49
+ # Add a response definition
50
+ def add_response(response, request_method:, path:, status:, response_content_type: nil)
51
+ (route_at(path, request_method)[:responses][status] ||= {})[response_content_type] = response
52
+ end
30
53
 
31
- if name.include?('.')
32
- module_name, method_name = name.split('.')
33
- klass = find_const(@namespace, module_name)
34
- return klass&.method(Utils.underscore(method_name))
54
+ # Return all request objects that match the given path and request method
55
+ def match(request_method, path, content_type: nil)
56
+ path_item, params = find_path_item(path)
57
+ unless path_item
58
+ message = "Request path #{path} is not defined in API description."
59
+ return NOT_FOUND.with(error: Failure.new(:not_found, message:))
35
60
  end
36
- if name.include?('#')
37
- module_name, klass_name = name.split('#')
38
- const = find_const(@namespace, module_name)
39
- klass = find_const(const, klass_name)
40
- return ->(params, res) { klass.new.call(params, res) }
61
+
62
+ contents = path_item.dig(request_method, :requests)
63
+ return NOT_FOUND.with(error: Failure.new(:method_not_allowed)) unless contents
64
+
65
+ request_definition = FindContent.call(contents, content_type)
66
+ unless request_definition
67
+ message = "#{content_type_err(content_type)} Content-Type should be #{contents.keys.join(' or ')}."
68
+ return NOT_FOUND.with(error: Failure.new(:unsupported_media_type, message:))
41
69
  end
42
- method_name = Utils.underscore(name)
43
- return unless @namespace.respond_to?(method_name)
44
70
 
45
- @namespace.method(method_name)
71
+ responses = path_item.dig(request_method, :responses)
72
+ RequestMatch.new(request_definition:, params:, error: nil, responses:)
46
73
  end
47
74
 
48
75
  private
49
76
 
50
- def find_const(parent, name)
51
- name = Utils.classify(name)
52
- return unless parent.const_defined?(name, false)
77
+ def route_at(path, request_method)
78
+ request_method = request_method.upcase
79
+ path_item = if PathTemplate.template?(path)
80
+ @dynamic[path] ||= { template: PathTemplate.new(path) }
81
+ else
82
+ @static[path] ||= {}
83
+ end
84
+ path_item[request_method] ||= {
85
+ requests: {},
86
+ responses: {}
87
+ }
88
+ end
89
+
90
+ def content_type_err(content_type)
91
+ return 'Content-Type must not be empty.' if content_type.nil? || content_type.empty?
53
92
 
54
- parent.const_get(name, false)
93
+ "Content-Type #{content_type} is not defined."
55
94
  end
56
95
 
57
- def build_router(operations) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
58
- router = Hanami::Router.new {}
59
- operations.each do |operation|
60
- normalized_path = operation.path.gsub('{', ':').gsub('}', '')
61
- if operation.operation_id.nil?
62
- warn "operationId is missing in '#{operation.method} #{operation.path}'. I am ignoring this operation." # rubocop:disable Layout/LineLength
63
- next
64
- end
65
- handler = find_handler(operation.operation_id)
66
- if handler.nil?
67
- warn "could not find handler for '#{operation.operation_id}' (#{operation.method} #{operation.path}). I am ignoring this operation." # rubocop:disable Layout/LineLength
68
- next
69
- end
70
- router.public_send(
71
- operation.method,
72
- normalized_path,
73
- to: lambda do |env|
74
- env[OPERATION] = operation
75
- env[PARAMS] = Utils.deep_stringify(env['router.params'])
76
- env[HANDLER] = handler
77
- @app.call(env)
78
- end
79
- )
96
+ def find_path_item(request_path)
97
+ found = @static[request_path]
98
+ return [found, {}] if found
99
+
100
+ matches = @dynamic.filter_map do |_path, path_item|
101
+ params = path_item[:template].match(request_path)
102
+ next unless params
103
+
104
+ [path_item, params]
80
105
  end
81
- router
106
+ return matches.first if matches.length == 1
107
+
108
+ matches&.min_by { |match| match[1].values.sum(&:length) }
82
109
  end
83
110
  end
84
111
  end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'validation_error'
4
+
5
+ module OpenapiFirst
6
+ module Schema
7
+ # A hash of Schemas
8
+ class Hash
9
+ # @param schema Hash of schemas
10
+ # @param required Array of required keys
11
+ def initialize(schemas, required: nil, **options)
12
+ @schemas = schemas
13
+ @options = options
14
+ @after_property_validation = options.delete(:after_property_validation)
15
+ schema = { 'type' => 'object' }
16
+ schema['required'] = required if required
17
+ @root_schema = JSONSchemer.schema(schema, **options)
18
+ end
19
+
20
+ def validate(root_value)
21
+ validation = @root_schema.validate(root_value)
22
+ validations = @schemas.reduce(validation) do |enum, (key, schema)|
23
+ root_value[key] = schema.value['default'] if schema.value.key?('default') && !root_value.key?(key)
24
+ next enum unless root_value.key?(key)
25
+
26
+ value = root_value[key]
27
+ key_validation = schema.validate(value)
28
+ @after_property_validation&.each do |hook|
29
+ hook.call(root_value, key, schema.value, nil)
30
+ end
31
+ enum.chain(key_validation.map do |err|
32
+ err.merge('data_pointer' => "/#{key}")
33
+ end)
34
+ end
35
+ ValidationResult.new(validations)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiFirst
4
+ module Schema
5
+ # One of multiple validation errors. Returned by Schema::ValidationResult#errors.
6
+ ValidationError = Data.define(:value, :data_pointer, :schema_pointer, :type, :details, :schema) do
7
+ # This returns an error message for this specific error.
8
+ # This it copied from json_schemer here to be easier to customize when passing custom data_pointers.
9
+ def message
10
+ location = data_pointer.empty? ? 'root' : "`#{data_pointer}`"
11
+
12
+ case type
13
+ when 'required'
14
+ keys = details.fetch('missing_keys', []).join(', ')
15
+ "object at #{location} is missing required properties: #{keys}"
16
+ when 'dependentRequired'
17
+ keys = details.fetch('missing_keys').join(', ')
18
+ "object at #{location} is missing required properties: #{keys}"
19
+ when 'string', 'boolean', 'number'
20
+ "value at #{location} is not a #{type}"
21
+ when 'array', 'object', 'integer'
22
+ "value at #{location} is not an #{type}"
23
+ when 'null'
24
+ "value at #{location} is not #{type}"
25
+ when 'pattern'
26
+ "string at #{location} does not match pattern: #{schema.fetch('pattern')}"
27
+ when 'format'
28
+ "value at #{location} does not match format: #{schema.fetch('format')}"
29
+ when 'const'
30
+ "value at #{location} is not: #{schema.fetch('const').inspect}"
31
+ when 'enum'
32
+ "value at #{location} is not one of: #{schema.fetch('enum')}"
33
+ when 'minimum'
34
+ "number at #{location} is less than: #{schema['minimum']}"
35
+ when 'maximum'
36
+ "number at #{location} is greater than: #{schema['maximum']}"
37
+ when 'readOnly'
38
+ "value at #{location} is `readOnly`"
39
+ else
40
+ "value at #{location} is invalid (#{type.inspect})"
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'validation_error'
4
+
5
+ module OpenapiFirst
6
+ module Schema
7
+ # Result of validating data against a schema. Return value of Schema#validate.
8
+ class ValidationResult
9
+ def initialize(validation)
10
+ @validation = validation
11
+ end
12
+
13
+ def error? = @validation.any?
14
+
15
+ # Returns an array of ValidationError objects.
16
+ def errors
17
+ @errors ||= @validation.map do |err|
18
+ ValidationError.new(
19
+ value: err['data'],
20
+ data_pointer: err['data_pointer'],
21
+ schema_pointer: err['schema_pointer'],
22
+ type: err['type'],
23
+ details: err['details'],
24
+ schema: err['schema']
25
+ )
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'observe'
4
+
5
+ module OpenapiFirst
6
+ module Test
7
+ REQUEST = 'openapi.test.request'
8
+ RESPONSE = 'openapi.test.response'
9
+
10
+ # A wrapper of the original app
11
+ # with silent request/response validation to track requests/responses.
12
+ class App < SimpleDelegator
13
+ def initialize(app, api:)
14
+ super(app)
15
+ @app = app
16
+ @definition = Test[api]
17
+ end
18
+
19
+ def call(env)
20
+ request = Rack::Request.new(env)
21
+ env[Test::REQUEST] = @definition.validate_request(request, raise_error: false)
22
+ response = @app.call(env)
23
+ status, headers, body = response
24
+ env[Test::RESPONSE] =
25
+ @definition.validate_response(request, Rack::Response[status, headers, body], raise_error: false)
26
+ response
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiFirst
4
+ module Test
5
+ # Return a Module with a call method that wrapps silent request/response validation to monitor a Rack app
6
+ # This is used by Openapi::Test.observe
7
+ module Callable
8
+ # Returns a Module with a `call(env)` method that wraps super inside silent request/response validation
9
+ # You can use `Application.prepend(OpenapiFirst::Test::Callable[oad])` to monitor your app during testing.
10
+ def self.[](definition)
11
+ Module.new.tap do |mod|
12
+ mod.define_method(:call) do |env|
13
+ request = Rack::Request.new(env)
14
+
15
+ definition.validate_request(request, raise_error: false)
16
+ response = super(env)
17
+ status, headers, body = response
18
+ definition.validate_response(request, Rack::Response[status, headers, body], raise_error: false)
19
+ response
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end