openapi_first 2.5.1 → 2.6.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: 63630ef9a08bcbb2602c05d518f0fc8e2da334ca27e8b5aa06d1165e16c5b1f8
4
+ data.tar.gz: 7a78262f72e78437b873a2044c81dac22fd76befa8671f457bf0deebe523028e
5
5
  SHA512:
6
- metadata.gz: 89f5dce609ef24d0ef739b887e42604784e7849395c967bce6f4f79084d5e35ef9f63608c3c328dc5825ffb668cc68c934290195f20edb8ace5bbf6d6adb6e26
7
- data.tar.gz: 9716f7808d362cec5e6261abc67c05082a7a6b997eed96a3e74b9e58172579f2f8a80464b20699b8f8a5c8d875d1cb3eaee57bf6300d971b6ad9bc6484f8d04b
6
+ metadata.gz: cd59bdb56d8ff90378967cf410ad0ccf0583d69b56bc153bc454773e2637023efb420658b7ba8e71ada6e763d116727b4057e73f6664b0ce2216127759e0aac7
7
+ data.tar.gz: 128c890b985671e5f882cb58bc0e5fbe49098619321d420d52d49035ad2650b18cc65b9cde246247a21176db23de527aec8b2e7921b693c638297d5057f63802
data/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 2.6.0
6
+
7
+ - Middlewares now accept the OAD as a first positional argument instead of `:spec` inside the options hash.
8
+ - 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). Use `OpenapiFirst::Schema::Hash` to validate multiple parameters schemas and return a single error object.
9
+ - `OpenapiFirst::Test::Methods[MyApplication]` returns a Module which adds an `app` method to be used by rack-test alonside the `assert_api_conform` method.
10
+ - Make default coverage report less verbose
11
+ 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
12
+
5
13
  ## 2.5.1
6
14
 
7
15
  - Fix skipping skipped responses during coverage tracking
