openapi_first 2.5.1 → 2.7.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: cf125609ca3acdca953b0ab241990d7593ac3ac1820e4dcda0b1f7f711cfd87b
4
- data.tar.gz: 53c2039f9fb7f379784f08f2dbcf7e3aac3273f7cd452af17227ccb2b896ed9e
3
+ metadata.gz: 177998c88421283a6868e3aa9c5bbbe1eea702bc8b65abb22c28795cb2b7b6a2
4
+ data.tar.gz: 603118b530e39957ea95086d98a95362e9eb9b9490a63adc7c2bc8e0f7ba7846
5
5
  SHA512:
6
- metadata.gz: 89f5dce609ef24d0ef739b887e42604784e7849395c967bce6f4f79084d5e35ef9f63608c3c328dc5825ffb668cc68c934290195f20edb8ace5bbf6d6adb6e26
7
- data.tar.gz: 9716f7808d362cec5e6261abc67c05082a7a6b997eed96a3e74b9e58172579f2f8a80464b20699b8f8a5c8d875d1cb3eaee57bf6300d971b6ad9bc6484f8d04b
6
+ metadata.gz: 58aac32a5c8d7433414f4bf0f2cbf8142376c76d9ad918bf06531f40da0206e870a12e36718ca7763dd273ea18b664799f22acff84df143e36591a19284895fd
7
+ data.tar.gz: cba873da9fa8b1bf957a47fe34dcd30c50af51185dc83ba78c3f78ad2d5630399555c7d81f89f6e7cb96d9c56771c55aa956900249c2a6700d88869233339f6d
data/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 2.7.0
6
+
7
+ - Allow to override path for schema matching with `config.path = ->(request) { '/prefix' + request.path } ` (https://github.com/ahx/openapi_first/issues/349)
8
+ - Support passing in a Definition instance when registering an OAD for tests (https://github.com/ahx/openapi_first/issues/353)
9
+ - Fix registering multiple APIs for testing (https://github.com/ahx/openapi_first/issues/352)
10
+
11
+ ## 2.6.0
12
+
13
+ - Middlewares now accept the OAD as a first positional argument instead of `:spec` inside the options hash.
14
+ - No longer merge parameter schemas of the same location (for example "query") in order to fix [#320](https://github.com/ahx/openapi_first/issues/320).
15
+ - `OpenapiFirst::Test::Methods[MyApplication]` returns a Module which adds an `app` method to be used by rack-test alonside the `assert_api_conform` method.
16
+ - Make default coverage report less verbose
17
+ The default formatter (TerminalFormatter) no longer prints all un-requested requests by default. You can set `test.coverage_formatter_options = { focused: false }` to get back the old behavior
18
+
5
19
  ## 2.5.1
6
20
 
7
21
  - Fix skipping skipped responses during coverage tracking
data/README.md CHANGED
@@ -19,6 +19,7 @@ You can use openapi_first on production for [request validation](#request-valida
19
19
  - [Configuration](#configuration)
20
20
  - [Hooks](#hooks)
21
21
  - [Alternatives](#alternatives)
22
+ - [Frequently Asked Questions](#frequently-asked-questions)
22
23
  - [Development](#development)
23
24
  - [Benchmarks](#benchmarks)
24
25
  - [Contributing](#contributing)
@@ -29,13 +30,13 @@ You can use openapi_first on production for [request validation](#request-valida
29
30
 
30
31
  ### Request validation
31
32
 
32
- The request validation middleware returns a 4xx if the request is invalid or not defined in the API description. It adds a request object to the current Rack environment at `env[OpenapiFirst::REQUEST]` with the request parameters parsed exaclty as described in your API description plus access to meta information from your API description. See _[Manual use](#manual-use)_ for more details about that object.
33
+ The request validation middleware returns a 4xx if the request is invalid or not defined in the API description. It adds a request object to the current Rack environment at `env[OpenapiFirst::REQUEST]` with the request parameters parsed exactly as described in your API description plus access to meta information from your API description. See _[Manual use](#manual-use)_ for more details about that object.
33
34
 
34
35
  ```ruby
35
- use OpenapiFirst::Middlewares::RequestValidation, spec: 'openapi.yaml'
36
+ use OpenapiFirst::Middlewares::RequestValidation, 'openapi.yaml'
36
37
 
37
38
  # Pass `raise_error: true` to raise an error if request is invalid:
38
- use OpenapiFirst::Middlewares::RequestValidation, raise_error: true, spec: 'openapi.yaml'
39
+ use OpenapiFirst::Middlewares::RequestValidation, 'openapi.yaml', raise_error: true
39
40
  ```
40
41
 
41
42
  #### Error responses
@@ -73,7 +74,7 @@ content-type: "application/problem+json"
73
74
  openapi_first offers a [JSON:API](https://jsonapi.org/) error response by passing `error_response: :jsonapi`:
74
75
 
75
76
  ```ruby
76
- use OpenapiFirst::Middlewares::RequestValidation, spec: 'openapi.yaml, error_response: :jsonapi'
77
+ use OpenapiFirst::Middlewares::RequestValidation, 'openapi.yaml, error_response: :jsonapi'
77
78
  ```
78
79
 
79
80
  <details>
@@ -126,45 +127,57 @@ This middleware raises an error by default if the response is not valid.
126
127
  This can be useful in a test or staging environment, especially if you are adopting OpenAPI for an existing implementation.
127
128
 
128
129
  ```ruby
129
- use OpenapiFirst::Middlewares::ResponseValidation, spec: 'openapi.yaml' if ENV['RACK_ENV'] == 'test'
130
+ use OpenapiFirst::Middlewares::ResponseValidation, 'openapi.yaml' if ENV['RACK_ENV'] == 'test'
130
131
 
131
132
  # Pass `raise_error: false` to not raise an error:
132
- use OpenapiFirst::Middlewares::ResponseValidation, raise_error: false, spec: 'openapi.yaml'
133
+ use OpenapiFirst::Middlewares::ResponseValidation, 'openapi.yaml', raise_error: false
133
134
  ```
134
135
 
135
136
  If you are adopting OpenAPI you can use these options together with [hooks](#hooks) to get notified about requests/responses that do match your API description.
136
137
 
137
138
  ## Contract Testing
138
139
 
140
+ Here are two aspects of contract testing: Validation and Coverage
141
+
142
+ ### Validation
143
+
144
+ By validating requests and responses, you can avoid that your API implementation processes requests or returns responses that don't match your API description. You can use [test assertions](#test-assertions) or [rack middlewares](#rack-middlewares) or manual validation to validate requests and responses with openapi_first.
145
+
139
146
  ### Coverage
140
147
 
148
+ To make sure your _whole_ API description is implemented, openapi_first ships with a coverage feature.
149
+
141
150
  > [!NOTE]
142
151
  > This is a brand new feature. ✨ Your feedback is very welcome.
143
152
 
144
- This feature tracks all requests/resposes that are validated via openapi_first and get return an overal coverage value. If all of your described requests/responses have been validated successfully at least once, your coverage is 100%.
145
- By checking your validation coverage you can avoid API drift where your API description describes requests/responses differently than your implemention works.
146
-
147
- Here is how to set it up for RSpec in your `spec/spec_helper.rb`:
153
+ This feature tracks all requests/responses that are validated via openapi_first and tells you about which request/responses are missing.
154
+ Here is how to set it up with [rack-test](https://github.com/rack/rack-test):
148
155
 
149
- 1. Register all OpenAPI documents to track coverage for and start tracking. This should go at the top of you test helper file before loading application code.
156
+ 1. Register all OpenAPI documents to track coverage for. This should go at the top of your test helper file before loading your application code.
150
157
  ```ruby
151
158
  require 'openapi_first'
152
- OpenapiFirst::Test.setup do |s|
159
+ OpenapiFirst::Test.setup do |test|
153
160
  test.register('openapi/openapi.yaml')
154
- test.minimum_coverage = 100 # Setting this will lead to an `exit 2` if coverage is below minimum
155
- test.skip_response_coverage { it.status == '500' }
161
+ test.minimum_coverage = 100 # (Optional) Setting this will lead to an `exit 2` if coverage is below minimum
162
+ test.skip_response_coverage { it.status == '500' } # (Optional) Skip certain responses
156
163
  end
157
164
  ```
158
- 2. Wrap your app with silent request / response validation. This validates all requets/responses you do during your test run. (✷1)
165
+ 2. Add an `app` method to your tests, which wraps your application with silent request / response validation. This validates all requests/responses in your test run. (✷1)
166
+
159
167
  ```ruby
160
- config.before type: :request do
161
- def app
162
- OpenapiFirst::Test.app(App)
163
- end
168
+ def app
169
+ OpenapiFirst::Test.app(MyApp)
164
170
  end
165
171
  ```
172
+ 3. Run your tests. The Coverage feature will tell you about missing request/responses.
173
+
174
+ Or you can generate a Module and include it in your rspec spec_helper.rb:
175
+
176
+ ```ruby
177
+ config.include OpenapiFirst::Test::Methods[MyApp], type: :request
178
+ ```
166
179
 
167
- (✷1): Instead of using `OpenapiFirstTest.app` to wrap your application, you can use the middlewares or [test assertion method](#test-assertions), but you would have to do that for all requests/responses defined in your API description to make coverage work.
180
+ (✷1): It does not matter what method of openapi_first you use to validate requests/responses. Instead of using `OpenapiFirstTest.app` to wrap your application, you could also use the middlewares or [test assertion method](#test-assertions), but you would have to do that for all requests/responses defined in your API description to make coverage work.
168
181
 
169
182
  ### Test assertions
170
183
 
@@ -296,7 +309,7 @@ Setup globally:
296
309
  ```ruby
297
310
  OpenapiFirst.configure do |config|
298
311
  config.after_request_parameter_property_validation do |data, property, property_schema|
299
- data[property] = Date.iso8601(data[property]) if propert_schema['format'] == 'date'
312
+ data[property] = Date.iso8601(data[property]) if property_schema['format'] == 'date'
300
313
  end
301
314
  end
302
315
  ```
@@ -306,11 +319,40 @@ end
306
319
  Using rack middlewares is supported in probably all Ruby web frameworks.
307
320
  If you are using Ruby on Rails for example, you can add the request validation middleware globally in `config/application.rb` or inside specific controllers.
308
321
 
322
+ The contract testing feature is designed to be used via rack-test, which should be compatible all Ruby web frameworks as well.
323
+
324
+ That aside, closer integration with specific frameworks like Sinatra, Hanami, Roda or Rails would be great. If you have ideas, pain points or PRs, please don't hesitate to [share](https://github.com/ahx/openapi_first/discussions).
325
+
309
326
  ## Alternatives
310
327
 
311
- This gem was inspired by [committe](https://github.com/interagent/committee) (Ruby) and [Connexion](https://github.com/spec-first/connexion) (Python).
328
+ This gem was inspired by [committee](https://github.com/interagent/committee) (Ruby) and [Connexion](https://github.com/spec-first/connexion) (Python).
312
329
  Here is a [feature comparison between openapi_first and committee](https://gist.github.com/ahx/1538c31f0652f459861713b5259e366a).
313
330
 
331
+ ## Frequently Asked Questions
332
+
333
+ ### How can I adapt request paths that don't match my schema?
334
+
335
+ If your API is deployed at a different path than what's defined in your OpenAPI schema, you can use `env[OpenapiFirst::PATH]` to override the path used for schema matching.
336
+
337
+ Let's say you have `openapi.yaml` like this:
338
+
339
+ ```yaml
340
+ servers:
341
+ - url: https://yourhost/api
342
+ paths:
343
+ # The actual endpoint URL is https://yourhost/api/resource
344
+ /resource:
345
+ ```
346
+
347
+ Here your OpenAPI schema defines endpoints starting with `/resource` but your actual application is mounted at `/api/resource`. You can bridge the gap by transforming the path via the `path:` configuration:
348
+
349
+ ```ruby
350
+ oad = OpenapiFirst.load('openapi.yaml') do |config|
351
+ config.path = ->(req) { request.path.delete_prefix('/api') }
352
+ end
353
+ use OpenapiFirst::Middlewares::RequestValidation, oad
354
+ ```
355
+
314
356
  ## Development
315
357
 
316
358
  Run `bin/setup` to install dependencies.
@@ -1,6 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json_schemer'
4
+
5
+ require_relative 'failure'
6
+ require_relative 'router'
7
+ require_relative 'header'
8
+ require_relative 'request'
9
+ require_relative 'response'
10
+ require_relative 'schema/hash'
4
11
  require_relative 'ref_resolver'
5
12
 
6
13
  module OpenapiFirst
@@ -17,10 +24,8 @@ module OpenapiFirst
17
24
  end
18
25
 
19
26
  def initialize(contents, filepath:, config:)
20
- @schemer_configuration = JSONSchemer.configuration.clone
21
- @schemer_configuration.meta_schema = detect_meta_schema(contents, filepath)
22
- @schemer_configuration.insert_property_defaults = true
23
-
27
+ meta_schema = detect_meta_schema(contents, filepath)
28
+ @schemer_configuration = build_schemer_config(filepath:, meta_schema:)
24
29
  @config = config
25
30
  @contents = RefResolver.for(contents, filepath:)
26
31
  end
@@ -28,15 +33,25 @@ module OpenapiFirst
28
33
  attr_reader :config
29
34
  private attr_reader :schemer_configuration
30
35
 
36
+ def build_schemer_config(filepath:, meta_schema:)
37
+ result = JSONSchemer.configuration.clone
38
+ dir = (filepath && File.absolute_path(File.dirname(filepath))) || Dir.pwd
39
+ result.base_uri = URI::File.build({ path: "#{dir}/" })
40
+ result.ref_resolver = JSONSchemer::CachedResolver.new do |uri|
41
+ FileLoader.load(uri.path)
42
+ end
43
+ result.meta_schema = meta_schema
44
+ result.insert_property_defaults = true
45
+ result
46
+ end
47
+
31
48
  def detect_meta_schema(document, filepath)
32
49
  # Copied from JSONSchemer 🙇🏻‍♂️
33
50
  version = document['openapi']
34
51
  case version
35
52
  when /\A3\.1\.\d+\z/
36
- @document_schema = JSONSchemer.openapi31_document
37
53
  document.fetch('jsonSchemaDialect') { JSONSchemer::OpenAPI31::BASE_URI.to_s }
38
54
  when /\A3\.0\.\d+\z/
39
- @document_schema = JSONSchemer.openapi30_document
40
55
  JSONSchemer::OpenAPI30::BASE_URI.to_s
41
56
  else
42
57
  raise Error, "Unsupported OpenAPI version #{version.inspect} #{filepath}"
@@ -46,10 +61,10 @@ module OpenapiFirst
46
61
  def router # rubocop:disable Metrics/MethodLength
47
62
  router = OpenapiFirst::Router.new
48
63
  @contents.fetch('paths').each do |path, path_item_object|
49
- path_parameters = resolve_parameters(path_item_object['parameters'])
64
+ path_parameters = path_item_object['parameters'] || []
50
65
  path_item_object.resolved.keys.intersection(REQUEST_METHODS).map do |request_method|
51
66
  operation_object = path_item_object[request_method]
52
- operation_parameters = resolve_parameters(operation_object['parameters'])
67
+ operation_parameters = operation_object['parameters'] || []
53
68
  parameters = parse_parameters(operation_parameters.chain(path_parameters))
54
69
 
55
70
  build_requests(path:, request_method:, operation_object:,
@@ -79,10 +94,10 @@ module OpenapiFirst
79
94
  def parse_parameters(parameters)
80
95
  grouped_parameters = group_parameters(parameters)
81
96
  ParsedParameters.new(
82
- query: grouped_parameters[:query],
83
- path: grouped_parameters[:path],
84
- cookie: grouped_parameters[:cookie],
85
- header: grouped_parameters[:header],
97
+ query: resolve_parameters(grouped_parameters[:query]),
98
+ path: resolve_parameters(grouped_parameters[:path]),
99
+ cookie: resolve_parameters(grouped_parameters[:cookie]),
100
+ header: resolve_parameters(grouped_parameters[:header]),
86
101
  query_schema: build_parameter_schema(grouped_parameters[:query]),
87
102
  path_schema: build_parameter_schema(grouped_parameters[:path]),
88
103
  cookie_schema: build_parameter_schema(grouped_parameters[:cookie]),
@@ -99,11 +114,18 @@ module OpenapiFirst
99
114
  end
100
115
 
101
116
  def build_parameter_schema(parameters)
102
- schema = build_parameters_schema(parameters)
117
+ return unless parameters
103
118
 
104
- JSONSchemer.schema(schema,
105
- configuration: schemer_configuration,
106
- after_property_validation: config.hooks[:after_request_parameter_property_validation])
119
+ required = []
120
+ schemas = parameters.each_with_object({}) do |parameter, result|
121
+ schema = parameter['schema'].schema(configuration: schemer_configuration)
122
+ name = parameter['name']&.value
123
+ required << name if parameter['required']&.value
124
+ result[name] = schema if schema
125
+ end
126
+
127
+ Schema::Hash.new(schemas, required:, configuration: schemer_configuration,
128
+ after_property_validation: config.hooks[:after_request_parameter_property_validation])
107
129
  end
108
130
 
109
131
  def build_requests(path:, request_method:, operation_object:, parameters:)
@@ -141,20 +163,15 @@ module OpenapiFirst
141
163
  return [] unless responses
142
164
 
143
165
  responses.flat_map do |status, response_object|
144
- headers = response_object['headers']&.resolved
145
- headers_schema = JSONSchemer::Schema.new(
146
- build_headers_schema(headers),
147
- configuration: schemer_configuration
148
- )
166
+ headers = build_response_headers(response_object['headers'])
149
167
  response_object['content']&.map do |content_type, content_object|
150
168
  content_schema = content_object['schema'].schema(configuration: schemer_configuration)
151
169
  Response.new(status:,
152
170
  headers:,
153
- headers_schema:,
154
171
  content_type:,
155
172
  content_schema:,
156
173
  key: [request.key, status, content_type].join(':'))
157
- end || Response.new(status:, headers:, headers_schema:, content_type: nil,
174
+ end || Response.new(status:, headers:, content_type: nil,
158
175
  content_schema: nil, key: [request.key, status, nil].join(':'))
159
176
  end
160
177
  end
@@ -162,49 +179,32 @@ module OpenapiFirst
162
179
  IGNORED_HEADER_PARAMETERS = Set['Content-Type', 'Accept', 'Authorization'].freeze
163
180
  private_constant :IGNORED_HEADER_PARAMETERS
164
181
 
165
- def group_parameters(parameter_definitions)
166
- result = {}
167
- parameter_definitions&.each do |parameter|
168
- (result[parameter['in'].to_sym] ||= []) << parameter
169
- end
170
- result[:header]&.reject! { IGNORED_HEADER_PARAMETERS.include?(_1['name']) }
171
- result
172
- end
173
-
174
- def build_headers_schema(headers_object)
175
- return unless headers_object&.any?
182
+ def build_response_headers(headers_object)
183
+ return if headers_object.nil?
176
184
 
177
- properties = {}
178
- required = []
185
+ result = []
179
186
  headers_object.each do |name, header|
180
- schema = header['schema']
181
- next if name.casecmp('content-type').zero?
182
-
183
- properties[name] = schema if schema
184
- required << name if header['required']
187
+ next if header['schema'].nil?
188
+ next if IGNORED_HEADER_PARAMETERS.include?(name)
189
+
190
+ header = Header.new(
191
+ name:,
192
+ schema: header['schema'].schema(configuration: schemer_configuration),
193
+ required?: header['required']&.value == true,
194
+ node: header
195
+ )
196
+ result << header
185
197
  end
186
- {
187
- 'properties' => properties,
188
- 'required' => required
189
- }
198
+ result
190
199
  end
191
200
 
192
- def build_parameters_schema(parameters)
193
- return unless parameters
194
-
195
- properties = {}
196
- required = []
197
- parameters.each do |parameter|
198
- schema = parameter['schema']
199
- name = parameter['name']
200
- properties[name] = schema if schema
201
- required << name if parameter['required']
201
+ def group_parameters(parameter_definitions)
202
+ result = {}
203
+ parameter_definitions&.each do |parameter|
204
+ (result[parameter['in']&.value&.to_sym] ||= []) << parameter
202
205
  end
203
-
204
- {
205
- 'properties' => properties,
206
- 'required' => required
207
- }
206
+ result[:header]&.reject! { IGNORED_HEADER_PARAMETERS.include?(_1['name']&.value) }
207
+ result
208
208
  end
209
209
 
210
210
  ParsedParameters = Data.define(:path, :query, :header, :cookie, :path_schema, :query_schema, :header_schema,
@@ -15,10 +15,11 @@ module OpenapiFirst
15
15
  @request_validation_raise_error = false
16
16
  @response_validation_raise_error = true
17
17
  @hooks = (HOOKS.map { [_1, Set.new] }).to_h
18
+ @path = nil
18
19
  end
19
20
 
20
21
  attr_reader :request_validation_error_response, :hooks
21
- attr_accessor :request_validation_raise_error, :response_validation_raise_error
22
+ attr_accessor :request_validation_raise_error, :response_validation_raise_error, :path
22
23
 
23
24
  def clone
24
25
  copy = super
@@ -1,9 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'failure'
4
- require_relative 'router'
5
- require_relative 'request'
6
- require_relative 'response'
7
3
  require_relative 'builder'
8
4
  require 'forwardable'
9
5
 
@@ -44,12 +40,30 @@ module OpenapiFirst
44
40
  # @return [Enumerable[Router::Route]]
45
41
  def_delegators :@router, :routes
46
42
 
43
+ # Returns a unique identifier for this API definition
44
+ # @return [String] A unique key for this API definition
45
+ def key
46
+ return filepath if filepath
47
+
48
+ info = self['info'] || {}
49
+ title = info['title']
50
+ version = info['version']
51
+
52
+ if title.nil? || version.nil?
53
+ raise ArgumentError,
54
+ "Cannot generate key for the OpenAPI document because 'info.title' or 'info.version' is missing. " \
55
+ 'Please add these fields to your OpenAPI document.'
56
+ end
57
+
58
+ "#{title} @ #{version}"
59
+ end
60
+
47
61
  # Validates the request against the API description.
48
62
  # @param [Rack::Request] request The Rack request object.
49
63
  # @param [Boolean] raise_error Whether to raise an error if validation fails.
50
64
  # @return [ValidatedRequest] The validated request object.
51
65
  def validate_request(request, raise_error: false)
52
- route = @router.match(request.request_method, request.path, content_type: request.content_type)
66
+ route = @router.match(request.request_method, resolve_path(request), content_type: request.content_type)
53
67
  if route.error
54
68
  ValidatedRequest.new(request, error: route.error)
55
69
  else
@@ -66,7 +80,8 @@ module OpenapiFirst
66
80
  # @param raise_error [Boolean] Whethir to raise an error if validation fails.
67
81
  # @return [ValidatedResponse] The validated response object.
68
82
  def validate_response(rack_request, rack_response, raise_error: false)
69
- route = @router.match(rack_request.request_method, rack_request.path, content_type: rack_request.content_type)
83
+ route = @router.match(rack_request.request_method, resolve_path(rack_request),
84
+ content_type: rack_request.content_type)
70
85
  return if route.error # Skip response validation for unknown requests
71
86
 
72
87
  response_match = route.match_response(status: rack_response.status, content_type: rack_response.content_type)
@@ -80,5 +95,13 @@ module OpenapiFirst
80
95
  raise validated.error.exception(validated) if raise_error && validated.invalid?
81
96
  end
82
97
  end
98
+
99
+ private
100
+
101
+ def resolve_path(rack_request)
102
+ return rack_request.path unless @config.path
103
+
104
+ @config.path.call(rack_request)
105
+ end
83
106
  end
84
107
  end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiFirst
4
+ Header = Data.define(:name, :required?, :schema, :node) do
5
+ def resolved_schema
6
+ node['schema']&.resolved
7
+ end
8
+ end
9
+ end
@@ -6,19 +6,26 @@ module OpenapiFirst
6
6
  # A Rack middleware to validate requests against an OpenAPI API description
7
7
  class RequestValidation
8
8
  # @param app The parent Rack application
9
- # @param options An optional Hash of configuration options to override defaults
9
+ # @param spec [String, OpenapiFirst::Definition] Path to the OpenAPI file or an instance of Definition.
10
+ # @param options Hash
11
+ # :spec [String, OpenapiFirst::Definition] Path to the OpenAPI file or an instance of Definition.
12
+ # This will be deprecated. Please use spec argument instead.
10
13
  # :raise_error A Boolean indicating whether to raise an error if validation fails.
11
14
  # default: false
12
15
  # :error_response The Class to use for error responses.
13
16
  # This can be a Symbol-name of an registered error response (:default, :jsonapi)
14
17
  # or it can be set to false to disable returning a response.
15
18
  # default: OpenapiFirst::Plugins::Default::ErrorResponse (Config.default_options.error_response)
16
- def initialize(app, options = {})
19
+ def initialize(app, spec = nil, options = {})
17
20
  @app = app
21
+ if spec.is_a?(Hash)
22
+ options = spec
23
+ spec = options.fetch(:spec)
24
+ end
18
25
  @raise = options.fetch(:raise_error, OpenapiFirst.configuration.request_validation_raise_error)
19
26
  @error_response_class = error_response_option(options[:error_response])
20
27
 
21
- spec = options.fetch(:spec)
28
+ spec ||= options.fetch(:spec)
22
29
  raise "You have to pass spec: when initializing #{self.class}" unless spec
23
30
 
24
31
  @definition = spec.is_a?(Definition) ? spec : OpenapiFirst.load(spec)
@@ -6,15 +6,19 @@ module OpenapiFirst
6
6
  module Middlewares
7
7
  # A Rack middleware to validate requests against an OpenAPI API description
8
8
  class ResponseValidation
9
- # @param app The parent Rack application
9
+ # @param spec [String, OpenapiFirst::Definition] Path to the OpenAPI file or an instance of Definition
10
10
  # @param options Hash
11
- # :spec [String, OpenapiFirst::Definition] Path to the OpenAPI file or an instance of Definition
11
+ # :spec [String, OpenapiFirst::Definition] Path to the OpenAPI file or an instance of Definition.
12
+ # This will be deprecated. Please use spec argument instead.
12
13
  # :raise_error [Boolean] Whether to raise an error if validation fails. default: true
13
- def initialize(app, options = {})
14
+ def initialize(app, spec = nil, options = {})
14
15
  @app = app
16
+ if spec.is_a?(Hash)
17
+ options = spec
18
+ spec = options.fetch(:spec)
19
+ end
15
20
  @raise = options.fetch(:raise_error, OpenapiFirst.configuration.response_validation_raise_error)
16
21
 
17
- spec = options.fetch(:spec)
18
22
  raise "You have to pass spec: when initializing #{self.class}" unless spec
19
23
 
20
24
  @definition = spec.is_a?(Definition) ? spec : OpenapiFirst.load(spec)
@@ -25,7 +29,8 @@ module OpenapiFirst
25
29
 
26
30
  def call(env)
27
31
  status, headers, body = @app.call(env)
28
- @definition.validate_response(Rack::Request.new(env), Rack::Response[status, headers, body], raise_error: @raise)
32
+ @definition.validate_response(Rack::Request.new(env), Rack::Response[status, headers, body],
33
+ raise_error: @raise)
29
34
  [status, headers, body]
30
35
  end
31
36
  end
@@ -41,8 +41,11 @@ module OpenapiFirst
41
41
  @value = value
42
42
  @context = context
43
43
  @filepath = filepath
44
- dir = File.dirname(File.expand_path(filepath)) if filepath
45
- @dir = (dir && File.absolute_path(dir)) || Dir.pwd
44
+ @dir = if filepath
45
+ File.dirname(File.absolute_path(filepath))
46
+ else
47
+ Dir.pwd
48
+ end
46
49
  end
47
50
 
48
51
  # The value of this node
@@ -52,7 +55,11 @@ module OpenapiFirst
52
55
  # The object where this node was found in
53
56
  attr_reader :context
54
57
 
55
- private attr_reader :filepath
58
+ attr_reader :filepath
59
+
60
+ def ==(_other)
61
+ raise "Don't call == on an unresolved value. Use .value == other instead."
62
+ end
56
63
 
57
64
  def resolve_ref(pointer)
58
65
  if pointer.start_with?('#')
@@ -89,6 +96,10 @@ module OpenapiFirst
89
96
  include Diggable
90
97
  include Enumerable
91
98
 
99
+ def ==(_other)
100
+ raise "Don't call == on an unresolved value. Use .value == other instead."
101
+ end
102
+
92
103
  def resolved
93
104
  return resolve_ref(value['$ref']).value if value.key?('$ref')
94
105
 
@@ -108,17 +119,16 @@ module OpenapiFirst
108
119
  end
109
120
 
110
121
  def each
111
- resolved.each do |key, value|
112
- yield key, RefResolver.for(value, filepath:, context:)
122
+ resolved.each_key do |key|
123
+ yield key, self[key]
113
124
  end
114
125
  end
115
126
 
116
- def schema(options = {})
117
- ref_resolver = JSONSchemer::CachedResolver.new do |uri|
118
- FileLoader.load(uri.path)
119
- end
127
+ # You have to pass configuration or ref_resolver
128
+ def schema(options)
120
129
  base_uri = URI::File.build({ path: "#{dir}/" })
121
- root = JSONSchemer::Schema.new(context, base_uri:, ref_resolver:, **options)
130
+ root = JSONSchemer::Schema.new(context, base_uri:, **options)
131
+ # binding.irb if value['maxItems'] == 4
122
132
  JSONSchemer::Schema.new(value, nil, root, base_uri:, **options)
123
133
  end
124
134
  end
@@ -137,8 +147,8 @@ module OpenapiFirst
137
147
  end
138
148
 
139
149
  def each
140
- resolved.each do |item|
141
- yield RefResolver.for(item, filepath:, context:)
150
+ resolved.each_with_index do |_item, index|
151
+ yield self[index]
142
152
  end
143
153
  end
144
154
 
@@ -9,21 +9,20 @@ module OpenapiFirst
9
9
  # This is not a direct reflecton of the OpenAPI 3.X response definition, but a combination of
10
10
  # status, content type and content schema.
11
11
  class Response
12
- def initialize(status:, headers:, headers_schema:, content_type:, content_schema:, key:)
12
+ def initialize(status:, headers:, content_type:, content_schema:, key:)
13
13
  @status = status
14
14
  @content_type = content_type
15
15
  @content_schema = content_schema
16
16
  @headers = headers
17
- @headers_schema = headers_schema
18
17
  @key = key
19
18
  @parser = ResponseParser.new(headers:, content_type:)
20
- @validator = ResponseValidator.new(self)
19
+ @validator = ResponseValidator.new(content_schema:, headers:)
21
20
  end
22
21
 
23
22
  # @attr_reader [Integer] status The HTTP status code of the response definition.
24
23
  # @attr_reader [String, nil] content_type Content type of this response.
25
24
  # @attr_reader [Schema, nil] content_schema the Schema of the response body.
26
- attr_reader :status, :content_type, :content_schema, :headers, :headers_schema, :key
25
+ attr_reader :status, :content_type, :content_schema, :headers, :key
27
26
 
28
27
  def validate(response)
29
28
  parsed_values = nil
@@ -15,7 +15,7 @@ module OpenapiFirst
15
15
  def parse(rack_response)
16
16
  ParsedResponse.new(
17
17
  body: @body_parser.call(read_body(rack_response)),
18
- headers: @headers_parser.call(rack_response.headers)
18
+ headers: @headers_parser&.call(rack_response.headers) || {}
19
19
  )
20
20
  end
21
21
 
@@ -30,9 +30,16 @@ module OpenapiFirst
30
30
  rack_response.body
31
31
  end
32
32
 
33
- def build_headers_parser(header_definitions)
34
- headers_as_parameters = header_definitions.to_a.map do |name, definition|
35
- definition.merge('name' => name, 'in' => 'header')
33
+ def build_headers_parser(headers)
34
+ return unless headers&.any?
35
+
36
+ headers_as_parameters = headers.map do |header|
37
+ {
38
+ 'name' => header.name,
39
+ 'explode' => false,
40
+ 'in' => 'header',
41
+ 'schema' => header.resolved_schema
42
+ }
36
43
  end
37
44
  OpenapiParameters::Header.new(headers_as_parameters).method(:unpack)
38
45
  end
@@ -11,10 +11,10 @@ module OpenapiFirst
11
11
  Validators::ResponseBody
12
12
  ].freeze
13
13
 
14
- def initialize(response_definition)
15
- @validators = VALIDATORS.filter_map do |klass|
16
- klass.for(response_definition)
17
- end
14
+ def initialize(content_schema:, headers:)
15
+ @validators = []
16
+ @validators << Validators::ResponseBody.new(content_schema) if content_schema
17
+ @validators << Validators::ResponseHeaders.new(headers) if headers&.any?
18
18
  end
19
19
 
20
20
  def call(parsed_response)
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'validation_error'
4
+
5
+ module OpenapiFirst
6
+ class Schema
7
+ # A hash of Schemas
8
+ class Hash
9
+ # @param schema Hash of schemas
10
+ # @param required Array of required keys
11
+ def initialize(schemas, required: nil, **options)
12
+ @schemas = schemas
13
+ @options = options
14
+ @after_property_validation = options.delete(:after_property_validation)
15
+ schema = { 'type' => 'object' }
16
+ schema['required'] = required if required
17
+ @root_schema = JSONSchemer.schema(schema, **options)
18
+ end
19
+
20
+ def validate(root_value)
21
+ validation = @root_schema.validate(root_value)
22
+ validations = @schemas.reduce(validation) do |enum, (key, schema)|
23
+ root_value[key] = schema.value['default'] if schema.value.key?('default') && !root_value.key?(key)
24
+ next enum unless root_value.key?(key)
25
+
26
+ value = root_value[key]
27
+ key_validation = schema.validate(value)
28
+ @after_property_validation&.each do |hook|
29
+ hook.call(root_value, key, schema.value, nil)
30
+ end
31
+ enum.chain(key_validation.map do |err|
32
+ data_pointer = "/#{key}"
33
+ err.merge(
34
+ 'error' => JSONSchemer::Errors.pretty(err),
35
+ 'data_pointer' => data_pointer
36
+ )
37
+ end)
38
+ end
39
+ ValidationResult.new(validations)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -3,7 +3,44 @@
3
3
  module OpenapiFirst
4
4
  class Schema
5
5
  # One of multiple validation errors. Returned by Schema::ValidationResult#errors.
6
- ValidationError = Data.define(:value, :message, :data_pointer, :schema_pointer, :type, :details, :schema) do
6
+ ValidationError = Data.define(:value, :data_pointer, :schema_pointer, :type, :details, :schema) do
7
+ # This returns an error message for this specific error.
8
+ # This it copied from json_schemer here to be easier to customize when passing custom data_pointers.
9
+ def message
10
+ location = data_pointer.empty? ? 'root' : "`#{data_pointer}`"
11
+
12
+ case type
13
+ when 'required'
14
+ keys = details.fetch('missing_keys', []).join(', ')
15
+ "object at #{location} is missing required properties: #{keys}"
16
+ when 'dependentRequired'
17
+ keys = details.fetch('missing_keys').join(', ')
18
+ "object at #{location} is missing required properties: #{keys}"
19
+ when 'string', 'boolean', 'number'
20
+ "value at #{location} is not a #{type}"
21
+ when 'array', 'object', 'integer'
22
+ "value at #{location} is not an #{type}"
23
+ when 'null'
24
+ "value at #{location} is not #{type}"
25
+ when 'pattern'
26
+ "string at #{location} does not match pattern: #{schema.fetch('pattern')}"
27
+ when 'format'
28
+ "value at #{location} does not match format: #{schema.fetch('format')}"
29
+ when 'const'
30
+ "value at #{location} is not: #{schema.fetch('const').inspect}"
31
+ when 'enum'
32
+ "value at #{location} is not one of: #{schema.fetch('enum')}"
33
+ when 'minimum'
34
+ "number at #{location} is less than: #{schema['minimum']}"
35
+ when 'maximum'
36
+ "number at #{location} is greater than: #{schema['maximum']}"
37
+ when 'readOnly'
38
+ "value at #{location} is `readOnly`"
39
+ else
40
+ "value at #{location} is invalid (#{type.inspect})"
41
+ end
42
+ end
43
+
7
44
  # @deprecated Please use {#message} instead
8
45
  def error
9
46
  warn 'OpenapiFirst::Schema::ValidationError#error is deprecated. Use #message instead.'
@@ -17,7 +17,6 @@ module OpenapiFirst
17
17
  @errors ||= @validation.map do |err|
18
18
  ValidationError.new(
19
19
  value: err['data'],
20
- message: err['error'],
21
20
  data_pointer: err['data_pointer'],
22
21
  schema_pointer: err['schema_pointer'],
23
22
  type: err['type'],
@@ -13,7 +13,7 @@ module OpenapiFirst
13
13
  class UnknownRequestError < StandardError; end
14
14
 
15
15
  def self.for(oad, skip_response: nil)
16
- plan = new(filepath: oad.filepath)
16
+ plan = new(definition_key: oad.key, filepath: oad.filepath)
17
17
  oad.routes.each do |route|
18
18
  responses = skip_response ? route.responses.reject(&skip_response) : route.responses
19
19
  plan.add_route request_method: route.request_method,
@@ -24,13 +24,14 @@ module OpenapiFirst
24
24
  plan
25
25
  end
26
26
 
27
- def initialize(filepath:)
27
+ def initialize(definition_key:, filepath: nil)
28
28
  @routes = []
29
29
  @index = {}
30
+ @api_identifier = filepath || definition_key
30
31
  @filepath = filepath
31
32
  end
32
33
 
33
- attr_reader :filepath, :routes
34
+ attr_reader :api_identifier, :filepath, :routes
34
35
  private attr_reader :index
35
36
 
36
37
  def track_request(validated_request)
@@ -5,8 +5,9 @@ module OpenapiFirst
5
5
  module Coverage
6
6
  # This is the default formatter
7
7
  class TerminalFormatter
8
- def initialize(verbose: false)
8
+ def initialize(verbose: false, focused: true)
9
9
  @verbose = verbose
10
+ @focused = focused && !verbose
10
11
  end
11
12
 
12
13
  # This takes a list of Coverage::Plan instances and outputs a String
@@ -16,7 +17,7 @@ module OpenapiFirst
16
17
  @out.string
17
18
  end
18
19
 
19
- private attr_reader :out, :verbose
20
+ private attr_reader :out, :verbose, :focused
20
21
 
21
22
  private
22
23
 
@@ -29,15 +30,15 @@ module OpenapiFirst
29
30
  end
30
31
 
31
32
  def format_plan(plan)
32
- filepath = plan.filepath
33
- puts ['', "API validation coverage for #{filepath}: #{plan.coverage}%"]
33
+ puts ['', "API validation coverage for #{plan.api_identifier}: #{plan.coverage}%"]
34
34
  return if plan.done? && !verbose
35
35
 
36
36
  plan.routes.each do |route|
37
37
  next if route.finished? && !verbose
38
38
 
39
+ next if route.requests.none?(&:requested?) && focused
40
+
39
41
  format_requests(route.requests)
40
- next if route.requests.none?(&:requested?)
41
42
 
42
43
  format_responses(route.responses)
43
44
  end
@@ -36,7 +36,7 @@ module OpenapiFirst
36
36
  def start(skip_response: nil)
37
37
  @current_run = Test.definitions.values.to_h do |oad|
38
38
  plan = Plan.for(oad, skip_response:)
39
- [oad.filepath, plan]
39
+ [oad.key, plan]
40
40
  end
41
41
  end
42
42
 
@@ -53,11 +53,11 @@ module OpenapiFirst
53
53
  end
54
54
 
55
55
  def track_request(request, oad)
56
- current_run[oad.filepath].track_request(request)
56
+ current_run[oad.key]&.track_request(request)
57
57
  end
58
58
 
59
59
  def track_response(response, _request, oad)
60
- current_run[oad.filepath].track_response(response)
60
+ current_run[oad.key]&.track_response(response)
61
61
  end
62
62
 
63
63
  def result
@@ -8,10 +8,53 @@ module OpenapiFirst
8
8
  # Methods to use in integration tests
9
9
  module Methods
10
10
  def self.included(base)
11
- if Test.minitest?(base)
12
- base.include(OpenapiFirst::Test::MinitestHelpers)
11
+ base.include(DefaultApiMethod)
12
+ base.include(AssertionMethod)
13
+ end
14
+
15
+ def self.[](application_under_test = nil, api: nil)
16
+ mod = Module.new do
17
+ def self.included(base)
18
+ base.include OpenapiFirst::Test::Methods::AssertionMethod
19
+ end
20
+ end
21
+
22
+ if api
23
+ mod.define_method(:openapi_first_default_api) { api }
13
24
  else
14
- base.include(OpenapiFirst::Test::PlainHelpers)
25
+ mod.include(DefaultApiMethod)
26
+ end
27
+
28
+ if application_under_test
29
+ mod.define_method(:app) { OpenapiFirst::Test.app(application_under_test, api: openapi_first_default_api) }
30
+ end
31
+
32
+ mod
33
+ end
34
+
35
+ # Default methods
36
+ module DefaultApiMethod
37
+ # This is the default api that is used by assert_api_conform
38
+ # :default is the default name that is used if you don't pass an `api:` option to `OpenapiFirst::Test.register`
39
+ # This is overwritten if you pass an `api:` option to `include OpenapiFirst::Test::Methods[…]`
40
+ def openapi_first_default_api
41
+ klass = self.class
42
+ if klass.respond_to?(:metadata) && klass.metadata[:api]
43
+ klass.metadata[:api]
44
+ else
45
+ :default
46
+ end
47
+ end
48
+ end
49
+
50
+ # @visibility private
51
+ module AssertionMethod
52
+ def self.included(base)
53
+ if Test.minitest?(base)
54
+ base.include(OpenapiFirst::Test::MinitestHelpers)
55
+ else
56
+ base.include(OpenapiFirst::Test::PlainHelpers)
57
+ end
15
58
  end
16
59
  end
17
60
  end
@@ -5,7 +5,7 @@ module OpenapiFirst
5
5
  # Assertion methods for Minitest
6
6
  module MinitestHelpers
7
7
  # :nocov:
8
- def assert_api_conform(status: nil, api: :default)
8
+ def assert_api_conform(status: nil, api: openapi_first_default_api)
9
9
  api = OpenapiFirst::Test[api]
10
10
  request = respond_to?(:last_request) ? last_request : @request
11
11
  response = respond_to?(:last_response) ? last_response : @response
@@ -5,7 +5,7 @@ module OpenapiFirst
5
5
  # Assertion methods to use when no known test framework was found
6
6
  # These methods just raise an exception if an error was found
7
7
  module PlainHelpers
8
- def assert_api_conform(status: nil, api: :default)
8
+ def assert_api_conform(status: nil, api: openapi_first_default_api)
9
9
  api = OpenapiFirst::Test[api]
10
10
  # :nocov:
11
11
  request = respond_to?(:last_request) ? last_request : @request
@@ -22,8 +22,8 @@ module OpenapiFirst
22
22
  yield self
23
23
  end
24
24
 
25
- def register(*)
26
- Test.register(*)
25
+ def register(oad, as: :default)
26
+ Test.register(oad, as:)
27
27
  end
28
28
 
29
29
  attr_accessor :minimum_coverage, :coverage_formatter_options, :coverage_formatter
@@ -47,7 +47,7 @@ module OpenapiFirst
47
47
  end
48
48
  return unless minimum_coverage > coverage
49
49
 
50
- puts "API Coverage fails with exit 2, because API coverage of #{coverage}%" \
50
+ puts "API Coverage fails with exit 2, because API coverage of #{coverage}% " \
51
51
  "is below minimum of #{minimum_coverage}%!"
52
52
  exit 2
53
53
  # :nocov:
@@ -81,7 +81,6 @@ module OpenapiFirst
81
81
  def self.report_coverage(formatter: Coverage::TerminalFormatter, **)
82
82
  coverage_result = Coverage.result
83
83
  puts formatter.new(**).format(coverage_result)
84
- puts "The overal API validation coverage of this run is: #{coverage_result.coverage}%"
85
84
  end
86
85
 
87
86
  # Returns the Rack app wrapped with silent request, response validation
@@ -104,18 +103,23 @@ module OpenapiFirst
104
103
  class << self
105
104
  attr_reader :definitions
106
105
 
107
- def register(path, as: :default)
108
- if definitions.key?(:default)
106
+ # Register an OpenAPI definition for testing
107
+ # @param path_or_definition [String, Definition] Path to the OpenAPI file or a Definition object
108
+ # @param as [Symbol] Name to register the API definition as
109
+ def register(path_or_definition, as: :default)
110
+ if definitions.key?(as) && as == :default
109
111
  raise(
110
112
  AlreadyRegisteredError,
111
113
  "#{definitions[as].filepath.inspect} is already registered " \
112
- "as ':default' so you cannot register #{path.inspect} without " \
114
+ "as ':default' so you cannot register #{path_or_definition.inspect} without " \
113
115
  'giving it a custom name. Please call register with a custom key like: ' \
114
- "OpenapiFirst::Test.register(#{path.inspect}, as: :my_other_api)"
116
+ "OpenapiFirst::Test.register(#{path_or_definition.inspect}, as: :my_other_api)"
115
117
  )
116
118
  end
117
119
 
118
- definitions[as] = OpenapiFirst.load(path)
120
+ definition = OpenapiFirst.load(path_or_definition)
121
+ definitions[as] = definition
122
+ definition
119
123
  end
120
124
 
121
125
  def [](api)
@@ -6,7 +6,6 @@ module OpenapiFirst
6
6
  RequestHeaders = Data.define(:schema) do
7
7
  def call(parsed_request)
8
8
  validation = schema.validate(parsed_request.headers)
9
- validation = Schema::ValidationResult.new(validation.to_a)
10
9
  Failure.fail!(:invalid_header, errors: validation.errors) if validation.error?
11
10
  end
12
11
  end
@@ -14,7 +13,6 @@ module OpenapiFirst
14
13
  Path = Data.define(:schema) do
15
14
  def call(parsed_request)
16
15
  validation = schema.validate(parsed_request.path)
17
- validation = Schema::ValidationResult.new(validation.to_a)
18
16
  Failure.fail!(:invalid_path, errors: validation.errors) if validation.error?
19
17
  end
20
18
  end
@@ -22,7 +20,6 @@ module OpenapiFirst
22
20
  Query = Data.define(:schema) do
23
21
  def call(parsed_request)
24
22
  validation = schema.validate(parsed_request.query)
25
- validation = Schema::ValidationResult.new(validation.to_a)
26
23
  Failure.fail!(:invalid_query, errors: validation.errors) if validation.error?
27
24
  end
28
25
  end
@@ -30,7 +27,6 @@ module OpenapiFirst
30
27
  RequestCookies = Data.define(:schema) do
31
28
  def call(parsed_request)
32
29
  validation = schema.validate(parsed_request.cookies)
33
- validation = Schema::ValidationResult.new(validation.to_a)
34
30
  Failure.fail!(:invalid_cookie, errors: validation.errors) if validation.error?
35
31
  end
36
32
  end
@@ -45,7 +41,7 @@ module OpenapiFirst
45
41
  def self.for(args)
46
42
  VALIDATORS.filter_map do |key, klass|
47
43
  schema = args[key]
48
- klass.new(schema) if schema.value
44
+ klass.new(schema) if schema
49
45
  end
50
46
  end
51
47
  end
@@ -5,13 +5,6 @@ require_relative '../schema/validation_result'
5
5
  module OpenapiFirst
6
6
  module Validators
7
7
  class ResponseBody
8
- def self.for(response_definition)
9
- schema = response_definition&.content_schema
10
- return unless schema
11
-
12
- new(schema)
13
- end
14
-
15
8
  def initialize(schema)
16
9
  @schema = schema
17
10
  end
@@ -3,24 +3,37 @@
3
3
  module OpenapiFirst
4
4
  module Validators
5
5
  class ResponseHeaders
6
- def self.for(response_definition)
7
- schema = response_definition&.headers_schema
8
- return unless schema&.value
9
-
10
- new(schema)
6
+ def initialize(headers)
7
+ @headers = headers
11
8
  end
12
9
 
13
- def initialize(schema)
14
- @schema = schema
10
+ attr_reader :headers
11
+
12
+ def call(parsed_response)
13
+ headers.each do |header|
14
+ header_value = parsed_response.headers[header.name]
15
+ next if header_value.nil? && !header.required?
16
+
17
+ validation_errors = header.schema.validate(header_value)
18
+ next unless validation_errors.any?
19
+
20
+ Failure.fail!(:invalid_response_header,
21
+ errors: [error_for(data_pointer: "/#{header.name}", value: header_value,
22
+ error: validation_errors.first)])
23
+ end
15
24
  end
16
25
 
17
- attr_reader :schema
26
+ private
18
27
 
19
- def call(parsed_request)
20
- validation = Schema::ValidationResult.new(
21
- schema.validate(parsed_request.headers)
28
+ def error_for(data_pointer:, value:, error:)
29
+ Schema::ValidationError.new(
30
+ value: value,
31
+ data_pointer:,
32
+ schema_pointer: error['schema_pointer'],
33
+ type: error['type'],
34
+ details: error['details'],
35
+ schema: error['schema']
22
36
  )
23
- Failure.fail!(:invalid_response_header, errors: validation.errors) if validation.error?
24
37
  end
25
38
  end
26
39
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OpenapiFirst
4
- VERSION = '2.5.1'
4
+ VERSION = '2.7.0'
5
5
  end
data/lib/openapi_first.rb CHANGED
@@ -15,6 +15,10 @@ module OpenapiFirst
15
15
 
16
16
  # Key in rack to find instance of Request
17
17
  REQUEST = 'openapi.request'
18
+
19
+ # Key in rack to store the alternate path used for schema matching
20
+ PATH = 'openapi.path'
21
+
18
22
  FAILURE = :openapi_first_validation_failure
19
23
 
20
24
  # @return [Configuration]
@@ -48,9 +52,13 @@ module OpenapiFirst
48
52
  end
49
53
  end
50
54
 
51
- # Load and dereference an OpenAPI spec file
55
+ # Load and dereference an OpenAPI spec file or return the Definition if it's already loaded
56
+ # @param filepath_or_definition [String, Definition] The path to the file or a Definition object
52
57
  # @return [Definition]
53
- def self.load(filepath, only: nil, &)
58
+ def self.load(filepath_or_definition, only: nil, &)
59
+ return filepath_or_definition if filepath_or_definition.is_a?(Definition)
60
+
61
+ filepath = filepath_or_definition
54
62
  raise FileNotFoundError, "File not found: #{filepath}" unless File.exist?(filepath)
55
63
 
56
64
  contents = FileLoader.load(filepath)
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: openapi_first
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.5.1
4
+ version: 2.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andreas Haller
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-03-26 00:00:00.000000000 Z
10
+ date: 2025-05-04 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: hana
@@ -102,6 +102,7 @@ files:
102
102
  - lib/openapi_first/errors.rb
103
103
  - lib/openapi_first/failure.rb
104
104
  - lib/openapi_first/file_loader.rb
105
+ - lib/openapi_first/header.rb
105
106
  - lib/openapi_first/json.rb
106
107
  - lib/openapi_first/middlewares/request_validation.rb
107
108
  - lib/openapi_first/middlewares/response_validation.rb
@@ -118,6 +119,7 @@ files:
118
119
  - lib/openapi_first/router/find_content.rb
119
120
  - lib/openapi_first/router/find_response.rb
120
121
  - lib/openapi_first/router/path_template.rb
122
+ - lib/openapi_first/schema/hash.rb
121
123
  - lib/openapi_first/schema/validation_error.rb
122
124
  - lib/openapi_first/schema/validation_result.rb
123
125
  - lib/openapi_first/test.rb