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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +70 -0
  3. data/README.md +95 -42
  4. data/lib/openapi_first/builder.rb +87 -52
  5. data/lib/openapi_first/child_configuration.rb +0 -2
  6. data/lib/openapi_first/configuration.rb +0 -23
  7. data/lib/openapi_first/definition.rb +35 -2
  8. data/lib/openapi_first/failure.rb +5 -1
  9. data/lib/openapi_first/middlewares/request_validation.rb +1 -1
  10. data/lib/openapi_first/middlewares/response_validation.rb +1 -1
  11. data/lib/openapi_first/parameter/converter/array_converter.rb +42 -0
  12. data/lib/openapi_first/parameter/converter/object_converter.rb +60 -0
  13. data/lib/openapi_first/parameter/converter.rb +69 -0
  14. data/lib/openapi_first/parameter/unpackers.rb +132 -0
  15. data/lib/openapi_first/parameter.rb +70 -0
  16. data/lib/openapi_first/parameter_content_parsers.rb +55 -0
  17. data/lib/openapi_first/parameters_parser.rb +23 -0
  18. data/lib/openapi_first/query_string_parser.rb +93 -0
  19. data/lib/openapi_first/ref_resolver.rb +56 -3
  20. data/lib/openapi_first/request.rb +12 -18
  21. data/lib/openapi_first/request_body_parsers.rb +19 -16
  22. data/lib/openapi_first/request_headers.rb +27 -0
  23. data/lib/openapi_first/request_validator.rb +4 -1
  24. data/lib/openapi_first/response_header.rb +9 -0
  25. data/lib/openapi_first/response_parser.rb +3 -10
  26. data/lib/openapi_first/router.rb +27 -12
  27. data/lib/openapi_first/schema/hash.rb +0 -1
  28. data/lib/openapi_first/sinatra.rb +217 -0
  29. data/lib/openapi_first/test/configuration.rb +0 -34
  30. data/lib/openapi_first/test/coverage/html_reporter/context.rb +24 -17
  31. data/lib/openapi_first/test/coverage/html_reporter.css +214 -67
  32. data/lib/openapi_first/test/coverage/html_reporter.html.erb +39 -11
  33. data/lib/openapi_first/test/coverage/html_reporter.rb +11 -1
  34. data/lib/openapi_first/test/coverage/plan.rb +30 -10
  35. data/lib/openapi_first/test/coverage/request_task.rb +7 -2
  36. data/lib/openapi_first/test/coverage/response_task.rb +6 -1
  37. data/lib/openapi_first/test/coverage/route_task.rb +23 -1
  38. data/lib/openapi_first/test/coverage/skipped_summary.rb +22 -0
  39. data/lib/openapi_first/test/coverage/terminal_reporter.rb +23 -11
  40. data/lib/openapi_first/test/coverage.rb +9 -3
  41. data/lib/openapi_first/test.rb +69 -11
  42. data/lib/openapi_first/validators/multipart_request_body.rb +57 -0
  43. data/lib/openapi_first/validators/request_body.rb +20 -7
  44. data/lib/openapi_first/validators/request_parameters.rb +5 -4
  45. data/lib/openapi_first/version.rb +1 -1
  46. metadata +15 -23
  47. 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
- require 'openapi_parameters'
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
- @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) }
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, :query_schema, :parameters
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: @headers_parser&.unpack_env(request.env),
73
- cookies: @cookies_parser&.unpack(request.env[Rack::HTTP_COOKIE]),
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 OpenapiParameters::InvalidParameterError
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
- if content_type.match?(MULTIPART_CONTENT_TYPE)
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(encoding: {})
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 raw unless content_type && raw.is_a?(String) && json?(content_type)
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[:tempfile]&.read if value.key?(:tempfile)
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('multipart/form-data', MultipartBodyParser)
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
- @validators << Validators::RequestBody.new(content_schema:, required_request_body:) if content_schema
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&.call(rack_response.headers) || {}
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
- headers_as_parameters = headers.map do |header|
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
@@ -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[:requests].each_value.lazy.uniq,
36
- responses: content[:responses].each_value.lazy.flat_map(&:values))
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[:requests]
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)[:responses][status] ||= {})[response_content_type] = response
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
- contents = path_item.dig(request_method, :requests)
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.dig(request_method, :responses)
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)