data/README.md CHANGED
@@ -136,35 +136,41 @@ If you are adopting OpenAPI you can use these options together with [hooks](#hoo
136
136
 
137
137
  ## Contract Testing
138
138
 
139
+ Here are two aspects of contract testing: Validation and Coverage
140
+
141
+ ### Validation
142
+
143
+ 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.
144
+
139
145
  ### Coverage
140
146
 
147
+ To make sure your _whole_ API description is implemented, openapi_first ships with a coverage feature.
148
+
141
149
  > [!NOTE]
142
150
  > This is a brand new feature. ✨ Your feedback is very welcome.
143
151
 
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`:
152
+ This feature tracks all requests/resposes that are validated via openapi_first and tells you about which request/responses are missing.
153
+ Here is how to set it up with [rack-test](https://github.com/rack/rack-test):
148
154
 
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.
155
+ 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
156
  ```ruby
151
157
  require 'openapi_first'
152
158
  OpenapiFirst::Test.setup do |s|
153
159
  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' }
160
+ test.minimum_coverage = 100 # (Optional) Setting this will lead to an `exit 2` if coverage is below minimum
161
+ test.skip_response_coverage { it.status == '500' } # (Optional) Skip certain responses
156
162
  end
157
163
  ```
158
- 2. Wrap your app with silent request / response validation. This validates all requets/responses you do during your test run. (✷1)
164
+ 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)
165
+
159
166
  ```ruby
160
- config.before type: :request do
161
- def app
162
- OpenapiFirst::Test.app(App)
163
- end
167
+ def app
168
+ OpenapiFirst::Test.app(MyApp)
164
169
  end
165
170
  ```
171
+ 3. Run your tests. The Coverage feature will tell you about missing request/responses.
166
172
 
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.
173
+ (✷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
174
 
169
175
  ### Test assertions
170
176
 
@@ -306,6 +312,10 @@ end
306
312
  Using rack middlewares is supported in probably all Ruby web frameworks.
307
313
  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
314
 
315
+ The contract testing feature is designed to be used via rack-test, which should be compatible all Ruby web frameworks as well.
316
+
317
+ 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).
318
+
309
319
  ## Alternatives
310
320
 
311
321
  This gem was inspired by [committe](https://github.com/interagent/committee) (Ruby) and [Connexion](https://github.com/spec-first/connexion) (Python).
@@ -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,6 +33,18 @@ 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']
@@ -46,10 +63,10 @@ module OpenapiFirst
46
63
  def router # rubocop:disable Metrics/MethodLength
47
64
  router = OpenapiFirst::Router.new
48
65
  @contents.fetch('paths').each do |path, path_item_object|
49
- path_parameters = resolve_parameters(path_item_object['parameters'])
66
+ path_parameters = path_item_object['parameters'] || []
50
67
  path_item_object.resolved.keys.intersection(REQUEST_METHODS).map do |request_method|
51
68
  operation_object = path_item_object[request_method]
52
- operation_parameters = resolve_parameters(operation_object['parameters'])
69
+ operation_parameters = operation_object['parameters'] || []
53
70
  parameters = parse_parameters(operation_parameters.chain(path_parameters))
54
71
 
55
72
  build_requests(path:, request_method:, operation_object:,
@@ -79,10 +96,10 @@ module OpenapiFirst
79
96
  def parse_parameters(parameters)
80
97
  grouped_parameters = group_parameters(parameters)
81
98
  ParsedParameters.new(
82
- query: grouped_parameters[:query],
83
- path: grouped_parameters[:path],
84
- cookie: grouped_parameters[:cookie],
85
- header: grouped_parameters[:header],
99
+ query: resolve_parameters(grouped_parameters[:query]),
100
+ path: resolve_parameters(grouped_parameters[:path]),
101
+ cookie: resolve_parameters(grouped_parameters[:cookie]),
102
+ header: resolve_parameters(grouped_parameters[:header]),
86
103
  query_schema: build_parameter_schema(grouped_parameters[:query]),
87
104
  path_schema: build_parameter_schema(grouped_parameters[:path]),
88
105
  cookie_schema: build_parameter_schema(grouped_parameters[:cookie]),
@@ -99,11 +116,18 @@ module OpenapiFirst
99
116
  end
100
117
 
101
118
  def build_parameter_schema(parameters)
102
- schema = build_parameters_schema(parameters)
119
+ return unless parameters
103
120
 
104
- JSONSchemer.schema(schema,
105
- configuration: schemer_configuration,
106
- after_property_validation: config.hooks[:after_request_parameter_property_validation])
121
+ required = []
122
+ schemas = parameters.each_with_object({}) do |parameter, result|
123
+ schema = parameter['schema'].schema(configuration: schemer_configuration)
124
+ name = parameter['name']&.value
125
+ required << name if parameter['required']&.value
126
+ result[name] = schema if schema
127
+ end
128
+
129
+ Schema::Hash.new(schemas, required:, configuration: schemer_configuration,
130
+ after_property_validation: config.hooks[:after_request_parameter_property_validation])
107
131
  end
108
132
 
109
133
  def build_requests(path:, request_method:, operation_object:, parameters:)
@@ -141,20 +165,15 @@ module OpenapiFirst
141
165
  return [] unless responses
142
166
 
143
167
  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
- )
168
+ headers = build_response_headers(response_object['headers'])
149
169
  response_object['content']&.map do |content_type, content_object|
150
170
  content_schema = content_object['schema'].schema(configuration: schemer_configuration)
151
171
  Response.new(status:,
152
172
  headers:,
153
- headers_schema:,
154
173
  content_type:,
155
174
  content_schema:,
156
175
  key: [request.key, status, content_type].join(':'))
157
- end || Response.new(status:, headers:, headers_schema:, content_type: nil,
176
+ end || Response.new(status:, headers:, content_type: nil,
158
177
  content_schema: nil, key: [request.key, status, nil].join(':'))
159
178
  end
160
179
  end
@@ -162,49 +181,32 @@ module OpenapiFirst
162
181
  IGNORED_HEADER_PARAMETERS = Set['Content-Type', 'Accept', 'Authorization'].freeze
163
182
  private_constant :IGNORED_HEADER_PARAMETERS
164
183
 
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?
184
+ def build_response_headers(headers_object)
185
+ return if headers_object.nil?
176
186
 
177
- properties = {}
178
- required = []
187
+ result = []
179
188
  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']
189
+ next if header['schema'].nil?
190
+ next if IGNORED_HEADER_PARAMETERS.include?(name)
191
+
192
+ header = Header.new(
193
+ name:,
194
+ schema: header['schema'].schema(configuration: schemer_configuration),
195
+ required?: header['required']&.value == true,
196
+ node: header
197
+ )
198
+ result << header
185
199
  end
186
- {
187
- 'properties' => properties,
188
- 'required' => required
189
- }
200
+ result
190
201
  end
191
202
 
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']
203
+ def group_parameters(parameter_definitions)
204
+ result = {}
205
+ parameter_definitions&.each do |parameter|
206
+ (result[parameter['in']&.value&.to_sym] ||= []) << parameter
202
207
  end
203
-
204
- {
205
- 'properties' => properties,
206
- 'required' => required
207
- }
208
+ result[:header]&.reject! { IGNORED_HEADER_PARAMETERS.include?(_1['name']&.value) }
209
+ result
208
210
  end
209
211
 
210
212
  ParsedParameters = Data.define(:path, :query, :header, :cookie, :path_schema, :query_schema, :header_schema,
@@ -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
 
@@ -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'],
@@ -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
 
@@ -36,8 +37,9 @@ module OpenapiFirst
36
37
  plan.routes.each do |route|
37
38
  next if route.finished? && !verbose
38
39
 
40
+ next if route.requests.none?(&:requested?) && focused
41
+
39
42
  format_requests(route.requests)
40
- next if route.requests.none?(&:requested?)
41
43
 
42
44
  format_responses(route.responses)
43
45
  end
@@ -7,6 +7,16 @@ module OpenapiFirst
7
7
  module Test
8
8
  # Methods to use in integration tests
9
9
  module Methods
10
+ def self.[](*)
11
+ mod = Module.new do
12
+ def self.included(base)
13
+ OpenapiFirst::Test::Methods.included(base)
14
+ end
15
+ end
16
+ mod.define_method(:app) { OpenapiFirst::Test.app(*) }
17
+ mod
18
+ end
19
+
10
20
  def self.included(base)
11
21
  if Test.minitest?(base)
12
22
  base.include(OpenapiFirst::Test::MinitestHelpers)
@@ -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
@@ -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.6.0'
5
5
  end
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.6.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-04-06 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