openapi_first 3.4.3 → 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 +66 -0
  3. data/README.md +95 -42
  4. data/lib/openapi_first/builder.rb +85 -50
  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 +11 -10
  21. data/lib/openapi_first/request_body_parsers.rb +11 -7
  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
@@ -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?
@@ -66,15 +67,15 @@ module OpenapiFirst
66
67
  [ParsedRequest.new(
67
68
  path: @path_parser&.unpack(route_params),
68
69
  query:,
69
- headers: @headers_parser&.unpack_env(request.env),
70
- 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])),
71
72
  body:
72
73
  ), nil]
73
74
  end
74
75
 
75
76
  def parse_query(query_string)
76
77
  [@query_parser&.unpack(query_string), nil]
77
- rescue OpenapiParameters::InvalidParameterError
78
+ rescue Rack::Utils::InvalidParameterError
78
79
  [nil, Failure.new(:invalid_query, message: 'Invalid query parameter.')]
79
80
  end
80
81
 
@@ -39,10 +39,6 @@ module OpenapiFirst
39
39
  Failure.new(:invalid_body, message: 'Failed to parse request body as JSON')
40
40
  end)
41
41
 
42
- # Parses multipart/form-data requests and currently puts the contents of a file upload at the parsed hash values.
43
- # NOTE: This behavior will probably change in the next major version.
44
- # The uploaded file should not be read during request validation.
45
- #
46
42
  # Honors the OpenAPI `encoding` map: when a top-level field has
47
43
  # `contentType: application/json` (or any */json), the field's raw value
48
44
  # is JSON-parsed before schema validation.
@@ -65,9 +61,11 @@ module OpenapiFirst
65
61
  private
66
62
 
67
63
  def decode_field(name, value)
68
- raw = unpack_value(value)
69
64
  content_type = @encoding.dig(name, 'contentType')
70
- 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?
71
69
 
72
70
  JSON.parse(raw)
73
71
  rescue JSON::ParserError => e
@@ -79,10 +77,16 @@ module OpenapiFirst
79
77
  content_type.match?(%r{[/+]json\b}i)
80
78
  end
81
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
+
82
86
  def unpack_value(value)
83
87
  return value.map { unpack_value(_1) } if value.is_a?(Array)
84
88
  return value unless value.is_a?(Hash)
85
- return value[:tempfile]&.read if value.key?(:tempfile)
89
+ return value if value.key?(:tempfile)
86
90
 
87
91
  value.transform_values { unpack_value(_1) }
88
92
  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)
@@ -38,30 +38,6 @@ module OpenapiFirst
38
38
  :ignore_unknown_requests, :ignore_unknown_response_status, :minimum_coverage, :logger
39
39
  attr_reader :report_coverage, :ignored_unknown_status
40
40
 
41
- # @deprecated Use {#coverage_reporter} instead.
42
- def coverage_formatter
43
- warn_coverage_formatter_deprecation
44
- coverage_reporter
45
- end
46
-
47
- # @deprecated Use {#coverage_reporter=} instead.
48
- def coverage_formatter=(value)
49
- warn_coverage_formatter_deprecation
50
- self.coverage_reporter = value
51
- end
52
-
53
- # @deprecated Use {#coverage_reporter_options} instead.
54
- def coverage_formatter_options
55
- warn_coverage_formatter_deprecation
56
- coverage_reporter_options
57
- end
58
-
59
- # @deprecated Use {#coverage_reporter_options=} instead.
60
- def coverage_formatter_options=(value)
61
- warn_coverage_formatter_deprecation
62
- self.coverage_reporter_options = value
63
- end
64
-
65
41
  # Set ignored unknown status codes.
66
42
  # @param [Array<Integer>] status Status codes that are okay not to cover in an OAD
67
43
  def ignored_unknown_status=(status)
@@ -130,16 +106,6 @@ module OpenapiFirst
130
106
 
131
107
  true
132
108
  end
133
-
134
- private
135
-
136
- def warn_coverage_formatter_deprecation
137
- return if @coverage_formatter_warned
138
-
139
- warn 'DEPRECATION WARNING: Test::Configuration#coverage_formatter(_options) is deprecated, ' \
140
- 'use #coverage_reporter(_options) instead.'
141
- @coverage_formatter_warned = true
142
- end
143
109
  end
144
110
  end
145
111
  end
@@ -12,12 +12,16 @@ module OpenapiFirst
12
12
  'API Coverage did not detect any API requests for the registered ' \
13
13
  'API descriptions. Make sure to observe your application using OpenapiFirst::Test.'
14
14
 
15
- attr_reader :coverage, :plans, :verbose
15
+ attr_reader :coverage, :plans, :verbose, :generated_at,
16
+ :skipped_requests_count, :skipped_responses_count
16
17
 
17
- def initialize(coverage_result, verbose)
18
+ def initialize(coverage_result, verbose, generated_at: Time.now)
18
19
  @coverage = coverage_result.coverage
19
20
  @plans = coverage_result.plans
21
+ @skipped_requests_count = coverage_result.skipped_requests_count
22
+ @skipped_responses_count = coverage_result.skipped_responses_count
20
23
  @verbose = verbose
24
+ @generated_at = generated_at.strftime('%Y-%m-%d %H:%M:%S %z')
21
25
  end
22
26
 
23
27
  # Helper for ERB rendering only — exposes this context's binding so the
@@ -26,14 +30,12 @@ module OpenapiFirst
26
30
  binding
27
31
  end
28
32
 
29
- def expand_plan?(plan)
30
- verbose || plan.done?
33
+ def any_skipped?
34
+ skipped_requests_count.positive? || skipped_responses_count.positive?
31
35
  end
32
36
 
33
37
  def visible_routes(plan)
34
- return plan.routes if expand_plan?(plan)
35
-
36
- plan.routes.reject(&:finished?)
38
+ plan.routes
37
39
  end
38
40
 
39
41
  def any_request_made?(route)
@@ -41,27 +43,32 @@ module OpenapiFirst
41
43
  end
42
44
 
43
45
  def route_status(route)
44
- return :request_problem if route.requests.none?(&:finished?)
45
- return :responses_problem if any_request_made?(route) && route.responses.any? { |r| !r.finished? }
46
+ return :skipped if route.skipped?
47
+ return :request_problem if route.tracked_requests.none?(&:finished?)
48
+ return :responses_problem if any_request_made?(route) && route.tracked_responses.any? { |r| !r.finished? }
46
49
 
47
50
  :ok
48
51
  end
49
52
 
53
+ def route_class(route)
54
+ return 'is-skipped' if route.skipped?
55
+
56
+ route.finished? ? 'is-covered' : 'is-uncovered'
57
+ end
58
+
50
59
  def uncovered_responses_count(route)
51
- route.responses.count { |r| !r.finished? }
60
+ route.tracked_responses.count { |r| !r.finished? }
52
61
  end
53
62
 
54
- def request_items(route, plan_verbose:)
63
+ def request_items(route)
64
+ return route.requests if route.skipped?
55
65
  return [] unless any_request_made?(route) && route.requests.any?(&:content_type)
56
66
 
57
- plan_verbose ? route.requests : route.requests.reject(&:finished?)
67
+ route.requests
58
68
  end
59
69
 
60
- def response_items(route, plan_verbose:)
61
- return [] unless plan_verbose || any_request_made?(route)
62
- return route.responses if plan_verbose || route.responses.any? { |r| !r.finished? }
63
-
64
- []
70
+ def response_items(route)
71
+ route.responses
65
72
  end
66
73
 
67
74
  def h(text)