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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +585 -2
- data/LICENSE.txt +21 -0
- data/README.md +360 -114
- data/lib/openapi_first/builder.rb +214 -0
- data/lib/openapi_first/child_configuration.rb +26 -0
- data/lib/openapi_first/configuration.rb +76 -0
- data/lib/openapi_first/definition.rb +98 -13
- data/lib/openapi_first/error_response.rb +40 -0
- data/lib/openapi_first/error_responses/default.rb +74 -0
- data/lib/openapi_first/error_responses/jsonapi.rb +60 -0
- data/lib/openapi_first/errors.rb +40 -0
- data/lib/openapi_first/failure.rb +77 -0
- data/lib/openapi_first/file_loader.rb +22 -0
- data/lib/openapi_first/header.rb +9 -0
- data/lib/openapi_first/json.rb +25 -0
- data/lib/openapi_first/middlewares/request_validation.rb +58 -0
- data/lib/openapi_first/middlewares/response_validation.rb +41 -0
- data/lib/openapi_first/parsed_request.rb +5 -0
- data/lib/openapi_first/ref_resolver.rb +193 -0
- data/lib/openapi_first/registry.rb +44 -0
- data/lib/openapi_first/request.rb +79 -0
- data/lib/openapi_first/request_body_parsers.rb +64 -0
- data/lib/openapi_first/request_validator.rb +32 -0
- data/lib/openapi_first/response.rb +43 -0
- data/lib/openapi_first/response_body_parsers.rb +31 -0
- data/lib/openapi_first/response_parser.rb +50 -0
- data/lib/openapi_first/response_validator.rb +16 -48
- data/lib/openapi_first/router/find_content.rb +17 -0
- data/lib/openapi_first/router/find_response.rb +53 -0
- data/lib/openapi_first/router/path_template.rb +48 -0
- data/lib/openapi_first/router.rb +84 -57
- data/lib/openapi_first/schema/hash.rb +39 -0
- data/lib/openapi_first/schema/validation_error.rb +45 -0
- data/lib/openapi_first/schema/validation_result.rb +30 -0
- data/lib/openapi_first/test/app.rb +30 -0
- data/lib/openapi_first/test/callable.rb +25 -0
- data/lib/openapi_first/test/configuration.rb +76 -0
- data/lib/openapi_first/test/coverage/covered_request.rb +11 -0
- data/lib/openapi_first/test/coverage/covered_response.rb +11 -0
- data/lib/openapi_first/test/coverage/plan.rb +77 -0
- data/lib/openapi_first/test/coverage/request_task.rb +42 -0
- data/lib/openapi_first/test/coverage/response_task.rb +42 -0
- data/lib/openapi_first/test/coverage/route_task.rb +13 -0
- data/lib/openapi_first/test/coverage/terminal_formatter.rb +115 -0
- data/lib/openapi_first/test/coverage/tracker.rb +32 -0
- data/lib/openapi_first/test/coverage.rb +102 -0
- data/lib/openapi_first/test/methods.rb +62 -0
- data/lib/openapi_first/test/minitest_helpers.rb +28 -0
- data/lib/openapi_first/test/observe.rb +37 -0
- data/lib/openapi_first/test/observer_middleware.rb +23 -0
- data/lib/openapi_first/test/plain_helpers.rb +31 -0
- data/lib/openapi_first/test.rb +171 -0
- data/lib/openapi_first/validated_request.rb +79 -0
- data/lib/openapi_first/validated_response.rb +53 -0
- data/lib/openapi_first/validators/request_body.rb +25 -0
- data/lib/openapi_first/validators/request_parameters.rb +49 -0
- data/lib/openapi_first/validators/response_body.rb +27 -0
- data/lib/openapi_first/validators/response_headers.rb +40 -0
- data/lib/openapi_first/version.rb +1 -1
- data/lib/openapi_first.rb +67 -35
- metadata +99 -133
- data/.github/CODEOWNERS +0 -1
- data/.gitignore +0 -11
- data/.rspec +0 -3
- data/.rubocop.yml +0 -14
- data/.travis.yml +0 -8
- data/Gemfile +0 -11
- data/Gemfile.lock +0 -126
- data/Rakefile +0 -15
- data/benchmarks/Gemfile +0 -13
- data/benchmarks/Gemfile.lock +0 -144
- data/benchmarks/apps/committee.ru +0 -30
- data/benchmarks/apps/grape.ru +0 -21
- data/benchmarks/apps/hanami_router.ru +0 -14
- data/benchmarks/apps/openapi.yaml +0 -86
- data/benchmarks/apps/openapi_first.ru +0 -22
- data/benchmarks/apps/openapi_first_resolve_only.ru +0 -23
- data/benchmarks/apps/sinatra.ru +0 -26
- data/benchmarks/apps/syro.ru +0 -25
- data/benchmarks/benchmarks.rb +0 -46
- data/bin/console +0 -15
- data/bin/setup +0 -8
- data/examples/README.md +0 -13
- data/examples/app.rb +0 -12
- data/examples/config.ru +0 -7
- data/examples/openapi.yaml +0 -29
- data/lib/openapi_first/app.rb +0 -27
- data/lib/openapi_first/coverage.rb +0 -28
- data/lib/openapi_first/operation.rb +0 -48
- data/lib/openapi_first/operation_resolver.rb +0 -43
- data/lib/openapi_first/request_validation.rb +0 -144
- data/lib/openapi_first/utils.rb +0 -31
- data/lib/openapi_first/validation.rb +0 -15
- data/lib/openapi_first/validation_format.rb +0 -31
- 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
|
-
|
|
4
|
-
|
|
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
|
-
|
|
10
|
-
|
|
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
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
data/lib/openapi_first/router.rb
CHANGED
|
@@ -1,84 +1,111 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
require_relative '
|
|
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
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
@
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
93
|
+
"Content-Type #{content_type} is not defined."
|
|
55
94
|
end
|
|
56
95
|
|
|
57
|
-
def
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|