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,214 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json_schemer'
|
|
4
|
+
|
|
5
|
+
require_relative 'failure'
|
|
6
|
+
require_relative 'router'
|
|
7
|
+
require_relative 'header'
|
|
8
|
+
require_relative 'request'
|
|
9
|
+
require_relative 'response'
|
|
10
|
+
require_relative 'schema/hash'
|
|
11
|
+
require_relative 'ref_resolver'
|
|
12
|
+
|
|
13
|
+
module OpenapiFirst
|
|
14
|
+
# Builds parts of a Definition
|
|
15
|
+
# This knows how to read a resolved OpenAPI document and build {Request} and {Response} objects.
|
|
16
|
+
class Builder # rubocop:disable Metrics/ClassLength
|
|
17
|
+
REQUEST_METHODS = %w[get head post put patch delete trace options query].freeze
|
|
18
|
+
|
|
19
|
+
# Builds a router from a resolved OpenAPI document.
|
|
20
|
+
# @param contents [Hash] The OpenAPI document Hash.
|
|
21
|
+
# @param config [OpenapiFirst::Configuration] The configuration object.
|
|
22
|
+
def self.build_router(contents, filepath:, config:)
|
|
23
|
+
new(contents, filepath:, config:).router
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def initialize(contents, filepath:, config:)
|
|
27
|
+
meta_schema = detect_meta_schema(contents, filepath)
|
|
28
|
+
@schemer_configuration = build_schemer_config(filepath:, meta_schema:)
|
|
29
|
+
@config = config
|
|
30
|
+
@contents = RefResolver.for(contents, filepath:)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
attr_reader :config
|
|
34
|
+
private attr_reader :schemer_configuration
|
|
35
|
+
|
|
36
|
+
def build_schemer_config(filepath:, meta_schema:)
|
|
37
|
+
result = JSONSchemer.configuration.clone
|
|
38
|
+
dir = (filepath && File.absolute_path(File.dirname(filepath))) || Dir.pwd
|
|
39
|
+
result.base_uri = URI::File.build({ path: "#{dir}/" })
|
|
40
|
+
result.ref_resolver = JSONSchemer::CachedResolver.new do |uri|
|
|
41
|
+
FileLoader.load(uri.path)
|
|
42
|
+
end
|
|
43
|
+
result.meta_schema = meta_schema
|
|
44
|
+
result.insert_property_defaults = true
|
|
45
|
+
result
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def detect_meta_schema(document, filepath)
|
|
49
|
+
# Copied from JSONSchemer 🙇🏻♂️
|
|
50
|
+
version = document['openapi']
|
|
51
|
+
case version
|
|
52
|
+
when /\A3\.1\.\d+\z/
|
|
53
|
+
document.fetch('jsonSchemaDialect') { JSONSchemer::OpenAPI31::BASE_URI.to_s }
|
|
54
|
+
when /\A3\.0\.\d+\z/
|
|
55
|
+
JSONSchemer::OpenAPI30::BASE_URI.to_s
|
|
56
|
+
else
|
|
57
|
+
raise Error, "Unsupported OpenAPI version #{version.inspect} #{filepath}"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def router # rubocop:disable Metrics/MethodLength
|
|
62
|
+
router = OpenapiFirst::Router.new
|
|
63
|
+
@contents.fetch('paths').each do |path, path_item_object|
|
|
64
|
+
path_parameters = path_item_object['parameters'] || []
|
|
65
|
+
path_item_object.resolved.keys.intersection(REQUEST_METHODS).map do |request_method|
|
|
66
|
+
operation_object = path_item_object[request_method]
|
|
67
|
+
operation_parameters = operation_object['parameters'] || []
|
|
68
|
+
parameters = parse_parameters(operation_parameters.chain(path_parameters))
|
|
69
|
+
|
|
70
|
+
build_requests(path:, request_method:, operation_object:,
|
|
71
|
+
parameters:).each do |request|
|
|
72
|
+
router.add_request(
|
|
73
|
+
request,
|
|
74
|
+
request_method:,
|
|
75
|
+
path:,
|
|
76
|
+
content_type: request.content_type,
|
|
77
|
+
allow_empty_content: request.allow_empty_content?
|
|
78
|
+
)
|
|
79
|
+
build_responses(request:, responses: operation_object['responses']).each do |response|
|
|
80
|
+
router.add_response(
|
|
81
|
+
response,
|
|
82
|
+
request_method:,
|
|
83
|
+
path:,
|
|
84
|
+
status: response.status,
|
|
85
|
+
response_content_type: response.content_type
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
router
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def parse_parameters(parameters)
|
|
95
|
+
grouped_parameters = group_parameters(parameters)
|
|
96
|
+
ParsedParameters.new(
|
|
97
|
+
query: resolve_parameters(grouped_parameters[:query]),
|
|
98
|
+
path: resolve_parameters(grouped_parameters[:path]),
|
|
99
|
+
cookie: resolve_parameters(grouped_parameters[:cookie]),
|
|
100
|
+
header: resolve_parameters(grouped_parameters[:header]),
|
|
101
|
+
query_schema: build_parameter_schema(grouped_parameters[:query]),
|
|
102
|
+
path_schema: build_parameter_schema(grouped_parameters[:path]),
|
|
103
|
+
cookie_schema: build_parameter_schema(grouped_parameters[:cookie]),
|
|
104
|
+
header_schema: build_parameter_schema(grouped_parameters[:header])
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def resolve_parameters(parameters)
|
|
109
|
+
parameters&.map do |parameter|
|
|
110
|
+
result = parameter.resolved
|
|
111
|
+
result['schema'] = parameter['schema'].resolved
|
|
112
|
+
result
|
|
113
|
+
end.to_a
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def build_parameter_schema(parameters)
|
|
117
|
+
return unless parameters
|
|
118
|
+
|
|
119
|
+
required = []
|
|
120
|
+
schemas = parameters.each_with_object({}) do |parameter, result|
|
|
121
|
+
schema = parameter['schema'].schema(configuration: schemer_configuration)
|
|
122
|
+
name = parameter['name']&.value
|
|
123
|
+
required << name if parameter['required']&.value
|
|
124
|
+
result[name] = schema if schema
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
Schema::Hash.new(schemas, required:, configuration: schemer_configuration,
|
|
128
|
+
after_property_validation: config.after_request_parameter_property_validation)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def build_requests(path:, request_method:, operation_object:, parameters:)
|
|
132
|
+
content_objects = operation_object.dig('requestBody', 'content')
|
|
133
|
+
if content_objects.nil?
|
|
134
|
+
return [
|
|
135
|
+
request_without_body(path:, request_method:, parameters:, operation_object:)
|
|
136
|
+
]
|
|
137
|
+
end
|
|
138
|
+
required_body = operation_object['requestBody']&.resolved&.fetch('required', false) == true
|
|
139
|
+
content_objects.map do |content_type, content_object|
|
|
140
|
+
content_schema = content_object['schema'].schema(
|
|
141
|
+
configuration: schemer_configuration,
|
|
142
|
+
after_property_validation: config.after_request_body_property_validation
|
|
143
|
+
)
|
|
144
|
+
Request.new(path:, request_method:, parameters:,
|
|
145
|
+
operation_object: operation_object.resolved,
|
|
146
|
+
content_type:,
|
|
147
|
+
content_schema:,
|
|
148
|
+
required_body:,
|
|
149
|
+
key: [path, request_method, content_type].join(':'))
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def request_without_body(path:, request_method:, parameters:, operation_object:)
|
|
154
|
+
Request.new(path:, request_method:, parameters:,
|
|
155
|
+
operation_object: operation_object.resolved,
|
|
156
|
+
content_type: nil,
|
|
157
|
+
content_schema: nil,
|
|
158
|
+
required_body: false,
|
|
159
|
+
key: [path, request_method, nil].join(':'))
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def build_responses(responses:, request:)
|
|
163
|
+
return [] unless responses
|
|
164
|
+
|
|
165
|
+
responses.flat_map do |status, response_object|
|
|
166
|
+
headers = build_response_headers(response_object['headers'])
|
|
167
|
+
response_object['content']&.map do |content_type, content_object|
|
|
168
|
+
content_schema = content_object['schema'].schema(configuration: schemer_configuration)
|
|
169
|
+
Response.new(status:,
|
|
170
|
+
headers:,
|
|
171
|
+
content_type:,
|
|
172
|
+
content_schema:,
|
|
173
|
+
key: [request.key, status, content_type].join(':'))
|
|
174
|
+
end || Response.new(status:, headers:, content_type: nil,
|
|
175
|
+
content_schema: nil, key: [request.key, status, nil].join(':'))
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
IGNORED_HEADER_PARAMETERS = Set['Content-Type', 'Accept', 'Authorization'].freeze
|
|
180
|
+
private_constant :IGNORED_HEADER_PARAMETERS
|
|
181
|
+
|
|
182
|
+
def build_response_headers(headers_object)
|
|
183
|
+
return if headers_object.nil?
|
|
184
|
+
|
|
185
|
+
result = []
|
|
186
|
+
headers_object.each do |name, header|
|
|
187
|
+
next if header['schema'].nil?
|
|
188
|
+
next if IGNORED_HEADER_PARAMETERS.include?(name)
|
|
189
|
+
|
|
190
|
+
header = Header.new(
|
|
191
|
+
name:,
|
|
192
|
+
schema: header['schema'].schema(configuration: schemer_configuration),
|
|
193
|
+
required?: header['required']&.value == true,
|
|
194
|
+
node: header
|
|
195
|
+
)
|
|
196
|
+
result << header
|
|
197
|
+
end
|
|
198
|
+
result
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def group_parameters(parameter_definitions)
|
|
202
|
+
result = {}
|
|
203
|
+
parameter_definitions&.each do |parameter|
|
|
204
|
+
(result[parameter['in']&.value&.to_sym] ||= []) << parameter
|
|
205
|
+
end
|
|
206
|
+
result[:header]&.reject! { IGNORED_HEADER_PARAMETERS.include?(_1['name']&.value) }
|
|
207
|
+
result
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
ParsedParameters = Data.define(:path, :query, :header, :cookie, :path_schema, :query_schema, :header_schema,
|
|
211
|
+
:cookie_schema)
|
|
212
|
+
private_constant :ParsedParameters
|
|
213
|
+
end
|
|
214
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenapiFirst
|
|
4
|
+
# A subclass to configuration that points to its parent
|
|
5
|
+
class ChildConfiguration < Configuration
|
|
6
|
+
def initialize(parent:)
|
|
7
|
+
super()
|
|
8
|
+
@parent = parent
|
|
9
|
+
@request_validation_error_response = parent.request_validation_error_response
|
|
10
|
+
@request_validation_raise_error = parent.request_validation_raise_error
|
|
11
|
+
@response_validation_raise_error = parent.response_validation_raise_error
|
|
12
|
+
@path = parent.path
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private attr_reader :parent
|
|
16
|
+
|
|
17
|
+
HOOKS.each do |hook|
|
|
18
|
+
define_method(hook) do |&block|
|
|
19
|
+
return hooks[hook].chain(parent.hooks[hook]) if block.nil?
|
|
20
|
+
|
|
21
|
+
hooks[hook] << block
|
|
22
|
+
block
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenapiFirst
|
|
4
|
+
# Global configuration. Currently only used for the request validation middleware.
|
|
5
|
+
class Configuration
|
|
6
|
+
HOOKS = %i[
|
|
7
|
+
after_request_validation
|
|
8
|
+
after_response_validation
|
|
9
|
+
after_request_parameter_property_validation
|
|
10
|
+
after_request_body_property_validation
|
|
11
|
+
].freeze
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@request_validation_error_response = OpenapiFirst.find_error_response(:default)
|
|
15
|
+
@request_validation_raise_error = false
|
|
16
|
+
@response_validation_raise_error = true
|
|
17
|
+
@hooks = HOOKS.to_h { [_1, Set.new] }
|
|
18
|
+
@path = nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def register(path_or_definition, as: :default)
|
|
22
|
+
OpenapiFirst.register(path_or_definition, as:)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
attr_reader :hooks, :request_validation_error_response
|
|
26
|
+
attr_accessor :path
|
|
27
|
+
|
|
28
|
+
# @deprecated
|
|
29
|
+
attr_reader :request_validation_raise_error
|
|
30
|
+
# @deprecated
|
|
31
|
+
attr_reader :response_validation_raise_error
|
|
32
|
+
|
|
33
|
+
# Return a child configuration that still receives updates of global hooks.
|
|
34
|
+
def child
|
|
35
|
+
ChildConfiguration.new(parent: self)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @visibility private
|
|
39
|
+
def clone
|
|
40
|
+
raise NoMethodError, 'OpenapiFirst::Configuration#clone was removed. You want to call #child instead'
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @deprecated Pass `raise_error:` to OpenapiFirst::Middlewares::RequestValidation directly
|
|
44
|
+
def request_validation_raise_error=(value)
|
|
45
|
+
message = 'Setting OpenapiFirst::Configuration#request_validation_raise_error will be removed. ' \
|
|
46
|
+
'Please pass `raise_error:` to `OpenapiFirst::Middlewares::RequestValidation directly`'
|
|
47
|
+
warn message, category: :deprecated
|
|
48
|
+
@request_validation_raise_error = value
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @deprecated Pass `raise_error:` to OpenapiFirst::Middlewares::ResponseValidation directly
|
|
52
|
+
def response_validation_raise_error=(value)
|
|
53
|
+
message = 'Setting OpenapiFirst::Configuration#request_validation_raise_error will be removed. ' \
|
|
54
|
+
'Please pass `raise_error:` to `OpenapiFirst::Middlewares::ResponseValidation directly`'
|
|
55
|
+
warn message
|
|
56
|
+
@response_validation_raise_error = value
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
HOOKS.each do |hook|
|
|
60
|
+
define_method(hook) do |&block|
|
|
61
|
+
return hooks[hook] if block.nil?
|
|
62
|
+
|
|
63
|
+
hooks[hook] << block
|
|
64
|
+
block
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def request_validation_error_response=(mod)
|
|
69
|
+
@request_validation_error_response = if mod.is_a?(Symbol)
|
|
70
|
+
OpenapiFirst.find_error_response(mod)
|
|
71
|
+
else
|
|
72
|
+
mod
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -1,27 +1,112 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative '
|
|
3
|
+
require_relative 'builder'
|
|
4
|
+
require 'forwardable'
|
|
4
5
|
|
|
5
6
|
module OpenapiFirst
|
|
7
|
+
# Represents an OpenAPI API Description document
|
|
8
|
+
# This is returned by OpenapiFirst.load.
|
|
6
9
|
class Definition
|
|
7
|
-
|
|
8
|
-
|
|
10
|
+
extend Forwardable
|
|
11
|
+
|
|
12
|
+
# @return [String,nil]
|
|
13
|
+
attr_reader :filepath
|
|
14
|
+
# @return [Configuration]
|
|
15
|
+
attr_reader :config
|
|
16
|
+
# @return [Enumerable[String]]
|
|
17
|
+
attr_reader :paths
|
|
18
|
+
# @return [Router]
|
|
19
|
+
attr_reader :router
|
|
20
|
+
|
|
21
|
+
# @param contents [Hash] The OpenAPI document.
|
|
22
|
+
# @param filepath [String] The file path of the OpenAPI document.
|
|
23
|
+
def initialize(contents, filepath = nil)
|
|
24
|
+
@filepath = filepath
|
|
25
|
+
@config = OpenapiFirst.configuration.child
|
|
26
|
+
yield @config if block_given?
|
|
27
|
+
@config.freeze
|
|
28
|
+
@router = Builder.build_router(contents, filepath:, config:)
|
|
29
|
+
@resolved = contents
|
|
30
|
+
@paths = @router.routes.map(&:path).to_a.uniq # TODO: Refactor
|
|
9
31
|
end
|
|
10
32
|
|
|
11
|
-
|
|
12
|
-
|
|
33
|
+
# Gives access to the raw resolved Hash. Like `mydefinition['components'].dig('schemas', 'Stations')`
|
|
34
|
+
# @!method [](key)
|
|
35
|
+
# @return [Hash]
|
|
36
|
+
def_delegators :@resolved, :[]
|
|
37
|
+
|
|
38
|
+
# Returns an Enumerable of available Routes for this API description.
|
|
39
|
+
# @!method routes
|
|
40
|
+
# @return [Enumerable[Router::Route]]
|
|
41
|
+
def_delegators :@router, :routes
|
|
42
|
+
|
|
43
|
+
# Returns a unique identifier for this API definition
|
|
44
|
+
# @return [String] A unique key for this API definition
|
|
45
|
+
def key
|
|
46
|
+
return filepath if filepath
|
|
47
|
+
|
|
48
|
+
info = self['info'] || {}
|
|
49
|
+
title = info['title']
|
|
50
|
+
version = info['version']
|
|
51
|
+
|
|
52
|
+
if title.nil? || version.nil?
|
|
53
|
+
raise ArgumentError,
|
|
54
|
+
"Cannot generate key for the OpenAPI document because 'info.title' or 'info.version' is missing. " \
|
|
55
|
+
'Please add these fields to your OpenAPI document.'
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
"#{title} @ #{version}"
|
|
13
59
|
end
|
|
14
60
|
|
|
15
|
-
def
|
|
16
|
-
@
|
|
17
|
-
.path_by_path(request.path)
|
|
18
|
-
.endpoint_by_method(request.request_method.downcase)
|
|
61
|
+
def inspect
|
|
62
|
+
"#<#{self.class.name} @key='#{key}'>"
|
|
19
63
|
end
|
|
20
64
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
65
|
+
# Validates the request against the API description.
|
|
66
|
+
# @param [Rack::Request] request The Rack request object.
|
|
67
|
+
# @param [Boolean] raise_error Whether to raise an error if validation fails.
|
|
68
|
+
# @return [ValidatedRequest] The validated request object.
|
|
69
|
+
def validate_request(request, raise_error: false)
|
|
70
|
+
route = @router.match(request.request_method, resolve_path(request), content_type: request.content_type)
|
|
71
|
+
if route.error
|
|
72
|
+
ValidatedRequest.new(request, error: route.error)
|
|
73
|
+
else
|
|
74
|
+
route.request_definition.validate(request, route_params: route.params)
|
|
75
|
+
end.tap do |validated|
|
|
76
|
+
@config.after_request_validation.each { |hook| hook.call(validated, self) }
|
|
77
|
+
raise validated.error.exception(validated) if validated.error && raise_error
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Validates the response against the API description.
|
|
82
|
+
# @param rack_request [Rack::Request] The Rack request object.
|
|
83
|
+
# @param rack_response [Rack::Response] The Rack response object.
|
|
84
|
+
# @param raise_error [Boolean] Whethir to raise an error if validation fails.
|
|
85
|
+
# @return [ValidatedResponse] The validated response object.
|
|
86
|
+
def validate_response(rack_request, rack_response, raise_error: false)
|
|
87
|
+
route = @router.match(rack_request.request_method, resolve_path(rack_request),
|
|
88
|
+
content_type: rack_request.content_type)
|
|
89
|
+
return if route.error # Skip response validation for unknown requests
|
|
90
|
+
|
|
91
|
+
response_match = route.match_response(status: rack_response.status, content_type: rack_response.content_type)
|
|
92
|
+
error = response_match.error
|
|
93
|
+
validated = if error
|
|
94
|
+
ValidatedResponse.new(rack_response, error:)
|
|
95
|
+
else
|
|
96
|
+
response_match.response.validate(rack_response)
|
|
97
|
+
end
|
|
98
|
+
@config.after_response_validation&.each { |hook| hook.call(validated, rack_request, self) }
|
|
99
|
+
raise validated.error.exception(validated) if raise_error && validated.invalid?
|
|
100
|
+
|
|
101
|
+
validated
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def resolve_path(rack_request)
|
|
107
|
+
return rack_request.path unless @config.path
|
|
108
|
+
|
|
109
|
+
@config.path.call(rack_request)
|
|
25
110
|
end
|
|
26
111
|
end
|
|
27
112
|
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenapiFirst
|
|
4
|
+
# This is the base module for error responses
|
|
5
|
+
module ErrorResponse
|
|
6
|
+
## @param failure [OpenapiFirst::Failure]
|
|
7
|
+
def initialize(failure: nil)
|
|
8
|
+
@failure = failure
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
attr_reader :failure
|
|
12
|
+
|
|
13
|
+
# The response body
|
|
14
|
+
def body
|
|
15
|
+
raise "#{self.class} must implement the method #{__method__}"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# The response content-type
|
|
19
|
+
def content_type
|
|
20
|
+
raise "#{self.class} must implement the method #{__method__}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
STATUS = {
|
|
24
|
+
not_found: 404,
|
|
25
|
+
method_not_allowed: 405,
|
|
26
|
+
unsupported_media_type: 415
|
|
27
|
+
}.freeze
|
|
28
|
+
private_constant :STATUS
|
|
29
|
+
|
|
30
|
+
# The response status
|
|
31
|
+
def status
|
|
32
|
+
STATUS[failure.type] || 400
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Render this error response
|
|
36
|
+
def render
|
|
37
|
+
Rack::Response.new(body, status, Rack::CONTENT_TYPE => content_type).finish
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenapiFirst
|
|
4
|
+
module ErrorResponses
|
|
5
|
+
# An error reponse that returns application/problem+json with a list of "errors"
|
|
6
|
+
# See also https://www.rfc-editor.org/rfc/rfc9457.html
|
|
7
|
+
class Default
|
|
8
|
+
include OpenapiFirst::ErrorResponse
|
|
9
|
+
|
|
10
|
+
OpenapiFirst.register_error_response(:default, self)
|
|
11
|
+
|
|
12
|
+
TITLES = {
|
|
13
|
+
not_found: 'Not Found',
|
|
14
|
+
method_not_allowed: 'Request Method Not Allowed',
|
|
15
|
+
unsupported_media_type: 'Unsupported Media Type',
|
|
16
|
+
invalid_body: 'Bad Request Body',
|
|
17
|
+
invalid_query: 'Bad Query Parameter',
|
|
18
|
+
invalid_header: 'Bad Request Header',
|
|
19
|
+
invalid_path: 'Bad Request Path',
|
|
20
|
+
invalid_cookie: 'Bad Request Cookie'
|
|
21
|
+
}.freeze
|
|
22
|
+
private_constant :TITLES
|
|
23
|
+
|
|
24
|
+
def body
|
|
25
|
+
result = {
|
|
26
|
+
title:,
|
|
27
|
+
status:
|
|
28
|
+
}
|
|
29
|
+
result[:errors] = errors if failure.errors
|
|
30
|
+
JSON.generate(result)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def type = failure.type
|
|
34
|
+
|
|
35
|
+
def title
|
|
36
|
+
TITLES.fetch(type)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def content_type
|
|
40
|
+
'application/problem+json'
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def errors
|
|
44
|
+
key = pointer_key
|
|
45
|
+
failure.errors.map do |error|
|
|
46
|
+
{
|
|
47
|
+
message: error.message,
|
|
48
|
+
key => pointer(error.data_pointer),
|
|
49
|
+
code: error.type
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def pointer_key
|
|
55
|
+
case type
|
|
56
|
+
when :invalid_body
|
|
57
|
+
:pointer
|
|
58
|
+
when :invalid_query, :invalid_path
|
|
59
|
+
:parameter
|
|
60
|
+
when :invalid_header
|
|
61
|
+
:header
|
|
62
|
+
when :invalid_cookie
|
|
63
|
+
:cookie
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def pointer(data_pointer)
|
|
68
|
+
return data_pointer if type == :invalid_body
|
|
69
|
+
|
|
70
|
+
data_pointer.delete_prefix('/')
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenapiFirst
|
|
4
|
+
module ErrorResponses
|
|
5
|
+
# A JSON:API conform error response. See https://jsonapi.org/.
|
|
6
|
+
class Jsonapi
|
|
7
|
+
include OpenapiFirst::ErrorResponse
|
|
8
|
+
|
|
9
|
+
OpenapiFirst.register_error_response(:jsonapi, self)
|
|
10
|
+
|
|
11
|
+
def body
|
|
12
|
+
JSON.generate({ errors: serialized_errors })
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def content_type
|
|
16
|
+
'application/vnd.api+json'
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def serialized_errors
|
|
20
|
+
return default_errors unless failure.errors
|
|
21
|
+
|
|
22
|
+
key = pointer_key
|
|
23
|
+
failure.errors.map do |error|
|
|
24
|
+
{
|
|
25
|
+
status: status.to_s,
|
|
26
|
+
source: { key => pointer(error.data_pointer) },
|
|
27
|
+
title: error.message,
|
|
28
|
+
code: error.type
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def default_errors
|
|
34
|
+
[{
|
|
35
|
+
status: status.to_s,
|
|
36
|
+
title: Rack::Utils::HTTP_STATUS_CODES[status]
|
|
37
|
+
}]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def pointer_key
|
|
41
|
+
case failure.type
|
|
42
|
+
when :invalid_body
|
|
43
|
+
:pointer
|
|
44
|
+
when :invalid_query, :invalid_path
|
|
45
|
+
:parameter
|
|
46
|
+
when :invalid_header
|
|
47
|
+
:header
|
|
48
|
+
when :invalid_cookie
|
|
49
|
+
:cookie
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def pointer(data_pointer)
|
|
54
|
+
return data_pointer if failure.type == :invalid_body
|
|
55
|
+
|
|
56
|
+
data_pointer.delete_prefix('/')
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenapiFirst
|
|
4
|
+
# Base class for all errors
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised if YAML/JSON file was not found
|
|
8
|
+
class FileNotFoundError < Error; end
|
|
9
|
+
|
|
10
|
+
# Raised if response body could not be parsed
|
|
11
|
+
class ParseError < Error; end
|
|
12
|
+
|
|
13
|
+
# Raised during request validation if request was invalid
|
|
14
|
+
class RequestInvalidError < Error
|
|
15
|
+
def initialize(message, validated_request)
|
|
16
|
+
super(message)
|
|
17
|
+
@request = validated_request
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @return [ValidatedRequest] The validated request
|
|
21
|
+
attr_reader :request
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Raised during request validation if request was not defined in the API description
|
|
25
|
+
class NotFoundError < RequestInvalidError; end
|
|
26
|
+
|
|
27
|
+
# Raised during response validation if request was invalid
|
|
28
|
+
class ResponseInvalidError < Error
|
|
29
|
+
def initialize(message, validated_response)
|
|
30
|
+
super(message)
|
|
31
|
+
@response = validated_response
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @return [ValidatedResponse] The validated response
|
|
35
|
+
attr_reader :response
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Raised during request validation if response was not defined in the API description
|
|
39
|
+
class ResponseNotFoundError < ResponseInvalidError; end
|
|
40
|
+
end
|