openapi_first 3.4.2 → 4.0.0
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 +70 -0
- data/README.md +95 -42
- data/lib/openapi_first/builder.rb +87 -52
- data/lib/openapi_first/child_configuration.rb +0 -2
- data/lib/openapi_first/configuration.rb +0 -23
- data/lib/openapi_first/definition.rb +35 -2
- data/lib/openapi_first/failure.rb +5 -1
- data/lib/openapi_first/middlewares/request_validation.rb +1 -1
- data/lib/openapi_first/middlewares/response_validation.rb +1 -1
- data/lib/openapi_first/parameter/converter/array_converter.rb +42 -0
- data/lib/openapi_first/parameter/converter/object_converter.rb +60 -0
- data/lib/openapi_first/parameter/converter.rb +69 -0
- data/lib/openapi_first/parameter/unpackers.rb +132 -0
- data/lib/openapi_first/parameter.rb +70 -0
- data/lib/openapi_first/parameter_content_parsers.rb +55 -0
- data/lib/openapi_first/parameters_parser.rb +23 -0
- data/lib/openapi_first/query_string_parser.rb +93 -0
- data/lib/openapi_first/ref_resolver.rb +56 -3
- data/lib/openapi_first/request.rb +12 -18
- data/lib/openapi_first/request_body_parsers.rb +19 -16
- data/lib/openapi_first/request_headers.rb +27 -0
- data/lib/openapi_first/request_validator.rb +4 -1
- data/lib/openapi_first/response_header.rb +9 -0
- data/lib/openapi_first/response_parser.rb +3 -10
- data/lib/openapi_first/router.rb +27 -12
- data/lib/openapi_first/schema/hash.rb +0 -1
- data/lib/openapi_first/sinatra.rb +217 -0
- data/lib/openapi_first/test/configuration.rb +0 -34
- data/lib/openapi_first/test/coverage/html_reporter/context.rb +24 -17
- data/lib/openapi_first/test/coverage/html_reporter.css +214 -67
- data/lib/openapi_first/test/coverage/html_reporter.html.erb +39 -11
- data/lib/openapi_first/test/coverage/html_reporter.rb +11 -1
- data/lib/openapi_first/test/coverage/plan.rb +30 -10
- data/lib/openapi_first/test/coverage/request_task.rb +7 -2
- data/lib/openapi_first/test/coverage/response_task.rb +6 -1
- data/lib/openapi_first/test/coverage/route_task.rb +23 -1
- data/lib/openapi_first/test/coverage/skipped_summary.rb +22 -0
- data/lib/openapi_first/test/coverage/terminal_reporter.rb +23 -11
- data/lib/openapi_first/test/coverage.rb +9 -3
- data/lib/openapi_first/test.rb +69 -11
- data/lib/openapi_first/validators/multipart_request_body.rb +57 -0
- data/lib/openapi_first/validators/request_body.rb +20 -7
- data/lib/openapi_first/validators/request_parameters.rb +5 -4
- data/lib/openapi_first/version.rb +1 -1
- metadata +15 -23
- data/lib/openapi_first/header.rb +0 -9
|
@@ -84,7 +84,7 @@ module OpenapiFirst
|
|
|
84
84
|
def resolve_ref(pointer)
|
|
85
85
|
if pointer.start_with?('#')
|
|
86
86
|
value = Hana::Pointer.new(pointer[1..]).eval(context)
|
|
87
|
-
raise "Unknown reference #{pointer} in #{context}" unless value
|
|
87
|
+
raise "Unknown reference #{pointer.inspect} in #{filepath || context}" unless value
|
|
88
88
|
|
|
89
89
|
return ref_resolver.for(value, filepath:, context:)
|
|
90
90
|
end
|
|
@@ -93,12 +93,19 @@ module OpenapiFirst
|
|
|
93
93
|
full_path = File.expand_path(relative_path, dir)
|
|
94
94
|
return ref_resolver.load(full_path) unless file_pointer
|
|
95
95
|
|
|
96
|
-
ref_resolver.file_at(full_path, file_pointer)
|
|
96
|
+
resolved = ref_resolver.file_at(full_path, file_pointer)
|
|
97
|
+
raise "Unknown reference #{pointer.inspect} in #{filepath || context}" unless resolved
|
|
98
|
+
|
|
99
|
+
resolved
|
|
97
100
|
rescue OpenapiFirst::FileNotFoundError => e
|
|
98
101
|
message = "Problem with reference resolving #{pointer.inspect} in " \
|
|
99
102
|
"file #{File.absolute_path(filepath).inspect}: #{e.message}"
|
|
100
103
|
raise OpenapiFirst::FileNotFoundError, message
|
|
101
104
|
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def mark(visited) = (visited || []) + [value.object_id]
|
|
102
109
|
end
|
|
103
110
|
|
|
104
111
|
# @visibility private
|
|
@@ -106,6 +113,8 @@ module OpenapiFirst
|
|
|
106
113
|
include Resolvable
|
|
107
114
|
|
|
108
115
|
def resolved = value
|
|
116
|
+
|
|
117
|
+
def dereferenced(_visited = nil) = value
|
|
109
118
|
end
|
|
110
119
|
|
|
111
120
|
# @visibility private
|
|
@@ -124,6 +133,19 @@ module OpenapiFirst
|
|
|
124
133
|
value
|
|
125
134
|
end
|
|
126
135
|
|
|
136
|
+
# Returns a plain Hash with all nested $refs resolved.
|
|
137
|
+
# A node that is reached again on its own path, as in a recursive schema,
|
|
138
|
+
# is returned unresolved to stop the recursion.
|
|
139
|
+
# @param visited [Array<Integer>, nil] Object ids of the nodes on the current path.
|
|
140
|
+
def dereferenced(visited = nil)
|
|
141
|
+
return value if visited&.include?(value.object_id)
|
|
142
|
+
|
|
143
|
+
visited = mark(visited)
|
|
144
|
+
return resolve_ref(value['$ref'])&.dereferenced(visited) if value.key?('$ref')
|
|
145
|
+
|
|
146
|
+
value.each_key.to_h { |key| [key, self[key]&.dereferenced(visited)] }
|
|
147
|
+
end
|
|
148
|
+
|
|
127
149
|
def [](key)
|
|
128
150
|
return resolve_ref(@value['$ref'])[key] if !@value.key?(key) && @value.key?('$ref')
|
|
129
151
|
|
|
@@ -154,6 +176,14 @@ module OpenapiFirst
|
|
|
154
176
|
class Schema
|
|
155
177
|
extend Forwardable
|
|
156
178
|
|
|
179
|
+
# The root context is a document, not a schema. Parsing it with only the core vocabulary
|
|
180
|
+
# keeps document keys that collide with dialect keywords (like "id" in OpenAPI 3.0)
|
|
181
|
+
# from being parsed as such and keeps them navigable for $ref pointers.
|
|
182
|
+
DOCUMENT_META_SCHEMA = JSONSchemer::Schema.new(
|
|
183
|
+
{},
|
|
184
|
+
vocabulary: { 'https://json-schema.org/draft/2020-12/vocab/core' => true }
|
|
185
|
+
)
|
|
186
|
+
|
|
157
187
|
def initialize(value:, context:, base_uri:, options:)
|
|
158
188
|
@value = value
|
|
159
189
|
@context = context
|
|
@@ -167,10 +197,24 @@ module OpenapiFirst
|
|
|
167
197
|
|
|
168
198
|
def schema
|
|
169
199
|
@schema ||= begin
|
|
170
|
-
root_schema = JSONSchemer::Schema.new(context, base_uri:, **options)
|
|
200
|
+
root_schema = JSONSchemer::Schema.new(context, base_uri:, **options, meta_schema: DOCUMENT_META_SCHEMA)
|
|
201
|
+
apply_dialect(root_schema)
|
|
171
202
|
JSONSchemer::Schema.new(value, nil, root_schema, base_uri:, **options)
|
|
172
203
|
end
|
|
173
204
|
end
|
|
205
|
+
|
|
206
|
+
private
|
|
207
|
+
|
|
208
|
+
# Set the dialect meta schema on the root like JSONSchemer::Schema#parse would,
|
|
209
|
+
# so that schemas resolved via $ref pointers into the document inherit it.
|
|
210
|
+
def apply_dialect(root_schema)
|
|
211
|
+
dialect = options[:meta_schema] || options.fetch(:configuration, JSONSchemer.configuration).meta_schema
|
|
212
|
+
if dialect.is_a?(String)
|
|
213
|
+
JSONSchemer::Schema::SCHEMA_KEYWORD_CLASS.new(dialect, root_schema, '$schema')
|
|
214
|
+
else
|
|
215
|
+
root_schema.meta_schema = dialect
|
|
216
|
+
end
|
|
217
|
+
end
|
|
174
218
|
end
|
|
175
219
|
|
|
176
220
|
# @visibility private
|
|
@@ -201,6 +245,15 @@ module OpenapiFirst
|
|
|
201
245
|
end
|
|
202
246
|
end
|
|
203
247
|
end
|
|
248
|
+
|
|
249
|
+
# Returns a plain Array with all nested $refs resolved.
|
|
250
|
+
# @param visited [Array<Integer>, nil] Object ids of the nodes on the current path.
|
|
251
|
+
def dereferenced(visited = nil)
|
|
252
|
+
return value if visited&.include?(value.object_id)
|
|
253
|
+
|
|
254
|
+
visited = mark(visited)
|
|
255
|
+
value.each_index.map { self[_1]&.dereferenced(visited) }
|
|
256
|
+
end
|
|
204
257
|
end
|
|
205
258
|
end
|
|
206
259
|
end
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
require_relative 'request_headers'
|
|
4
4
|
require_relative 'parsed_request'
|
|
5
5
|
require_relative 'request_validator'
|
|
6
6
|
require_relative 'validated_request'
|
|
@@ -21,23 +21,24 @@ module OpenapiFirst
|
|
|
21
21
|
@operation = operation_object
|
|
22
22
|
@allow_empty_content = content_type.nil? || required_body == false
|
|
23
23
|
@key = key
|
|
24
|
-
@
|
|
25
|
-
@
|
|
26
|
-
@
|
|
27
|
-
@
|
|
24
|
+
@parameters = parameters.all
|
|
25
|
+
@query_parser = parameters.query_parser
|
|
26
|
+
@path_parser = parameters.path_parser
|
|
27
|
+
@header_parser = parameters.header_parser
|
|
28
|
+
@cookie_parser = parameters.cookie_parser
|
|
28
29
|
@body_parsers = build_body_parser(content_type, encoding) if content_type
|
|
29
30
|
@validator = RequestValidator.new(
|
|
30
31
|
content_schema:,
|
|
32
|
+
content_type:,
|
|
31
33
|
required_request_body: required_body == true,
|
|
32
34
|
path_schema: parameters.path_schema,
|
|
33
35
|
query_schema: parameters.query_schema,
|
|
34
36
|
header_schema: parameters.header_schema,
|
|
35
37
|
cookie_schema: parameters.cookie_schema
|
|
36
38
|
)
|
|
37
|
-
@parameters = parameters
|
|
38
39
|
end
|
|
39
40
|
|
|
40
|
-
attr_reader :content_type, :content_schema, :operation, :request_method, :path, :key, :
|
|
41
|
+
attr_reader :content_type, :content_schema, :operation, :request_method, :path, :key, :parameters
|
|
41
42
|
private attr_reader :query_parser
|
|
42
43
|
|
|
43
44
|
def allow_empty_content?
|
|
@@ -54,9 +55,6 @@ module OpenapiFirst
|
|
|
54
55
|
@operation['operationId']
|
|
55
56
|
end
|
|
56
57
|
|
|
57
|
-
MULTIPART_CONTENT_TYPE = %r{\Amultipart/form-data\b}i
|
|
58
|
-
private_constant :MULTIPART_CONTENT_TYPE
|
|
59
|
-
|
|
60
58
|
private
|
|
61
59
|
|
|
62
60
|
def parse_request(request, route_params:)
|
|
@@ -69,24 +67,20 @@ module OpenapiFirst
|
|
|
69
67
|
[ParsedRequest.new(
|
|
70
68
|
path: @path_parser&.unpack(route_params),
|
|
71
69
|
query:,
|
|
72
|
-
headers: @
|
|
73
|
-
cookies: @
|
|
70
|
+
headers: @header_parser&.unpack(RequestHeaders.new(request.env)),
|
|
71
|
+
cookies: @cookie_parser&.unpack(Rack::Utils.parse_cookies_header(request.env[Rack::HTTP_COOKIE])),
|
|
74
72
|
body:
|
|
75
73
|
), nil]
|
|
76
74
|
end
|
|
77
75
|
|
|
78
76
|
def parse_query(query_string)
|
|
79
77
|
[@query_parser&.unpack(query_string), nil]
|
|
80
|
-
rescue
|
|
78
|
+
rescue Rack::Utils::InvalidParameterError
|
|
81
79
|
[nil, Failure.new(:invalid_query, message: 'Invalid query parameter.')]
|
|
82
80
|
end
|
|
83
81
|
|
|
84
82
|
def build_body_parser(content_type, encoding)
|
|
85
|
-
|
|
86
|
-
RequestBodyParsers::MultipartBodyParser.new(encoding: encoding || {})
|
|
87
|
-
else
|
|
88
|
-
RequestBodyParsers[content_type]
|
|
89
|
-
end
|
|
83
|
+
RequestBodyParsers[content_type, { encoding: encoding || {} }]
|
|
90
84
|
end
|
|
91
85
|
end
|
|
92
86
|
end
|
|
@@ -14,9 +14,12 @@ module OpenapiFirst
|
|
|
14
14
|
parsers[pattern] = parser
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
-
def [](content_type)
|
|
17
|
+
def [](content_type, options = {})
|
|
18
18
|
key = parsers.keys.find { content_type.match?(_1) }
|
|
19
|
-
parsers.fetch(key) { DEFAULT }
|
|
19
|
+
parser = parsers.fetch(key) { DEFAULT }
|
|
20
|
+
return parser.new(options) if parser.is_a?(Class)
|
|
21
|
+
|
|
22
|
+
parser
|
|
20
23
|
end
|
|
21
24
|
end
|
|
22
25
|
|
|
@@ -36,20 +39,12 @@ module OpenapiFirst
|
|
|
36
39
|
Failure.new(:invalid_body, message: 'Failed to parse request body as JSON')
|
|
37
40
|
end)
|
|
38
41
|
|
|
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
|
-
#
|
|
43
42
|
# Honors the OpenAPI `encoding` map: when a top-level field has
|
|
44
43
|
# `contentType: application/json` (or any */json), the field's raw value
|
|
45
44
|
# is JSON-parsed before schema validation.
|
|
46
45
|
class MultipartBodyParser
|
|
47
|
-
def initialize(
|
|
48
|
-
@encoding = encoding || {}
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
def self.call(request)
|
|
52
|
-
new.call(request)
|
|
46
|
+
def initialize(options)
|
|
47
|
+
@encoding = options[:encoding] || {}
|
|
53
48
|
end
|
|
54
49
|
|
|
55
50
|
def call(request)
|
|
@@ -66,9 +61,11 @@ module OpenapiFirst
|
|
|
66
61
|
private
|
|
67
62
|
|
|
68
63
|
def decode_field(name, value)
|
|
69
|
-
raw = unpack_value(value)
|
|
70
64
|
content_type = @encoding.dig(name, 'contentType')
|
|
71
|
-
return
|
|
65
|
+
return unpack_value(value) unless content_type && json?(content_type)
|
|
66
|
+
|
|
67
|
+
raw = read_raw(value)
|
|
68
|
+
return unpack_value(value) if raw.nil?
|
|
72
69
|
|
|
73
70
|
JSON.parse(raw)
|
|
74
71
|
rescue JSON::ParserError => e
|
|
@@ -80,16 +77,22 @@ module OpenapiFirst
|
|
|
80
77
|
content_type.match?(%r{[/+]json\b}i)
|
|
81
78
|
end
|
|
82
79
|
|
|
80
|
+
def read_raw(value)
|
|
81
|
+
return value if value.is_a?(String)
|
|
82
|
+
|
|
83
|
+
value[:tempfile]&.read if value.is_a?(Hash) && value.key?(:tempfile)
|
|
84
|
+
end
|
|
85
|
+
|
|
83
86
|
def unpack_value(value)
|
|
84
87
|
return value.map { unpack_value(_1) } if value.is_a?(Array)
|
|
85
88
|
return value unless value.is_a?(Hash)
|
|
86
|
-
return value
|
|
89
|
+
return value if value.key?(:tempfile)
|
|
87
90
|
|
|
88
91
|
value.transform_values { unpack_value(_1) }
|
|
89
92
|
end
|
|
90
93
|
end
|
|
91
94
|
|
|
92
|
-
register(
|
|
95
|
+
register(%r{\Amultipart/form-data\b}i, MultipartBodyParser)
|
|
93
96
|
|
|
94
97
|
register('application/x-www-form-urlencoded', lambda(&:POST))
|
|
95
98
|
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenapiFirst
|
|
4
|
+
# A wrapper around the Rack env hash that allows accessing headers by header name
|
|
5
|
+
# @visibility private
|
|
6
|
+
class RequestHeaders
|
|
7
|
+
# This was copied from this Rack::Request PR: https://github.com/rack/rack/pull/1881
|
|
8
|
+
def initialize(env)
|
|
9
|
+
@env = env
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def [](key)
|
|
13
|
+
@env[header_to_env_key(key)]
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def key?(key)
|
|
17
|
+
@env.key?(header_to_env_key(key))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def header_to_env_key(key)
|
|
21
|
+
key = key.upcase
|
|
22
|
+
key.tr!('-', '_')
|
|
23
|
+
key = "HTTP_#{key}" unless %w[CONTENT_LENGTH CONTENT_TYPE].include?(key)
|
|
24
|
+
key
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -9,6 +9,7 @@ module OpenapiFirst
|
|
|
9
9
|
class RequestValidator
|
|
10
10
|
def initialize(
|
|
11
11
|
content_schema:,
|
|
12
|
+
content_type:,
|
|
12
13
|
required_request_body:,
|
|
13
14
|
path_schema:,
|
|
14
15
|
query_schema:,
|
|
@@ -16,7 +17,9 @@ module OpenapiFirst
|
|
|
16
17
|
cookie_schema:
|
|
17
18
|
)
|
|
18
19
|
@validators = []
|
|
19
|
-
|
|
20
|
+
if content_schema
|
|
21
|
+
@validators.concat Validators::RequestBody.for(content_schema:, required_request_body:, content_type:)
|
|
22
|
+
end
|
|
20
23
|
@validators.concat Validators::RequestParameters.for(
|
|
21
24
|
path_schema:,
|
|
22
25
|
query_schema:,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'parameter'
|
|
4
|
+
|
|
5
|
+
module OpenapiFirst
|
|
6
|
+
# A header of a response definition.
|
|
7
|
+
# @attr_reader [Parameter] parameter The header as a Parameter, which knows how to unpack a raw value.
|
|
8
|
+
ResponseHeader = Data.define(:name, :required?, :schema, :parameter)
|
|
9
|
+
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'parameters_parser'
|
|
3
4
|
require_relative 'response_body_parsers'
|
|
4
5
|
|
|
5
6
|
module OpenapiFirst
|
|
@@ -18,7 +19,7 @@ module OpenapiFirst
|
|
|
18
19
|
|
|
19
20
|
[ParsedResponse.new(
|
|
20
21
|
body:,
|
|
21
|
-
headers: @headers_parser&.
|
|
22
|
+
headers: @headers_parser&.unpack(rack_response.headers) || {}
|
|
22
23
|
), nil]
|
|
23
24
|
end
|
|
24
25
|
|
|
@@ -39,15 +40,7 @@ module OpenapiFirst
|
|
|
39
40
|
def build_headers_parser(headers)
|
|
40
41
|
return unless headers&.any?
|
|
41
42
|
|
|
42
|
-
|
|
43
|
-
{
|
|
44
|
-
'name' => header.name,
|
|
45
|
-
'explode' => false,
|
|
46
|
-
'in' => 'header',
|
|
47
|
-
'schema' => header.resolved_schema
|
|
48
|
-
}
|
|
49
|
-
end
|
|
50
|
-
OpenapiParameters::Header.new(headers_as_parameters).method(:unpack)
|
|
43
|
+
ParametersParser.new(headers.map(&:parameter))
|
|
51
44
|
end
|
|
52
45
|
end
|
|
53
46
|
end
|
data/lib/openapi_first/router.rb
CHANGED
|
@@ -18,6 +18,10 @@ module OpenapiFirst
|
|
|
18
18
|
# Returned by {#routes} to introspect all routes
|
|
19
19
|
Route = Data.define(:path, :request_method, :requests, :responses)
|
|
20
20
|
|
|
21
|
+
# Holds the requests/responses defined for one path + request method
|
|
22
|
+
RouteEntry = Data.define(:requests, :responses)
|
|
23
|
+
private_constant :RouteEntry
|
|
24
|
+
|
|
21
25
|
NOT_FOUND = RequestMatch.new(request_definition: nil, params: nil, responses: nil, error: Failure.new(:not_found))
|
|
22
26
|
private_constant :NOT_FOUND
|
|
23
27
|
|
|
@@ -32,8 +36,8 @@ module OpenapiFirst
|
|
|
32
36
|
request_methods.filter_map do |request_method, content|
|
|
33
37
|
next if request_method == :template
|
|
34
38
|
|
|
35
|
-
Route.new(path:, request_method:, requests: content
|
|
36
|
-
responses: content
|
|
39
|
+
Route.new(path:, request_method:, requests: content.requests.each_value.lazy.uniq,
|
|
40
|
+
responses: content.responses.each_value.lazy.flat_map(&:values))
|
|
37
41
|
end
|
|
38
42
|
end
|
|
39
43
|
end
|
|
@@ -41,14 +45,14 @@ module OpenapiFirst
|
|
|
41
45
|
# Add a request definition
|
|
42
46
|
def add_request(request, request_method:, path:, content_type: nil, allow_empty_content: false)
|
|
43
47
|
route = route_at(path, request_method)
|
|
44
|
-
requests = route
|
|
48
|
+
requests = route.requests
|
|
45
49
|
requests[content_type] = request
|
|
46
50
|
requests[nil] = request if allow_empty_content
|
|
47
51
|
end
|
|
48
52
|
|
|
49
53
|
# Add a response definition
|
|
50
54
|
def add_response(response, request_method:, path:, status:, response_content_type: nil)
|
|
51
|
-
(route_at(path, request_method)
|
|
55
|
+
(route_at(path, request_method).responses[status] ||= {})[response_content_type] = response
|
|
52
56
|
end
|
|
53
57
|
|
|
54
58
|
# Return all request objects that match the given path and request method
|
|
@@ -59,7 +63,23 @@ module OpenapiFirst
|
|
|
59
63
|
return NOT_FOUND.with(error: Failure.new(:not_found, message:))
|
|
60
64
|
end
|
|
61
65
|
|
|
62
|
-
|
|
66
|
+
match_path_item(path_item, params, request_method, content_type:)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def match_route(request_method, template, params:, content_type: nil)
|
|
70
|
+
path_item = @static[template] || @dynamic[template]
|
|
71
|
+
unless path_item
|
|
72
|
+
message = "Request path #{template} is not defined in API description."
|
|
73
|
+
return NOT_FOUND.with(error: Failure.new(:not_found, message:))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
match_path_item(path_item, params, request_method, content_type:)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def match_path_item(path_item, params, request_method, content_type:)
|
|
82
|
+
contents = path_item[request_method]&.requests
|
|
63
83
|
return NOT_FOUND.with(error: Failure.new(:method_not_allowed)) unless contents
|
|
64
84
|
|
|
65
85
|
request_definition = FindContent.call(contents, content_type)
|
|
@@ -68,12 +88,10 @@ module OpenapiFirst
|
|
|
68
88
|
return NOT_FOUND.with(error: Failure.new(:unsupported_media_type, message:))
|
|
69
89
|
end
|
|
70
90
|
|
|
71
|
-
responses = path_item
|
|
91
|
+
responses = path_item[request_method]&.responses
|
|
72
92
|
RequestMatch.new(request_definition:, params:, error: nil, responses:)
|
|
73
93
|
end
|
|
74
94
|
|
|
75
|
-
private
|
|
76
|
-
|
|
77
95
|
def route_at(path, request_method)
|
|
78
96
|
request_method = request_method.upcase
|
|
79
97
|
path_item = if PathTemplate.template?(path)
|
|
@@ -81,10 +99,7 @@ module OpenapiFirst
|
|
|
81
99
|
else
|
|
82
100
|
@static[path] ||= {}
|
|
83
101
|
end
|
|
84
|
-
path_item[request_method] ||= {
|
|
85
|
-
requests: {},
|
|
86
|
-
responses: {}
|
|
87
|
-
}
|
|
102
|
+
path_item[request_method] ||= RouteEntry.new(requests: {}, responses: {})
|
|
88
103
|
end
|
|
89
104
|
|
|
90
105
|
def content_type_err(content_type)
|
|
@@ -10,7 +10,6 @@ module OpenapiFirst
|
|
|
10
10
|
# @param required Array of required keys
|
|
11
11
|
def initialize(schemas, required: nil, **options)
|
|
12
12
|
@schemas = schemas
|
|
13
|
-
@options = options
|
|
14
13
|
@after_property_validation = options.delete(:after_property_validation)
|
|
15
14
|
schema = { 'type' => 'object' }
|
|
16
15
|
schema['required'] = required if required
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# :nocov:
|
|
4
|
+
begin
|
|
5
|
+
require 'sinatra/base'
|
|
6
|
+
rescue LoadError
|
|
7
|
+
raise LoadError, 'openapi_first/sinatra needs the `sinatra` gem. Add `gem "sinatra"` to your Gemfile.'
|
|
8
|
+
end
|
|
9
|
+
# :nocov:
|
|
10
|
+
|
|
11
|
+
require 'did_you_mean'
|
|
12
|
+
require 'openapi_first'
|
|
13
|
+
|
|
14
|
+
module OpenapiFirst
|
|
15
|
+
# Sinatra extension to define routes by referencing operations in an OpenAPI description via operationId.
|
|
16
|
+
#
|
|
17
|
+
# require 'openapi_first/sinatra'
|
|
18
|
+
#
|
|
19
|
+
# In a classic (top-level) app the extension is registered automatically, so the +openapi+
|
|
20
|
+
# and +operation+ keywords are available right away:
|
|
21
|
+
#
|
|
22
|
+
# require 'sinatra'
|
|
23
|
+
# require 'openapi_first/sinatra'
|
|
24
|
+
#
|
|
25
|
+
# openapi 'openapi.yaml'
|
|
26
|
+
# operation :create_customer do
|
|
27
|
+
# json create_customer(parsed_params)
|
|
28
|
+
# end
|
|
29
|
+
#
|
|
30
|
+
# In a modular app register it explicitly, like any other Sinatra extension:
|
|
31
|
+
#
|
|
32
|
+
# require 'sinatra/base'
|
|
33
|
+
# require 'openapi_first/sinatra'
|
|
34
|
+
#
|
|
35
|
+
# class PetsApi < Sinatra::Base
|
|
36
|
+
# register OpenapiFirst::Sinatra
|
|
37
|
+
# openapi 'openapi.yaml'
|
|
38
|
+
#
|
|
39
|
+
# operation :index_pets do |params|
|
|
40
|
+
# json index_pets(params[:filter])
|
|
41
|
+
# end
|
|
42
|
+
#
|
|
43
|
+
# operation :create_pet do
|
|
44
|
+
# json create_pet(parsed_body[:data])
|
|
45
|
+
# end
|
|
46
|
+
# end
|
|
47
|
+
#
|
|
48
|
+
# Each +operation+ route validates its request against the OpenAPI description before the block
|
|
49
|
+
# runs, so contract violations return 400/415 and the block is not reached. Validation reuses
|
|
50
|
+
# Sinatra's own routing (the operation's path template is known when the route is defined), so
|
|
51
|
+
# openapi_first does not run its own router - there is no request-validation middleware.
|
|
52
|
+
#
|
|
53
|
+
# Because routing is left to Sinatra, requests to paths without an +operation+ block fall through
|
|
54
|
+
# to Sinatra's normal handling (a 404 by default), and you can add plain Sinatra routes
|
|
55
|
+
# (health checks, assets, ...) alongside +operation+ blocks.
|
|
56
|
+
# This relaxes the strict approach of openapi_first's request validation middleware
|
|
57
|
+
# where all unknown routes that are not described in the OAD return 404. Take care to avoid API drift
|
|
58
|
+
#
|
|
59
|
+
# NOTE: Requests are matched by Sinatra's router (Mustermann), but validated against the OpenAPI path
|
|
60
|
+
# template the route was defined from, using the path parameters Sinatra extracted. The two
|
|
61
|
+
# matchers can diverge at the edges (trailing slashes, dots inside a path segment,
|
|
62
|
+
# encoded characters, ...) - a request Sinatra matches is not re-checked against openapi_first's own
|
|
63
|
+
# path matching. Avoid path shapes where the two routers disagree.
|
|
64
|
+
module Sinatra
|
|
65
|
+
PATH_PARAMETER = /\{[^}]+\}/
|
|
66
|
+
private_constant :PATH_PARAMETER
|
|
67
|
+
|
|
68
|
+
# The configuration lives in Sinatra settings (rather than plain instance variables) so a
|
|
69
|
+
# subclass of a configured app inherits the loaded description and its operation index.
|
|
70
|
+
def self.registered(app)
|
|
71
|
+
app.helpers(Helpers)
|
|
72
|
+
# Declared up front so the reader methods exist (returning nil) before #openapi runs,
|
|
73
|
+
# which keeps the "call `openapi` first" guard in #operation working.
|
|
74
|
+
app.set :openapi_definition, nil
|
|
75
|
+
app.set :openapi_operations_index, nil
|
|
76
|
+
app.set :openapi_error_response, nil
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Loads an OpenAPI description for this app. Call this once per app; the loaded description is
|
|
80
|
+
# then available via {#openapi_definition}. Each {#operation} route validates its request
|
|
81
|
+
# against the description before the block runs.
|
|
82
|
+
# @param spec [String, Symbol, OpenapiFirst::Definition] A file path, a key registered via
|
|
83
|
+
# OpenapiFirst.register, or a Definition instance.
|
|
84
|
+
# @return [OpenapiFirst::Definition]
|
|
85
|
+
# @raise [OpenapiFirst::Error] if {#openapi} has already been called for this app.
|
|
86
|
+
def openapi(spec)
|
|
87
|
+
raise OpenapiFirst::Error, '`openapi` can only be called once per app.' if openapi_definition
|
|
88
|
+
|
|
89
|
+
definition = OpenapiFirst.load(spec)
|
|
90
|
+
set :openapi_definition, definition
|
|
91
|
+
set :openapi_operations_index, build_operation_index(definition)
|
|
92
|
+
set :openapi_error_response, OpenapiFirst.configuration.request_validation_error_response
|
|
93
|
+
definition
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Defines a route for the operation with the given +operationId+. The HTTP method and path
|
|
97
|
+
# are taken from the OpenAPI description; the block is the Sinatra route handler.
|
|
98
|
+
#
|
|
99
|
+
# If the block declares an argument, it receives {Helpers#parsed_params}:
|
|
100
|
+
#
|
|
101
|
+
# operation(:show_pet) { |params| json find_pet(params[:id]) }
|
|
102
|
+
#
|
|
103
|
+
# A block without arguments runs as a normal Sinatra route (use {Helpers#parsed_params} inside).
|
|
104
|
+
# A block that takes a splat or optional argument (arity < 0) is also passed {Helpers#parsed_params}.
|
|
105
|
+
#
|
|
106
|
+
# Symbols are the idiomatic form (+operation :create_customer+). Use a String for operationIds
|
|
107
|
+
# that are not valid Ruby symbols, e.g. +operation 'pets.list'+.
|
|
108
|
+
# @param operation_id [String, Symbol] An operationId present in the API description.
|
|
109
|
+
# @raise [OpenapiFirst::Error] if {#openapi} has not been called yet.
|
|
110
|
+
# @raise [ArgumentError] if the operationId is not defined in the API description.
|
|
111
|
+
def operation(operation_id, &block)
|
|
112
|
+
unless openapi_operations_index
|
|
113
|
+
raise OpenapiFirst::Error, 'Call `openapi` with your API description before defining operations.'
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
request_method, path = openapi_operations_index.fetch(operation_id.to_s) do
|
|
117
|
+
raise ArgumentError, unknown_operation_message(operation_id.to_s)
|
|
118
|
+
end
|
|
119
|
+
public_send(request_method.downcase, sinatra_pattern(path), &operation_handler(path, block))
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
def unknown_operation_message(operation_id)
|
|
125
|
+
defined_ids = openapi_operations_index.keys
|
|
126
|
+
message = "Operation #{operation_id.inspect} is not defined in #{openapi_definition.key}."
|
|
127
|
+
suggestions = ::DidYouMean::SpellChecker.new(dictionary: defined_ids).correct(operation_id)
|
|
128
|
+
message << if suggestions.any?
|
|
129
|
+
" Did you mean #{suggestions.map(&:inspect).join(' or ')}?"
|
|
130
|
+
else
|
|
131
|
+
" Defined operationIds are: #{defined_ids.join(', ')}."
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def operation_handler(path_template, block)
|
|
136
|
+
param_names = path_template.scan(PATH_PARAMETER).map! { |placeholder| placeholder[1..-2] }
|
|
137
|
+
proc do |*captures|
|
|
138
|
+
path_params = param_names.zip(captures).to_h
|
|
139
|
+
validated = settings.openapi_definition.validate_request(request, path_template:, path_params:)
|
|
140
|
+
env[OpenapiFirst::REQUEST] = validated
|
|
141
|
+
if (failure = validated.error) && (error_response = settings.openapi_error_response)
|
|
142
|
+
halt(*error_response.new(failure:).render)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
block.arity.zero? ? instance_exec(&block) : instance_exec(parsed_params, &block)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def sinatra_pattern(path)
|
|
150
|
+
path.gsub(PATH_PARAMETER) { |placeholder| ":#{placeholder[1..-2].gsub(/[^A-Za-z0-9_]/, '_')}" }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def build_operation_index(definition)
|
|
154
|
+
definition.routes.each_with_object({}) do |route, index|
|
|
155
|
+
route.requests.each do |request|
|
|
156
|
+
operation_id = request.operation_id
|
|
157
|
+
next unless operation_id
|
|
158
|
+
|
|
159
|
+
entry = [route.request_method, route.path]
|
|
160
|
+
existing = index[operation_id]
|
|
161
|
+
if existing && existing != entry
|
|
162
|
+
raise OpenapiFirst::Error,
|
|
163
|
+
"operationId #{operation_id.inspect} is used for #{existing.join(' ')} and " \
|
|
164
|
+
"#{entry.join(' ')} in #{definition.key}. operationIds must be unique."
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
index[operation_id] = entry
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Helpers available inside route blocks.
|
|
173
|
+
module Helpers
|
|
174
|
+
# The merged path and query parameters parsed and coerced per the OpenAPI description. See also
|
|
175
|
+
# OpenapiFirst::ValidatedRequest#parsed_query, OpenapiFirst::ValidatedRequest#parsed_path_parameters).
|
|
176
|
+
#
|
|
177
|
+
# Sinatra's own +params+ is left untouched and still returns the raw, unparsed values. For
|
|
178
|
+
# parts that can have colliding names, read them explicitly via {#openapi_request}
|
|
179
|
+
# (e.g. +openapi_request.parsed_headers+).
|
|
180
|
+
#
|
|
181
|
+
# @return [Sinatra::IndifferentHash]
|
|
182
|
+
def parsed_params
|
|
183
|
+
::Sinatra::IndifferentHash[openapi_request.parsed_query.merge(openapi_request.parsed_path_parameters)]
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# The parsed request body
|
|
187
|
+
# @return [Sinatra::IndifferentHash, Object, nil]
|
|
188
|
+
def parsed_body
|
|
189
|
+
body = openapi_request.parsed_body
|
|
190
|
+
body.is_a?(Hash) ? ::Sinatra::IndifferentHash[body] : body
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Generates a URL for the operation with the given +operationId+, filling in any path
|
|
194
|
+
# parameters from +path_params+. Delegates to Sinatra's own +url+ helper so reverse-proxy
|
|
195
|
+
# and script-name handling is preserved.
|
|
196
|
+
#
|
|
197
|
+
# href = operation_url(:show_pet, petId: pet.id) # => "http://example.com/pets/42"
|
|
198
|
+
#
|
|
199
|
+
# @param operation_id [String, Symbol] An operationId present in the API description.
|
|
200
|
+
# @param path_params [Hash] Path-parameter values keyed by name (String or Symbol).
|
|
201
|
+
# @return [String] Absolute URL for the operation.
|
|
202
|
+
# @raise [ArgumentError] if the operationId is unknown or a required path parameter is missing.
|
|
203
|
+
def operation_url(operation_id, path_params = {})
|
|
204
|
+
url(settings.openapi_definition.path_for(path_params, operation_id:))
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# @return [OpenapiFirst::ValidatedRequest] The validated request for the current request.
|
|
208
|
+
def openapi_request
|
|
209
|
+
env[OpenapiFirst::REQUEST]
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Make the +openapi+/+operation+ keywords available to classic (top-level) apps, so that
|
|
216
|
+
# requiring this single file is enough. Modular apps still `register OpenapiFirst::Sinatra`.
|
|
217
|
+
Sinatra.register(OpenapiFirst::Sinatra)
|