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,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenapiFirst
|
|
4
|
+
module Test
|
|
5
|
+
# Assertion methods to use when no known test framework was found
|
|
6
|
+
# These methods just raise an exception if an error was found
|
|
7
|
+
module PlainHelpers
|
|
8
|
+
def assert_api_conform(status: nil, api: openapi_first_default_api)
|
|
9
|
+
api = OpenapiFirst::Test[api]
|
|
10
|
+
# :nocov:
|
|
11
|
+
request = respond_to?(:last_request) ? last_request : @request
|
|
12
|
+
response = respond_to?(:last_response) ? last_response : @response
|
|
13
|
+
# :nocov:
|
|
14
|
+
|
|
15
|
+
if status && status != response.status
|
|
16
|
+
raise OpenapiFirst::Error,
|
|
17
|
+
"Expected status #{status}, but got #{response.status} " \
|
|
18
|
+
"from #{request.request_method.upcase} #{request.path}."
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
validated = api.validate_request(request, raise_error: false)
|
|
22
|
+
# :nocov:
|
|
23
|
+
raise validated.error.exception if validated.invalid?
|
|
24
|
+
|
|
25
|
+
validated = api.validate_response(request, response, raise_error: false)
|
|
26
|
+
raise validated.error.exception if validated.invalid?
|
|
27
|
+
# :nocov:
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'test/configuration'
|
|
4
|
+
require_relative 'registry'
|
|
5
|
+
|
|
6
|
+
module OpenapiFirst
|
|
7
|
+
# Test integration
|
|
8
|
+
module Test # rubocop:disable Metrics/ModuleLength
|
|
9
|
+
autoload :Coverage, 'openapi_first/test/coverage'
|
|
10
|
+
autoload :Methods, 'openapi_first/test/methods'
|
|
11
|
+
autoload :Observe, 'openapi_first/test/observe'
|
|
12
|
+
autoload :App, 'openapi_first/test/app'
|
|
13
|
+
extend Registry
|
|
14
|
+
|
|
15
|
+
class CoverageError < Error; end
|
|
16
|
+
class UnknownQueryParameterError < Error; end
|
|
17
|
+
|
|
18
|
+
# Inject request/response validation in a rack app class
|
|
19
|
+
def self.observe(app, api: :default)
|
|
20
|
+
Observe.observe(app, api:)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.minitest?(base)
|
|
24
|
+
base.include?(::Minitest::Assertions)
|
|
25
|
+
rescue NameError
|
|
26
|
+
false
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.definitions
|
|
30
|
+
super.empty? ? OpenapiFirst.definitions : super
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.configuration
|
|
34
|
+
@configuration ||= Configuration.new
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.registered?(oad)
|
|
38
|
+
key = oad.key
|
|
39
|
+
definitions.any? { |(_name, registered)| registered.key == key }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Sets up OpenAPI test coverage and OAD registration.
|
|
43
|
+
# @yieldparam [OpenapiFirst::Test::Configuration] configuration A configuration to setup test integration
|
|
44
|
+
def self.setup
|
|
45
|
+
install
|
|
46
|
+
yield configuration if block_given?
|
|
47
|
+
|
|
48
|
+
Coverage.start(skip_response: configuration.skip_response_coverage, skip_route: configuration.skip_coverage)
|
|
49
|
+
|
|
50
|
+
if definitions.empty?
|
|
51
|
+
raise NotRegisteredError,
|
|
52
|
+
'No API descriptions have been registered. ' \
|
|
53
|
+
'Please register your API description via ' \
|
|
54
|
+
"`OpenapiFirst.register('myopenapi.yaml)` or " \
|
|
55
|
+
'in a block passed to `OpenapiFirst::Test.setup` like this: ' \
|
|
56
|
+
"`OpenapiFirst::Test.setup { |test| test.register('myopenapi.yaml') }` " \
|
|
57
|
+
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
@exit_handler = method(:handle_exit)
|
|
61
|
+
|
|
62
|
+
main_process = Process.pid
|
|
63
|
+
@setup ||= at_exit do
|
|
64
|
+
# :nocov:
|
|
65
|
+
# Only handle exit once in the main process
|
|
66
|
+
@exit_handler&.call if Process.pid == main_process
|
|
67
|
+
# :nocov:
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.handle_exit
|
|
72
|
+
return unless configuration.report_coverage
|
|
73
|
+
|
|
74
|
+
report_coverage(
|
|
75
|
+
formatter: configuration.coverage_formatter,
|
|
76
|
+
**configuration.coverage_formatter_options
|
|
77
|
+
)
|
|
78
|
+
return unless configuration.report_coverage == true
|
|
79
|
+
|
|
80
|
+
coverage = Coverage.result.coverage
|
|
81
|
+
return if coverage >= configuration.minimum_coverage
|
|
82
|
+
|
|
83
|
+
puts "API Coverage fails with exit 2, because not all described requests and responses have been tested (#{coverage.round(4)}% covered)." # rubocop:disable Layout/LineLength
|
|
84
|
+
|
|
85
|
+
exit 2
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Print the coverage report
|
|
89
|
+
# @param formatter A formatter to define the report.
|
|
90
|
+
# @output [IO] An output where to puts the report.
|
|
91
|
+
def self.report_coverage(formatter: Coverage::TerminalFormatter, **)
|
|
92
|
+
puts formatter.new(**).format(Coverage.result)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Returns the Rack app wrapped with silent request, response validation
|
|
96
|
+
# You can use this if you want to track coverage via Test::Coverage, but don't want to use
|
|
97
|
+
# the middlewares or manual request, response validation.
|
|
98
|
+
def self.app(app, spec: nil, api: :default)
|
|
99
|
+
spec ||= self[api]
|
|
100
|
+
App.new(app, api: spec)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def self.install
|
|
104
|
+
return if @installed
|
|
105
|
+
|
|
106
|
+
OpenapiFirst.configure do |config|
|
|
107
|
+
@after_request_validation = config.after_request_validation do |validated_request, oad|
|
|
108
|
+
next unless registered?(oad)
|
|
109
|
+
raise validated_request.error.exception if raise_request_error?(validated_request)
|
|
110
|
+
|
|
111
|
+
check_unknown_query_parameters(validated_request)
|
|
112
|
+
|
|
113
|
+
Coverage.track_request(validated_request, oad)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
@after_response_validation = config.after_response_validation do |validated_response, rack_request, oad|
|
|
117
|
+
next unless registered?(oad)
|
|
118
|
+
if validated_response.invalid? && raise_response_error?(validated_response)
|
|
119
|
+
raise validated_response.error.exception
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
Coverage.track_response(validated_response, rack_request, oad)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
@installed = true
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def self.uninstall
|
|
129
|
+
configuration = OpenapiFirst.configuration
|
|
130
|
+
configuration.after_request_validation.delete(@after_request_validation)
|
|
131
|
+
configuration.after_response_validation.delete(@after_response_validation)
|
|
132
|
+
definitions.clear
|
|
133
|
+
@configuration = nil
|
|
134
|
+
@installed = nil
|
|
135
|
+
@exit_handler = nil
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
class << self
|
|
139
|
+
private
|
|
140
|
+
|
|
141
|
+
def check_unknown_query_parameters(validated_request)
|
|
142
|
+
return unless validated_request.known?
|
|
143
|
+
|
|
144
|
+
unknown_parameters = validated_request.unknown_query_parameters
|
|
145
|
+
return unless unknown_parameters
|
|
146
|
+
|
|
147
|
+
message = unknown_parameters_message(unknown_parameters, validated_request)
|
|
148
|
+
raise UnknownQueryParameterError, message
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def unknown_parameters_message(unknown_parameters, validated_request)
|
|
152
|
+
s = 's' if many?(unknown_parameters)
|
|
153
|
+
list = unknown_parameters.keys.map(&:inspect).join(', ')
|
|
154
|
+
"Unknown query parameter#{s} #{list} for #{validated_request.fullpath}"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def raise_request_error?(validated_request)
|
|
158
|
+
return false if validated_request.valid?
|
|
159
|
+
return false if validated_request.known?
|
|
160
|
+
|
|
161
|
+
!configuration.ignore_unknown_requests
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def many?(array) = array.length > 1
|
|
165
|
+
|
|
166
|
+
def raise_response_error?(invalid_response)
|
|
167
|
+
configuration.response_raise_error && !configuration.ignore_response?(invalid_response)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'forwardable'
|
|
4
|
+
require 'delegate'
|
|
5
|
+
|
|
6
|
+
module OpenapiFirst
|
|
7
|
+
# A validated request. It can be valid or not.
|
|
8
|
+
class ValidatedRequest < SimpleDelegator
|
|
9
|
+
extend Forwardable
|
|
10
|
+
|
|
11
|
+
def initialize(original_request, error:, parsed_request: nil, request_definition: nil, query_parser: nil)
|
|
12
|
+
super(original_request)
|
|
13
|
+
@parsed_request = parsed_request
|
|
14
|
+
@error = error
|
|
15
|
+
@request_definition = request_definition
|
|
16
|
+
@query_parser = query_parser
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# A Failure object if the request is invalid
|
|
20
|
+
# @return [Failure, nil]
|
|
21
|
+
attr_reader :error
|
|
22
|
+
|
|
23
|
+
# The request definition if this request is defined in the API description
|
|
24
|
+
# @return [Request, nil]
|
|
25
|
+
attr_reader :request_definition
|
|
26
|
+
|
|
27
|
+
# @!method operation_id
|
|
28
|
+
# @return [String, nil] The OpenAPI 3 operationId
|
|
29
|
+
def_delegator :request_definition, :operation_id
|
|
30
|
+
|
|
31
|
+
# @!method operation
|
|
32
|
+
# @return [Hash] The raw OpenAPI 3 operation object
|
|
33
|
+
def_delegator :request_definition, :operation
|
|
34
|
+
|
|
35
|
+
# @return [Hash] Query parameters and values that are not defined in the OpenAPI spec.
|
|
36
|
+
def unknown_query_parameters
|
|
37
|
+
@query_parser&.unknown_values(query_string)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Parsed path parameters
|
|
41
|
+
# @return [Hash<String, anything>]
|
|
42
|
+
def parsed_path_parameters = @parsed_request&.path || {}
|
|
43
|
+
|
|
44
|
+
# Parsed query parameters. This only returns the query parameters that are defined in the OpenAPI spec.
|
|
45
|
+
# @return [Hash<String, anything>]
|
|
46
|
+
def parsed_query = @parsed_request&.query || {}
|
|
47
|
+
|
|
48
|
+
# Parsed headers. This only returns the query parameters that are defined in the OpenAPI spec.
|
|
49
|
+
# @return [Hash<String, anything>]
|
|
50
|
+
def parsed_headers = @parsed_request&.headers || {}
|
|
51
|
+
|
|
52
|
+
# Parsed cookies. This only returns the query parameters that are defined in the OpenAPI spec.
|
|
53
|
+
# @return [Hash<String, anything>]
|
|
54
|
+
def parsed_cookies = @parsed_request&.cookies || {}
|
|
55
|
+
|
|
56
|
+
# Parsed body. This parses the body according to the content type.
|
|
57
|
+
# Note that this returns the hole body, not only the fields that are defined in the OpenAPI spec.
|
|
58
|
+
# You can use JSON Schemas `additionalProperties` or `unevaluatedProperties` to
|
|
59
|
+
# return a validation error if the body contains unknown fields.
|
|
60
|
+
# @return [Hash<String, anything>, anything]
|
|
61
|
+
def parsed_body = @parsed_request&.body
|
|
62
|
+
|
|
63
|
+
# Checks if the request is valid.
|
|
64
|
+
def valid? = error.nil?
|
|
65
|
+
|
|
66
|
+
# Checks if the request is invalid.
|
|
67
|
+
def invalid? = !valid?
|
|
68
|
+
|
|
69
|
+
# Returns true if the request is defined.
|
|
70
|
+
def known? = request_definition != nil
|
|
71
|
+
|
|
72
|
+
# Merged path, query, body parameters.
|
|
73
|
+
# Here path has the highest precedence, then query, then body.
|
|
74
|
+
# @return [Hash<String, anything>]
|
|
75
|
+
def parsed_params
|
|
76
|
+
@parsed_params ||= parsed_body.to_h.merge(parsed_query, parsed_path_parameters) || {}
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'forwardable'
|
|
4
|
+
require 'delegate'
|
|
5
|
+
|
|
6
|
+
module OpenapiFirst
|
|
7
|
+
# A validated response. It can be valid or not.
|
|
8
|
+
class ValidatedResponse < SimpleDelegator
|
|
9
|
+
extend Forwardable
|
|
10
|
+
|
|
11
|
+
def initialize(original_response, error:, parsed_values: nil, response_definition: nil)
|
|
12
|
+
super(original_response)
|
|
13
|
+
@error = error
|
|
14
|
+
@parsed_values = parsed_values
|
|
15
|
+
@response_definition = response_definition
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# A Failure object if the response is invalid
|
|
19
|
+
# @return [Failure, nil]
|
|
20
|
+
attr_reader :error
|
|
21
|
+
|
|
22
|
+
# The response definition if this response is defined in the API description
|
|
23
|
+
# @return [Response, nil]
|
|
24
|
+
attr_reader :response_definition
|
|
25
|
+
|
|
26
|
+
# The parsed headers
|
|
27
|
+
# @return [Hash<String,anything>, nil]
|
|
28
|
+
def parsed_headers
|
|
29
|
+
@parsed_values&.headers
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The parsed body
|
|
33
|
+
# @return [Hash<String,anything>, nil]
|
|
34
|
+
def parsed_body
|
|
35
|
+
@parsed_values&.body
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Checks if the response is valid.
|
|
39
|
+
# @return [Boolean] true if the response is valid, false otherwise.
|
|
40
|
+
def valid?
|
|
41
|
+
error.nil?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Returns true if the response is defined.
|
|
45
|
+
def known? = response_definition != nil
|
|
46
|
+
|
|
47
|
+
# Checks if the response is invalid.
|
|
48
|
+
# @return [Boolean]
|
|
49
|
+
def invalid?
|
|
50
|
+
!valid?
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenapiFirst
|
|
4
|
+
module Validators
|
|
5
|
+
class RequestBody
|
|
6
|
+
def initialize(content_schema:, required_request_body:)
|
|
7
|
+
@schema = content_schema
|
|
8
|
+
@required = required_request_body
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call(parsed_request)
|
|
12
|
+
body = parsed_request.body
|
|
13
|
+
if body.nil?
|
|
14
|
+
Failure.fail!(:invalid_body, message: 'Request body must not be empty') if @required
|
|
15
|
+
return
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
validation = Schema::ValidationResult.new(
|
|
19
|
+
@schema.validate(body, access_mode: 'write')
|
|
20
|
+
)
|
|
21
|
+
Failure.fail!(:invalid_body, errors: validation.errors) if validation.error?
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenapiFirst
|
|
4
|
+
module Validators
|
|
5
|
+
module RequestParameters
|
|
6
|
+
RequestHeaders = Data.define(:schema) do
|
|
7
|
+
def call(parsed_request)
|
|
8
|
+
validation = schema.validate(parsed_request.headers)
|
|
9
|
+
Failure.fail!(:invalid_header, errors: validation.errors) if validation.error?
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
Path = Data.define(:schema) do
|
|
14
|
+
def call(parsed_request)
|
|
15
|
+
validation = schema.validate(parsed_request.path)
|
|
16
|
+
Failure.fail!(:invalid_path, errors: validation.errors) if validation.error?
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
Query = Data.define(:schema) do
|
|
21
|
+
def call(parsed_request)
|
|
22
|
+
validation = schema.validate(parsed_request.query)
|
|
23
|
+
Failure.fail!(:invalid_query, errors: validation.errors) if validation.error?
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
RequestCookies = Data.define(:schema) do
|
|
28
|
+
def call(parsed_request)
|
|
29
|
+
validation = schema.validate(parsed_request.cookies)
|
|
30
|
+
Failure.fail!(:invalid_cookie, errors: validation.errors) if validation.error?
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
VALIDATORS = {
|
|
35
|
+
path_schema: Path,
|
|
36
|
+
query_schema: Query,
|
|
37
|
+
header_schema: RequestHeaders,
|
|
38
|
+
cookie_schema: RequestCookies
|
|
39
|
+
}.freeze
|
|
40
|
+
|
|
41
|
+
def self.for(args)
|
|
42
|
+
VALIDATORS.filter_map do |key, klass|
|
|
43
|
+
schema = args[key]
|
|
44
|
+
klass.new(schema) if schema
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../schema/validation_result'
|
|
4
|
+
|
|
5
|
+
module OpenapiFirst
|
|
6
|
+
module Validators
|
|
7
|
+
class ResponseBody
|
|
8
|
+
def initialize(schema)
|
|
9
|
+
@schema = schema
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
attr_reader :schema
|
|
13
|
+
|
|
14
|
+
def call(response)
|
|
15
|
+
begin
|
|
16
|
+
parsed_body = response.body
|
|
17
|
+
rescue ParseError => e
|
|
18
|
+
Failure.fail!(:invalid_response_body, message: e.message)
|
|
19
|
+
end
|
|
20
|
+
validation = Schema::ValidationResult.new(
|
|
21
|
+
schema.validate(parsed_body, access_mode: 'read')
|
|
22
|
+
)
|
|
23
|
+
Failure.fail!(:invalid_response_body, errors: validation.errors) if validation.error?
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenapiFirst
|
|
4
|
+
module Validators
|
|
5
|
+
class ResponseHeaders
|
|
6
|
+
def initialize(headers)
|
|
7
|
+
@headers = headers
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
attr_reader :headers
|
|
11
|
+
|
|
12
|
+
def call(parsed_response)
|
|
13
|
+
headers.each do |header|
|
|
14
|
+
header_value = parsed_response.headers[header.name]
|
|
15
|
+
next if header_value.nil? && !header.required?
|
|
16
|
+
|
|
17
|
+
validation_errors = header.schema.validate(header_value)
|
|
18
|
+
next unless validation_errors.any?
|
|
19
|
+
|
|
20
|
+
Failure.fail!(:invalid_response_header,
|
|
21
|
+
errors: [error_for(data_pointer: "/#{header.name}", value: header_value,
|
|
22
|
+
error: validation_errors.first)])
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def error_for(data_pointer:, value:, error:)
|
|
29
|
+
Schema::ValidationError.new(
|
|
30
|
+
value: value,
|
|
31
|
+
data_pointer:,
|
|
32
|
+
schema_pointer: error['schema_pointer'],
|
|
33
|
+
type: error['type'],
|
|
34
|
+
details: error['details'],
|
|
35
|
+
schema: error['schema']
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
data/lib/openapi_first.rb
CHANGED
|
@@ -1,50 +1,82 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
3
|
+
require_relative 'openapi_first/json'
|
|
4
|
+
require_relative 'openapi_first/file_loader'
|
|
5
|
+
require_relative 'openapi_first/errors'
|
|
6
|
+
require_relative 'openapi_first/registry'
|
|
7
|
+
require_relative 'openapi_first/configuration'
|
|
8
|
+
require_relative 'openapi_first/child_configuration'
|
|
9
|
+
require_relative 'openapi_first/definition'
|
|
10
|
+
require_relative 'openapi_first/version'
|
|
11
|
+
require_relative 'openapi_first/middlewares/response_validation'
|
|
12
|
+
require_relative 'openapi_first/middlewares/request_validation'
|
|
12
13
|
|
|
14
|
+
# OpenapiFirst is a toolchain to build HTTP APIS based on OpenAPI API descriptions.
|
|
13
15
|
module OpenapiFirst
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
16
|
+
extend Registry
|
|
17
|
+
|
|
18
|
+
autoload :Test, 'openapi_first/test'
|
|
19
|
+
|
|
20
|
+
# Key in rack to find instance of Request
|
|
21
|
+
REQUEST = 'openapi.request'
|
|
22
|
+
|
|
23
|
+
FAILURE = :openapi_first_validation_failure
|
|
24
|
+
|
|
25
|
+
# @return [Configuration]
|
|
26
|
+
def self.configuration
|
|
27
|
+
@configuration ||= Configuration.new
|
|
25
28
|
end
|
|
26
29
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
+
# @return [Configuration]
|
|
31
|
+
# @yield [Configuration]
|
|
32
|
+
def self.configure
|
|
33
|
+
yield configuration if block_given?
|
|
30
34
|
end
|
|
31
35
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
36
|
+
ERROR_RESPONSES = {} # rubocop:disable Style/MutableConstant
|
|
37
|
+
private_constant :ERROR_RESPONSES
|
|
38
|
+
|
|
39
|
+
# Register an error response class
|
|
40
|
+
# @param name [Symbol]
|
|
41
|
+
# @param klass [Class] A class that includes / implements OpenapiFirst::ErrorResponse
|
|
42
|
+
def self.register_error_response(name, klass)
|
|
43
|
+
ERROR_RESPONSES[name.to_sym] = klass
|
|
35
44
|
end
|
|
36
45
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
46
|
+
# @param name [Symbol]
|
|
47
|
+
# @return [Class] The error response class
|
|
48
|
+
def self.find_error_response(name)
|
|
49
|
+
ERROR_RESPONSES.fetch(name) do
|
|
50
|
+
raise "Unknown error response: #{name}. " \
|
|
51
|
+
'Register your error response class via `OpenapiFirst.register_error_response(name, klass)`. ' \
|
|
52
|
+
"Registered error responses are: #{ERROR_RESPONSES.keys.join(', ')}."
|
|
41
53
|
end
|
|
54
|
+
end
|
|
42
55
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
56
|
+
# Load and dereference an OpenAPI spec file or return the Definition if it's already loaded
|
|
57
|
+
# @param filepath_or_definition [String, Definition] The path to the file or a Definition object
|
|
58
|
+
# @return [Definition]
|
|
59
|
+
def self.load(filepath_or_definition, only: nil, &)
|
|
60
|
+
return filepath_or_definition if filepath_or_definition.is_a?(Definition)
|
|
61
|
+
return self[filepath_or_definition] if filepath_or_definition.is_a?(Symbol)
|
|
62
|
+
|
|
63
|
+
filepath = filepath_or_definition
|
|
64
|
+
raise FileNotFoundError, "File not found: #{filepath}" unless File.exist?(filepath)
|
|
65
|
+
|
|
66
|
+
contents = FileLoader.load(filepath)
|
|
67
|
+
parse(contents, only:, filepath:, &)
|
|
46
68
|
end
|
|
47
69
|
|
|
48
|
-
|
|
49
|
-
#
|
|
70
|
+
# Parse a dereferenced Hash
|
|
71
|
+
# @return [Definition]
|
|
72
|
+
# TODO: This needs to work with unresolved contents as well
|
|
73
|
+
def self.parse(contents, only: nil, filepath: nil, &)
|
|
74
|
+
contents = ::JSON.parse(::JSON.generate(contents)) # Deeply stringify keys, because of YAML. See https://github.com/ahx/openapi_first/issues/367
|
|
75
|
+
contents['paths'].filter!(&->(key, _) { only.call(key) }) if only
|
|
76
|
+
Definition.new(contents, filepath, &)
|
|
77
|
+
end
|
|
50
78
|
end
|
|
79
|
+
|
|
80
|
+
require_relative 'openapi_first/error_response'
|
|
81
|
+
require_relative 'openapi_first/error_responses/default'
|
|
82
|
+
require_relative 'openapi_first/error_responses/jsonapi'
|