openapi_first 3.1.1 → 3.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 253737faf3fc405b3392732741837952f8be5082a53b5951f0eb7cfa3d19e15d
4
- data.tar.gz: 9afffa2c557e0de2607c36ba271d93df6e02c291cabc0f8b7100efb2452e4d31
3
+ metadata.gz: 3d95e7c0085b398b3ec034554d30cf4bd6e9dc488c5cc19d7777ec88aa5ed9ec
4
+ data.tar.gz: 323d16c0d405b4233ebe4377cb1a9960db84d19033193d3ccf9badb052a00f24
5
5
  SHA512:
6
- metadata.gz: 2336b176928f49a20717077619aab2bae0d5503e53d4c25f98407929488d99e0ad3bd22763a6afa41df3591b5e0f01bb99f304bae099c8f4679ff24132bcbfa5
7
- data.tar.gz: 6c8186bb46f45f42b6aa1db24dc7886ee73733994108bfa2870c8ecba71a72d9a993bf7cf51c6facc676cfab0270328ca532a7780797614ef17222db97aa797a
6
+ metadata.gz: d80c5803dcb0746cdf9def8e1b470394b424926a2a0235da90bc47e3f748f74fdbf278cd8e7640b157aabe279e2028e80581f4a7ebcde4b4d1238e9ee0040618
7
+ data.tar.gz: 1022635f98bd37c776d137315fd61f9ba82dd72831ca16ef2e834f45bcba0456e90614eebc85df1fd08b7e03183daa16beaa098575d4161f74b54e3d1ef998b6
data/CHANGELOG.md CHANGED
@@ -2,6 +2,39 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 3.2.0
6
+
7
+ ### Changed
8
+ - Changed OpenapiFirst::Test to track the request _after_ the app has handled the request. See [PR #434](https://github.com/ahx/openapi_first/pull/434). You can restore the old behavior with
9
+ ```ruby
10
+ include OpenapiFirst::Test::Methods[MyApp, validate_request_before_handling: true]
11
+ ```
12
+
13
+ ### Added
14
+
15
+ - Added `OpenapiFirst::ValidatedRequest#unknown?` and `OpenapiFirst::ValidatedResponse#unknown?`
16
+ - Added new hook: `after_response_body_property_validation`
17
+ - Added support for a static `path_prefix` value to be set on the creation of a Definition. See [PR #432](https://github.com/ahx/openapi_first/pull/432):
18
+ ```ruby
19
+ OpenapiFirst.configure do |config|
20
+ config.register('openapi/openapi.yaml' path_prefix: '/weather')
21
+ end
22
+ ```
23
+ - Added `OpenapiFirst::Test::Configuration#ignore_response_error` and `#ignore_request_error` to configure which request/response errors should not raise an error during testing:
24
+ ```ruby
25
+ OpenapiFirst::Test.setup do |test|
26
+ test.ignore_request_error do |validated_request|
27
+ # Ignore unknown requests on certain paths
28
+ validated_request.path.start_with?('/api/v1') && validated_request.unknown?
29
+ end
30
+
31
+ test.ignore_response_error do |validated_response, rack_request|
32
+ # Ignore invalid response bodies on certain paths
33
+ validated_request.path.start_with?('/api/legacy/stuff') && validated_request.error.type == :invalid_body
34
+ end
35
+ end
36
+ ```
37
+
5
38
  ## 3.1.1
6
39
 
7
40
  - Changed: Return uniqe errors in default error responses
data/README.md CHANGED
@@ -231,6 +231,23 @@ Here is how to set it up:
231
231
 
232
232
  ### Configure test coverage
233
233
 
234
+ You can ignore errors for certain requests/responses. This will stop `OpenapiFirst::Test` from raising an exception if the block returns true.
235
+
236
+ ```ruby
237
+ OpenapiFirst::Test.setup do |test|
238
+ test.ignore_request_error do |validated_request|
239
+ # Ignore unknown requests on certain paths
240
+ validated_request.path.start_with?('/api/v1') && validated_request.unknown?
241
+ end
242
+
243
+ test.ignore_response_error do |validated_response, rack_request|
244
+ # Ignore invalid response bodies on certain paths
245
+ validated_request.path.start_with?('/api/legacy/stuff') && validated_request.error.type == :invalid_body
246
+ end
247
+ end
248
+ ```
249
+
250
+
234
251
  OpenapiFirst::Test raises an error when a response status is not defined except for 404 and 500. You can change this:
235
252
 
236
253
  ```ruby
@@ -368,6 +385,7 @@ Available hooks:
368
385
  - `after_response_validation`
369
386
  - `after_request_parameter_property_validation`
370
387
  - `after_request_body_property_validation`
388
+ - `after_response_body_property_validation`
371
389
 
372
390
  Setup per per instance:
373
391
 
@@ -165,7 +165,10 @@ module OpenapiFirst
165
165
  responses.flat_map do |status, response_object|
166
166
  headers = build_response_headers(response_object['headers'])
167
167
  response_object['content']&.map do |content_type, content_object|
168
- content_schema = content_object['schema'].schema(configuration: schemer_configuration)
168
+ content_schema = content_object['schema'].schema(
169
+ configuration: schemer_configuration,
170
+ after_property_validation: config.after_response_body_property_validation
171
+ )
169
172
  Response.new(status:,
170
173
  headers:,
171
174
  content_type:,
@@ -8,6 +8,7 @@ module OpenapiFirst
8
8
  after_response_validation
9
9
  after_request_parameter_property_validation
10
10
  after_request_body_property_validation
11
+ after_response_body_property_validation
11
12
  ].freeze
12
13
 
13
14
  def initialize
@@ -11,6 +11,8 @@ module OpenapiFirst
11
11
 
12
12
  # @return [String,nil]
13
13
  attr_reader :filepath
14
+ # @return [String,nil]
15
+ attr_reader :path_prefix
14
16
  # @return [Configuration]
15
17
  attr_reader :config
16
18
  # @return [Enumerable[String]]
@@ -20,8 +22,10 @@ module OpenapiFirst
20
22
 
21
23
  # @param contents [Hash] The OpenAPI document.
22
24
  # @param filepath [String] The file path of the OpenAPI document.
23
- def initialize(contents, filepath = nil)
25
+ # @param path_prefix [String,nil] An optional path prefix, that is not documented, that all requests begin with.
26
+ def initialize(contents, filepath = nil, path_prefix = nil)
24
27
  @filepath = filepath
28
+ @path_prefix = path_prefix
25
29
  @config = OpenapiFirst.configuration.child
26
30
  yield @config if block_given?
27
31
  @config.freeze
@@ -79,23 +83,22 @@ module OpenapiFirst
79
83
  end
80
84
 
81
85
  # 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.
86
+ # @param request [Rack::Request] The Rack request object.
87
+ # @param response [Rack::Response] The Rack response object.
88
+ # @param raise_error [Boolean] Whether to raise an error if validation fails.
85
89
  # @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)
90
+ def validate_response(request, response, raise_error: false)
91
+ route = @router.match(request.request_method, resolve_path(request), content_type: request.content_type)
89
92
  return if route.error # Skip response validation for unknown requests
90
93
 
91
- response_match = route.match_response(status: rack_response.status, content_type: rack_response.content_type)
94
+ response_match = route.match_response(status: response.status, content_type: response.content_type)
92
95
  error = response_match.error
93
96
  validated = if error
94
- ValidatedResponse.new(rack_response, error:)
97
+ ValidatedResponse.new(response, error:)
95
98
  else
96
- response_match.response.validate(rack_response)
99
+ response_match.response.validate(response)
97
100
  end
98
- @config.after_response_validation&.each { |hook| hook.call(validated, rack_request, self) }
101
+ @config.after_response_validation&.each { |hook| hook.call(validated, request, self) }
99
102
  raise validated.error.exception(validated) if raise_error && validated.invalid?
100
103
 
101
104
  validated
@@ -104,6 +107,7 @@ module OpenapiFirst
104
107
  private
105
108
 
106
109
  def resolve_path(rack_request)
110
+ return rack_request.path.delete_prefix(path_prefix) if path_prefix && rack_request.path.start_with?(path_prefix)
107
111
  return rack_request.path unless @config.path
108
112
 
109
113
  @config.path.call(rack_request)
@@ -10,16 +10,24 @@ module OpenapiFirst
10
10
  # A wrapper of the original app
11
11
  # with silent request/response validation to track requests/responses.
12
12
  class App < SimpleDelegator
13
- def initialize(app, api:)
13
+ def initialize(app, api:, validate_request_before_handling:)
14
14
  super(app)
15
15
  @app = app
16
16
  @definition = Test[api]
17
+ @validate_request_before_handling = validate_request_before_handling
17
18
  end
18
19
 
19
20
  def call(env)
20
21
  request = Rack::Request.new(env)
21
- env[Test::REQUEST] = @definition.validate_request(request, raise_error: false)
22
+ if @validate_request_before_handling
23
+ env[Test::REQUEST] = @definition.validate_request(request, raise_error: false)
24
+ end
25
+
22
26
  response = @app.call(env)
27
+ unless @validate_request_before_handling
28
+ env[Test::REQUEST] = @definition.validate_request(request, raise_error: false)
29
+ end
30
+
23
31
  status, headers, body = response
24
32
  env[Test::RESPONSE] =
25
33
  @definition.validate_response(request, Rack::Response[status, headers, body], raise_error: false)
@@ -15,6 +15,8 @@ module OpenapiFirst
15
15
  @ignore_unknown_response_status = false
16
16
  @report_coverage = true
17
17
  @ignore_unknown_requests = false
18
+ @ignore_request_error = nil
19
+ @ignore_response_error = nil
18
20
  end
19
21
 
20
22
  # Register OADs, but don't load them just yet
@@ -50,6 +52,23 @@ module OpenapiFirst
50
52
  @report_coverage = value
51
53
  end
52
54
 
55
+ # Ignore certain errors for certain requests
56
+ # @param block A Proc that will be called with [OpenapiFirst::ValidatedRequest]
57
+ def ignore_request_error(&block)
58
+ raise ArgumentError, 'You have to pass a block' unless block_given?
59
+
60
+ @ignore_request_error = block
61
+ end
62
+
63
+ # Ignore certain errors for certain responses
64
+ # @param block A Proc that will be called with [OpenapiFirst::ValidatedResponse, Rack::Request]
65
+ def ignore_response_error(&block)
66
+ raise ArgumentError, 'You have to pass a block' unless block_given?
67
+
68
+ @ignore_response_error = block
69
+ end
70
+
71
+ # @param block A Proc that will be called with [OpenapiFirst::ValidatedResponse, Rack::Request]
53
72
  def skip_response_coverage(&block)
54
73
  return @skip_response_coverage unless block_given?
55
74
 
@@ -63,13 +82,22 @@ module OpenapiFirst
63
82
  end
64
83
 
65
84
  alias ignore_unknown_response_status? ignore_unknown_response_status
85
+ alias ignore_unknown_requests? ignore_unknown_requests
66
86
 
67
- def ignore_response?(validated_response)
68
- return false if validated_response.known?
87
+ def raise_request_error?(validated_request)
88
+ return false if @ignore_request_error&.call(validated_request)
89
+ return false if ignore_unknown_requests? && validated_request.unknown?
90
+
91
+ validated_request.unknown?
92
+ end
69
93
 
70
- return true if ignored_unknown_status.include?(validated_response.status)
94
+ def raise_response_error?(validated_response, rack_request)
95
+ return false if @ignore_response_error&.call(validated_response, rack_request)
96
+ return false if response_raise_error == false
97
+ return false if ignored_unknown_status.include?(validated_response.status)
98
+ return false if ignore_unknown_response_status? && validated_response.error.type == :response_status_not_found
71
99
 
72
- ignore_unknown_response_status? && validated_response.error.type == :response_status_not_found
100
+ true
73
101
  end
74
102
  end
75
103
  end
@@ -12,12 +12,13 @@ module OpenapiFirst
12
12
  base.include(AssertionMethod)
13
13
  end
14
14
 
15
- def self.[](application_under_test = nil, api: nil)
15
+ def self.[](application_under_test = nil, api: nil, validate_request_before_handling: false)
16
16
  mod = Module.new do
17
17
  def self.included(base)
18
18
  base.include OpenapiFirst::Test::Methods::AssertionMethod
19
19
  end
20
20
  end
21
+ mod.define_method(:openapi_first_validate_request_before_handling?) { validate_request_before_handling }
21
22
 
22
23
  if api
23
24
  mod.define_method(:openapi_first_default_api) { api }
@@ -26,7 +27,12 @@ module OpenapiFirst
26
27
  end
27
28
 
28
29
  if application_under_test
29
- mod.define_method(:app) { OpenapiFirst::Test.app(application_under_test, api: openapi_first_default_api) }
30
+ mod.define_method(:app) do
31
+ OpenapiFirst::Test.app(
32
+ application_under_test, api: openapi_first_default_api,
33
+ validate_request_before_handling: openapi_first_validate_request_before_handling?
34
+ )
35
+ end
30
36
  end
31
37
 
32
38
  mod
@@ -23,7 +23,7 @@ module OpenapiFirst
23
23
  return
24
24
  end
25
25
 
26
- unless app.instance_methods.include?(:call)
26
+ unless app.method_defined?(:call)
27
27
  raise ObserveError, "Don't know how to observe #{app}, because it has no call instance method."
28
28
  end
29
29
 
@@ -40,7 +40,7 @@ module OpenapiFirst
40
40
  end
41
41
 
42
42
  # Sets up OpenAPI test coverage and OAD registration.
43
- # @yieldparam [OpenapiFirst::Test::Configuration] configuration A configuration to setup test integration
43
+ # @yield [OpenapiFirst::Test::Configuration] configuration A configuration to setup test integration
44
44
  def self.setup
45
45
  install
46
46
  yield configuration if block_given?
@@ -95,9 +95,9 @@ module OpenapiFirst
95
95
  # Returns the Rack app wrapped with silent request, response validation
96
96
  # You can use this if you want to track coverage via Test::Coverage, but don't want to use
97
97
  # the middlewares or manual request, response validation.
98
- def self.app(app, spec: nil, api: :default)
98
+ def self.app(app, spec: nil, api: :default, validate_request_before_handling: false)
99
99
  spec ||= self[api]
100
- App.new(app, api: spec)
100
+ App.new(app, api: spec, validate_request_before_handling:)
101
101
  end
102
102
 
103
103
  def self.install
@@ -115,9 +115,7 @@ module OpenapiFirst
115
115
 
116
116
  @after_response_validation = config.after_response_validation do |validated_response, rack_request, oad|
117
117
  next unless registered?(oad)
118
- if validated_response.invalid? && raise_response_error?(validated_response)
119
- raise validated_response.error.exception
120
- end
118
+ raise validated_response.error.exception if raise_response_error?(validated_response, rack_request)
121
119
 
122
120
  Coverage.track_response(validated_response, rack_request, oad)
123
121
  end
@@ -156,15 +154,16 @@ module OpenapiFirst
156
154
 
157
155
  def raise_request_error?(validated_request)
158
156
  return false if validated_request.valid?
159
- return false if validated_request.known?
160
157
 
161
- !configuration.ignore_unknown_requests
158
+ configuration.raise_request_error?(validated_request)
162
159
  end
163
160
 
164
161
  def many?(array) = array.length > 1
165
162
 
166
- def raise_response_error?(invalid_response)
167
- configuration.response_raise_error && !configuration.ignore_response?(invalid_response)
163
+ def raise_response_error?(validated_response, rack_request)
164
+ return false if validated_response.valid?
165
+
166
+ configuration.raise_response_error?(validated_response, rack_request)
168
167
  end
169
168
  end
170
169
  end
@@ -69,6 +69,9 @@ module OpenapiFirst
69
69
  # Returns true if the request is defined.
70
70
  def known? = request_definition != nil
71
71
 
72
+ # Returns true if the request is not defined.
73
+ def unknown? = !known?
74
+
72
75
  # Merged path, query, body parameters.
73
76
  # Here path has the highest precedence, then query, then body.
74
77
  # @return [Hash<String, anything>]
@@ -44,6 +44,9 @@ module OpenapiFirst
44
44
  # Returns true if the response is defined.
45
45
  def known? = response_definition != nil
46
46
 
47
+ # Returns true if the response is not defined.
48
+ def unknown? = !known?
49
+
47
50
  # Checks if the response is invalid.
48
51
  # @return [Boolean]
49
52
  def invalid?
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OpenapiFirst
4
- VERSION = '3.1.1'
4
+ VERSION = '3.2.0'
5
5
  end
data/lib/openapi_first.rb CHANGED
@@ -55,8 +55,11 @@ module OpenapiFirst
55
55
 
56
56
  # Load and dereference an OpenAPI spec file or return the Definition if it's already loaded
57
57
  # @param filepath_or_definition [String, Definition] The path to the file or a Definition object
58
+ # @param only [Proc, nil] An optional proc to filter paths. It is called with the path string and should return
59
+ # true/false
60
+ # @param path_prefix [String, nil] An optional path prefix, that is not documented, that all requests begin with.
58
61
  # @return [Definition]
59
- def self.load(filepath_or_definition, only: nil, &)
62
+ def self.load(filepath_or_definition, only: nil, path_prefix: nil, &)
60
63
  return filepath_or_definition if filepath_or_definition.is_a?(Definition)
61
64
  return self[filepath_or_definition] if filepath_or_definition.is_a?(Symbol)
62
65
 
@@ -64,16 +67,16 @@ module OpenapiFirst
64
67
  raise FileNotFoundError, "File not found: #{filepath}" unless File.exist?(filepath)
65
68
 
66
69
  contents = FileLoader.load(filepath)
67
- parse(contents, only:, filepath:, &)
70
+ parse(contents, only:, filepath:, path_prefix:, &)
68
71
  end
69
72
 
70
73
  # Parse a dereferenced Hash
71
74
  # @return [Definition]
72
75
  # TODO: This needs to work with unresolved contents as well
73
- def self.parse(contents, only: nil, filepath: nil, &)
76
+ def self.parse(contents, only: nil, filepath: nil, path_prefix: nil, &)
74
77
  contents = ::JSON.parse(::JSON.generate(contents)) # Deeply stringify keys, because of YAML. See https://github.com/ahx/openapi_first/issues/367
75
78
  contents['paths'].filter!(&->(key, _) { only.call(key) }) if only
76
- Definition.new(contents, filepath, &)
79
+ Definition.new(contents, filepath, path_prefix, &)
77
80
  end
78
81
  end
79
82
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: openapi_first
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.1.1
4
+ version: 3.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andreas Haller