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,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenapiFirst
|
|
4
|
+
# A failure object returned when validation or parsing of a request or response has failed.
|
|
5
|
+
# This returned in ValidatedRequest#error and ValidatedResponse#error.
|
|
6
|
+
class Failure
|
|
7
|
+
TYPES = {
|
|
8
|
+
not_found: [NotFoundError, 'Not found.'],
|
|
9
|
+
method_not_allowed: [RequestInvalidError, 'Request method is not defined.'],
|
|
10
|
+
unsupported_media_type: [RequestInvalidError, 'Request content type is not defined.'],
|
|
11
|
+
invalid_body: [RequestInvalidError, 'Request body invalid:'],
|
|
12
|
+
invalid_query: [RequestInvalidError, 'Query parameter is invalid:'],
|
|
13
|
+
invalid_header: [RequestInvalidError, 'Request header is invalid:'],
|
|
14
|
+
invalid_path: [RequestInvalidError, 'Path segment is invalid:'],
|
|
15
|
+
invalid_cookie: [RequestInvalidError, 'Cookie value is invalid:'],
|
|
16
|
+
response_content_type_not_found: [ResponseNotFoundError],
|
|
17
|
+
response_status_not_found: [ResponseNotFoundError],
|
|
18
|
+
invalid_response_body: [ResponseInvalidError, 'Response body is invalid:'],
|
|
19
|
+
invalid_response_header: [ResponseInvalidError, 'Response header is invalid:']
|
|
20
|
+
}.freeze
|
|
21
|
+
private_constant :TYPES
|
|
22
|
+
|
|
23
|
+
# @param type [Symbol] See Failure::TYPES.keys
|
|
24
|
+
# @param errors [Array<OpenapiFirst::Schema::ValidationError>]
|
|
25
|
+
def self.fail!(type, message: nil, errors: nil)
|
|
26
|
+
throw FAILURE, new(
|
|
27
|
+
type,
|
|
28
|
+
message:,
|
|
29
|
+
errors:
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @param type [Symbol] See TYPES.keys
|
|
34
|
+
# @param message [String] A generic error message
|
|
35
|
+
# @param errors [Array<OpenapiFirst::Schema::ValidationError>]
|
|
36
|
+
def initialize(type, message: nil, errors: nil)
|
|
37
|
+
unless TYPES.key?(type)
|
|
38
|
+
raise ArgumentError,
|
|
39
|
+
"type must be one of #{TYPES.keys} but was #{type.inspect}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
@type = type
|
|
43
|
+
@message = message
|
|
44
|
+
@errors = errors
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @attr_reader [Symbol] type The type of the failure. See TYPES.keys.
|
|
48
|
+
# Example: :invalid_body
|
|
49
|
+
attr_reader :type
|
|
50
|
+
|
|
51
|
+
# @attr_reader [Array<OpenapiFirst::Schema::ValidationError>] errors Schema validation errors
|
|
52
|
+
attr_reader :errors
|
|
53
|
+
|
|
54
|
+
# A generic error message
|
|
55
|
+
def message
|
|
56
|
+
@message ||= exception_message
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def exception(context = nil)
|
|
60
|
+
TYPES.fetch(type).first.new(exception_message, context)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def exception_message
|
|
64
|
+
_, message_prefix = TYPES.fetch(type)
|
|
65
|
+
|
|
66
|
+
[message_prefix, @message || generate_message].compact.join(' ')
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def generate_message
|
|
72
|
+
messages = errors&.take(3)&.map(&:message)
|
|
73
|
+
messages << "... (#{errors.size} errors total)" if errors && errors.size > 3
|
|
74
|
+
messages&.join('. ')
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'yaml'
|
|
5
|
+
|
|
6
|
+
module OpenapiFirst
|
|
7
|
+
# @!visibility private
|
|
8
|
+
module FileLoader
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def load(file_path)
|
|
12
|
+
raise FileNotFoundError, "File not found #{file_path.inspect}" unless File.exist?(file_path)
|
|
13
|
+
|
|
14
|
+
body = File.read(file_path)
|
|
15
|
+
extname = File.extname(file_path)
|
|
16
|
+
return ::JSON.parse(body) if extname == '.json'
|
|
17
|
+
return YAML.unsafe_load(body) if ['.yaml', '.yml'].include?(extname)
|
|
18
|
+
|
|
19
|
+
body
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# This tries to load MultiJson if available to keep compatibility
|
|
4
|
+
# with MultiJson until next major version
|
|
5
|
+
|
|
6
|
+
begin
|
|
7
|
+
require 'multi_json'
|
|
8
|
+
module OpenapiFirst
|
|
9
|
+
# Compatibility with MultiJson
|
|
10
|
+
# @visibility private
|
|
11
|
+
module JSON
|
|
12
|
+
ParserError = MultiJson::ParseError
|
|
13
|
+
|
|
14
|
+
def self.parse(string)
|
|
15
|
+
MultiJson.load(string)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.generate(object)
|
|
19
|
+
MultiJson.dump(object)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
rescue LoadError
|
|
24
|
+
require 'json'
|
|
25
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rack'
|
|
4
|
+
module OpenapiFirst
|
|
5
|
+
module Middlewares
|
|
6
|
+
# A Rack middleware to validate requests against an OpenAPI API description
|
|
7
|
+
class RequestValidation
|
|
8
|
+
# @param app The parent Rack application
|
|
9
|
+
# @param spec [String, Symbol, OpenapiFirst::Definition] Path to the OpenAPI file or an instance of Definition.
|
|
10
|
+
# If you pass a Symbol, it will load the OAD registered via `OpenapiFirst.register`
|
|
11
|
+
# If you leave this blank, it will load the OAD registered as `:default`
|
|
12
|
+
# @param options Hash
|
|
13
|
+
# :spec [String, Symbol, OpenapiFirst::Definition] Path to the OpenAPI file or an instance of Definition.
|
|
14
|
+
# This will be deprecated. Please use spec argument instead.
|
|
15
|
+
# :raise_error A Boolean indicating whether to raise an error if validation fails.
|
|
16
|
+
# default: false
|
|
17
|
+
# :error_response The Class to use for error responses.
|
|
18
|
+
# This can be a Symbol-name of an registered error response (:default, :jsonapi)
|
|
19
|
+
# or it can be set to false to disable returning a response.
|
|
20
|
+
# default: OpenapiFirst::Plugins::Default::ErrorResponse (Config.default_options.error_response)
|
|
21
|
+
def initialize(app, spec = nil, options = {})
|
|
22
|
+
@app = app
|
|
23
|
+
if spec.is_a?(Hash)
|
|
24
|
+
options = spec
|
|
25
|
+
spec = options[:spec]
|
|
26
|
+
end
|
|
27
|
+
@raise = options.fetch(:raise_error, OpenapiFirst.configuration.request_validation_raise_error)
|
|
28
|
+
@error_response_class = error_response_option(options[:error_response])
|
|
29
|
+
|
|
30
|
+
spec ||= :default
|
|
31
|
+
spec = OpenapiFirst[spec] if spec.is_a?(Symbol)
|
|
32
|
+
|
|
33
|
+
@definition = spec.is_a?(Definition) ? spec : OpenapiFirst.load(spec)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @attr_reader [Proc] app The upstream Rack application
|
|
37
|
+
attr_reader :app
|
|
38
|
+
|
|
39
|
+
def call(env)
|
|
40
|
+
validated = @definition.validate_request(Rack::Request.new(env), raise_error: @raise)
|
|
41
|
+
env[REQUEST] = validated
|
|
42
|
+
failure = validated.error
|
|
43
|
+
return @error_response_class.new(failure:).render if failure && @error_response_class
|
|
44
|
+
|
|
45
|
+
@app.call(env)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def error_response_option(value)
|
|
51
|
+
return if value == false
|
|
52
|
+
return OpenapiFirst.find_error_response(value) if value.is_a?(Symbol)
|
|
53
|
+
|
|
54
|
+
value || OpenapiFirst.configuration.request_validation_error_response
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rack'
|
|
4
|
+
|
|
5
|
+
module OpenapiFirst
|
|
6
|
+
module Middlewares
|
|
7
|
+
# A Rack middleware to validate requests against an OpenAPI API description
|
|
8
|
+
class ResponseValidation
|
|
9
|
+
# @param spec [String, Symbol, OpenapiFirst::Definition] Path to the OpenAPI file or an instance of Definition.
|
|
10
|
+
# If you pass a Symbol, it will load the OAD registered via `OpenapiFirst.register`
|
|
11
|
+
# If you leave this blank, it will load the OAD registered as `:default`
|
|
12
|
+
# @param options Hash
|
|
13
|
+
# :spec [String, OpenapiFirst::Definition] Path to the OpenAPI file or an instance of Definition.
|
|
14
|
+
# This will be deprecated. Please use spec argument instead.
|
|
15
|
+
# :raise_error [Boolean] Whether to raise an error if validation fails. default: true
|
|
16
|
+
def initialize(app, spec = nil, options = {})
|
|
17
|
+
@app = app
|
|
18
|
+
if spec.is_a?(Hash)
|
|
19
|
+
options = spec
|
|
20
|
+
spec = options[:spec]
|
|
21
|
+
end
|
|
22
|
+
@raise = options.fetch(:raise_error, OpenapiFirst.configuration.response_validation_raise_error)
|
|
23
|
+
|
|
24
|
+
spec ||= :default
|
|
25
|
+
spec = OpenapiFirst[spec] if spec.is_a?(Symbol)
|
|
26
|
+
|
|
27
|
+
@definition = spec.is_a?(Definition) ? spec : OpenapiFirst.load(spec)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @attr_reader [Proc] app The upstream Rack application
|
|
31
|
+
attr_reader :app
|
|
32
|
+
|
|
33
|
+
def call(env)
|
|
34
|
+
status, headers, body = @app.call(env)
|
|
35
|
+
@definition.validate_response(Rack::Request.new(env), Rack::Response[status, headers, body],
|
|
36
|
+
raise_error: @raise)
|
|
37
|
+
[status, headers, body]
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json_schemer'
|
|
4
|
+
|
|
5
|
+
module OpenapiFirst
|
|
6
|
+
# This is here to give traverse an OAD while keeping $refs intact
|
|
7
|
+
# @visibility private
|
|
8
|
+
module RefResolver
|
|
9
|
+
def self.load(filepath)
|
|
10
|
+
contents = OpenapiFirst::FileLoader.load(filepath)
|
|
11
|
+
self.for(contents, filepath:)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.for(value, filepath: nil, context: value)
|
|
15
|
+
case value
|
|
16
|
+
when ::Hash
|
|
17
|
+
resolver = Hash.new(value, context:, filepath:)
|
|
18
|
+
if value.key?('$ref')
|
|
19
|
+
probe = resolver.resolve_ref(value['$ref'])
|
|
20
|
+
return probe if probe.is_a?(Array)
|
|
21
|
+
end
|
|
22
|
+
resolver
|
|
23
|
+
when ::Array
|
|
24
|
+
Array.new(value, context:, filepath:)
|
|
25
|
+
when ::NilClass
|
|
26
|
+
nil
|
|
27
|
+
else
|
|
28
|
+
Simple.new(value)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @visibility private
|
|
33
|
+
module Diggable
|
|
34
|
+
def dig(*keys)
|
|
35
|
+
keys.inject(self) do |result, key|
|
|
36
|
+
break unless result.respond_to?(:[])
|
|
37
|
+
|
|
38
|
+
result[key]
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @visibility private
|
|
44
|
+
module Resolvable
|
|
45
|
+
def initialize(value, context: value, filepath: nil)
|
|
46
|
+
@value = value
|
|
47
|
+
@context = context
|
|
48
|
+
@filepath = filepath
|
|
49
|
+
@dir = if filepath
|
|
50
|
+
File.dirname(File.absolute_path(filepath))
|
|
51
|
+
else
|
|
52
|
+
Dir.pwd
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The value of this node
|
|
57
|
+
attr_reader :value
|
|
58
|
+
# The path of the file sytem directory where this was loaded from
|
|
59
|
+
attr_reader :dir
|
|
60
|
+
# The object where this node was found in
|
|
61
|
+
attr_reader :context
|
|
62
|
+
|
|
63
|
+
attr_reader :filepath
|
|
64
|
+
|
|
65
|
+
def ==(_other)
|
|
66
|
+
raise "Don't call == on an unresolved value. Use .value == other instead."
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def resolve_ref(pointer)
|
|
70
|
+
if pointer.start_with?('#')
|
|
71
|
+
value = Hana::Pointer.new(pointer[1..]).eval(context)
|
|
72
|
+
raise "Unknown reference #{pointer} in #{context}" unless value
|
|
73
|
+
|
|
74
|
+
return RefResolver.for(value, filepath:, context:)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
relative_path, file_pointer = pointer.split('#')
|
|
78
|
+
full_path = File.expand_path(relative_path, dir)
|
|
79
|
+
return RefResolver.load(full_path) unless file_pointer
|
|
80
|
+
|
|
81
|
+
file_contents = FileLoader.load(full_path)
|
|
82
|
+
value = Hana::Pointer.new(file_pointer).eval(file_contents)
|
|
83
|
+
RefResolver.for(value, filepath: full_path, context: file_contents)
|
|
84
|
+
rescue OpenapiFirst::FileNotFoundError => e
|
|
85
|
+
message = "Problem with reference resolving #{pointer.inspect} in " \
|
|
86
|
+
"file #{File.absolute_path(filepath).inspect}: #{e.message}"
|
|
87
|
+
raise OpenapiFirst::FileNotFoundError, message
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# @visibility private
|
|
92
|
+
class Simple
|
|
93
|
+
include Resolvable
|
|
94
|
+
|
|
95
|
+
def resolved = value
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# @visibility private
|
|
99
|
+
class Hash
|
|
100
|
+
include Resolvable
|
|
101
|
+
include Diggable
|
|
102
|
+
include Enumerable
|
|
103
|
+
|
|
104
|
+
def ==(_other)
|
|
105
|
+
raise "Don't call == on an unresolved value. Use .value == other instead."
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def resolved
|
|
109
|
+
return resolve_ref(value['$ref']).value if value.key?('$ref')
|
|
110
|
+
|
|
111
|
+
value
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def [](key)
|
|
115
|
+
return resolve_ref(@value['$ref'])[key] if !@value.key?(key) && @value.key?('$ref')
|
|
116
|
+
|
|
117
|
+
RefResolver.for(@value[key], filepath:, context:)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def fetch(key)
|
|
121
|
+
return resolve_ref(@value['$ref']).fetch(key) if !@value.key?(key) && @value.key?('$ref')
|
|
122
|
+
|
|
123
|
+
RefResolver.for(@value.fetch(key), filepath:, context:)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def each
|
|
127
|
+
resolved.each_key do |key|
|
|
128
|
+
yield key, self[key]
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# You have to pass configuration or ref_resolver
|
|
133
|
+
def schema(options)
|
|
134
|
+
base_uri = URI::File.build({ path: "#{dir}/" })
|
|
135
|
+
Schema.new(value:, context:, base_uri:, options:)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# @visibility private
|
|
140
|
+
# Defers initialization JSONSchemer::Schema, because that takes time.
|
|
141
|
+
class Schema
|
|
142
|
+
extend Forwardable
|
|
143
|
+
|
|
144
|
+
def initialize(value:, context:, base_uri:, options:)
|
|
145
|
+
@value = value
|
|
146
|
+
@context = context
|
|
147
|
+
@base_uri = base_uri
|
|
148
|
+
@options = options
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
attr_reader :value, :context, :base_uri, :options
|
|
152
|
+
|
|
153
|
+
def_delegators :schema, :validate, :valid?
|
|
154
|
+
|
|
155
|
+
def schema
|
|
156
|
+
@schema ||= begin
|
|
157
|
+
root_schema = JSONSchemer::Schema.new(context, base_uri:, **options)
|
|
158
|
+
JSONSchemer::Schema.new(value, nil, root_schema, base_uri:, **options)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# @visibility private
|
|
164
|
+
class Array
|
|
165
|
+
include Enumerable
|
|
166
|
+
include Resolvable
|
|
167
|
+
include Diggable
|
|
168
|
+
|
|
169
|
+
def [](index)
|
|
170
|
+
item = @value[index]
|
|
171
|
+
return resolve_ref(item['$ref']) if item.is_a?(::Hash) && item.key?('$ref')
|
|
172
|
+
|
|
173
|
+
RefResolver.for(item, filepath:, context:)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def each
|
|
177
|
+
resolved.each_with_index do |_item, index|
|
|
178
|
+
yield self[index]
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def resolved
|
|
183
|
+
value.map do |item|
|
|
184
|
+
if item.respond_to?(:key?) && item.key?('$ref')
|
|
185
|
+
resolve_ref(item['$ref']).resolved
|
|
186
|
+
else
|
|
187
|
+
item
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenapiFirst
|
|
4
|
+
class NotRegisteredError < Error; end
|
|
5
|
+
class AlreadyRegisteredError < Error; end
|
|
6
|
+
|
|
7
|
+
# @visibility private
|
|
8
|
+
module Registry
|
|
9
|
+
def definitions
|
|
10
|
+
@definitions ||= {}
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Register an OpenAPI definition for testing
|
|
14
|
+
# @param path_or_definition [String, Definition] Path to the OpenAPI file or a Definition object
|
|
15
|
+
# @param as [Symbol] Name to register the API definition as
|
|
16
|
+
def register(path_or_definition, as: :default)
|
|
17
|
+
if definitions.key?(as) && as == :default
|
|
18
|
+
raise(
|
|
19
|
+
AlreadyRegisteredError,
|
|
20
|
+
"#{definitions[as].inspect} is already registered " \
|
|
21
|
+
"as ':default' so you cannot register #{path_or_definition.inspect} without " \
|
|
22
|
+
'giving it a custom name. Please call register with a custom key like: ' \
|
|
23
|
+
"#{name}.register(#{path_or_definition.inspect}, as: :my_other_api)"
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
definition = OpenapiFirst.load(path_or_definition)
|
|
28
|
+
definitions[as] = definition
|
|
29
|
+
definition
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def [](api)
|
|
33
|
+
return api if api.is_a?(Definition)
|
|
34
|
+
|
|
35
|
+
definitions.fetch(api) do
|
|
36
|
+
option = api == :default ? '' : ", as: #{api.inspect}"
|
|
37
|
+
raise(NotRegisteredError,
|
|
38
|
+
"API description '#{api.inspect}' not found." \
|
|
39
|
+
"Please call #{name}.register('myopenapi.yaml'#{option}) " \
|
|
40
|
+
'once before running your app.')
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'openapi_parameters'
|
|
4
|
+
require_relative 'parsed_request'
|
|
5
|
+
require_relative 'request_validator'
|
|
6
|
+
require_relative 'validated_request'
|
|
7
|
+
require_relative 'request_body_parsers'
|
|
8
|
+
|
|
9
|
+
module OpenapiFirst
|
|
10
|
+
# Represents one request definition of an OpenAPI description.
|
|
11
|
+
# Note that this is not the same as an OpenAPI 3.x Operation.
|
|
12
|
+
# An 3.x Operation object can accept multiple requests, because it can handle multiple content-types.
|
|
13
|
+
# This class represents one of those requests.
|
|
14
|
+
class Request
|
|
15
|
+
def initialize(path:, request_method:, operation_object:, # rubocop:disable Metrics/MethodLength
|
|
16
|
+
parameters:, content_type:, content_schema:, required_body:, key:)
|
|
17
|
+
@path = path
|
|
18
|
+
@request_method = request_method
|
|
19
|
+
@content_type = content_type
|
|
20
|
+
@content_schema = content_schema
|
|
21
|
+
@operation = operation_object
|
|
22
|
+
@allow_empty_content = content_type.nil? || required_body == false
|
|
23
|
+
@key = key
|
|
24
|
+
@query_parser = parameters.query&.then { |params| OpenapiParameters::Query.new(params) }
|
|
25
|
+
@path_parser = parameters.path&.then { |params| OpenapiParameters::Path.new(params) }
|
|
26
|
+
@headers_parser = parameters.header&.then { |params| OpenapiParameters::Header.new(params) }
|
|
27
|
+
@cookies_parser = parameters.cookie&.then { |params| OpenapiParameters::Cookie.new(params) }
|
|
28
|
+
@body_parsers = RequestBodyParsers[content_type] if content_type
|
|
29
|
+
@validator = RequestValidator.new(
|
|
30
|
+
content_schema:,
|
|
31
|
+
required_request_body: required_body == true,
|
|
32
|
+
path_schema: parameters.path_schema,
|
|
33
|
+
query_schema: parameters.query_schema,
|
|
34
|
+
header_schema: parameters.header_schema,
|
|
35
|
+
cookie_schema: parameters.cookie_schema
|
|
36
|
+
)
|
|
37
|
+
@parameters = parameters
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
attr_reader :content_type, :content_schema, :operation, :request_method, :path, :key, :query_schema, :parameters
|
|
41
|
+
private attr_reader :query_parser
|
|
42
|
+
|
|
43
|
+
def allow_empty_content?
|
|
44
|
+
@allow_empty_content
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def validate(request, route_params:)
|
|
48
|
+
parsed_request = nil
|
|
49
|
+
error = catch FAILURE do
|
|
50
|
+
parsed_request = parse_request(request, route_params:)
|
|
51
|
+
@validator.call(parsed_request)
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
ValidatedRequest.new(request, parsed_request:, error:, request_definition: self, query_parser:)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def operation_id
|
|
58
|
+
@operation['operationId']
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def parse_request(request, route_params:)
|
|
64
|
+
ParsedRequest.new(
|
|
65
|
+
path: @path_parser&.unpack(route_params),
|
|
66
|
+
query: parse_query(request.env[Rack::QUERY_STRING]),
|
|
67
|
+
headers: @headers_parser&.unpack_env(request.env),
|
|
68
|
+
cookies: @cookies_parser&.unpack(request.env[Rack::HTTP_COOKIE]),
|
|
69
|
+
body: @body_parsers&.call(request)
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def parse_query(query_string)
|
|
74
|
+
@query_parser&.unpack(query_string)
|
|
75
|
+
rescue OpenapiParameters::InvalidParameterError
|
|
76
|
+
Failure.fail!(:invalid_query, message: 'Invalid query parameter.')
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenapiFirst
|
|
4
|
+
# @!visibility private
|
|
5
|
+
module RequestBodyParsers
|
|
6
|
+
DEFAULT = ->(request) { Utils.read_body(request) }
|
|
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
|
+
# Not sure where to put this
|
|
24
|
+
module Utils
|
|
25
|
+
def self.read_body(request)
|
|
26
|
+
body = request.body&.read
|
|
27
|
+
request.body.rewind if request.body.respond_to?(:rewind)
|
|
28
|
+
body
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
register(/json/i, lambda do |request|
|
|
33
|
+
body = Utils.read_body(request)
|
|
34
|
+
JSON.parse(body) unless body.nil? || body.empty?
|
|
35
|
+
rescue JSON::ParserError
|
|
36
|
+
Failure.fail!(:invalid_body, message: 'Failed to parse request body as JSON')
|
|
37
|
+
end)
|
|
38
|
+
|
|
39
|
+
# Parses multipart/form-data requests and currently puts the contents of a file upload at the parsed hash values.
|
|
40
|
+
# NOTE: This behavior will probably change in the next major version.
|
|
41
|
+
# The uploaded file should not be read during request validation.
|
|
42
|
+
module MultipartBodyParser
|
|
43
|
+
def self.call(request)
|
|
44
|
+
request.POST.transform_values do |value|
|
|
45
|
+
unpack_value(value)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.unpack_value(value)
|
|
50
|
+
return value.map { unpack_value(_1) } if value.is_a?(Array)
|
|
51
|
+
return value unless value.is_a?(Hash)
|
|
52
|
+
return value[:tempfile]&.read if value.key?(:tempfile)
|
|
53
|
+
|
|
54
|
+
value.transform_values do |v|
|
|
55
|
+
unpack_value(v)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
register('multipart/form-data', MultipartBodyParser)
|
|
61
|
+
|
|
62
|
+
register('application/x-www-form-urlencoded', lambda(&:POST))
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'failure'
|
|
4
|
+
require_relative 'validators/request_parameters'
|
|
5
|
+
require_relative 'validators/request_body'
|
|
6
|
+
|
|
7
|
+
module OpenapiFirst
|
|
8
|
+
# Validates a Request against a request definition.
|
|
9
|
+
class RequestValidator
|
|
10
|
+
def initialize(
|
|
11
|
+
content_schema:,
|
|
12
|
+
required_request_body:,
|
|
13
|
+
path_schema:,
|
|
14
|
+
query_schema:,
|
|
15
|
+
header_schema:,
|
|
16
|
+
cookie_schema:
|
|
17
|
+
)
|
|
18
|
+
@validators = []
|
|
19
|
+
@validators << Validators::RequestBody.new(content_schema:, required_request_body:) if content_schema
|
|
20
|
+
@validators.concat Validators::RequestParameters.for(
|
|
21
|
+
path_schema:,
|
|
22
|
+
query_schema:,
|
|
23
|
+
header_schema:,
|
|
24
|
+
cookie_schema:
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def call(parsed_request)
|
|
29
|
+
@validators.each { |v| v.call(parsed_request) }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|