openapi_first 2.6.0 → 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: 63630ef9a08bcbb2602c05d518f0fc8e2da334ca27e8b5aa06d1165e16c5b1f8
4
- data.tar.gz: 7a78262f72e78437b873a2044c81dac22fd76befa8671f457bf0deebe523028e
3
+ metadata.gz: 177998c88421283a6868e3aa9c5bbbe1eea702bc8b65abb22c28795cb2b7b6a2
4
+ data.tar.gz: 603118b530e39957ea95086d98a95362e9eb9b9490a63adc7c2bc8e0f7ba7846
5
5
  SHA512:
6
- metadata.gz: cd59bdb56d8ff90378967cf410ad0ccf0583d69b56bc153bc454773e2637023efb420658b7ba8e71ada6e763d116727b4057e73f6664b0ce2216127759e0aac7
7
- data.tar.gz: 128c890b985671e5f882cb58bc0e5fbe49098619321d420d52d49035ad2650b18cc65b9cde246247a21176db23de527aec8b2e7921b693c638297d5057f63802
6
+ metadata.gz: 58aac32a5c8d7433414f4bf0f2cbf8142376c76d9ad918bf06531f40da0206e870a12e36718ca7763dd273ea18b664799f22acff84df143e36591a19284895fd
7
+ data.tar.gz: cba873da9fa8b1bf957a47fe34dcd30c50af51185dc83ba78c3f78ad2d5630399555c7d81f89f6e7cb96d9c56771c55aa956900249c2a6700d88869233339f6d
data/CHANGELOG.md CHANGED
@@ -2,10 +2,16 @@
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
+
5
11
  ## 2.6.0
6
12
 
7
13
  - 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.
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).
9
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.
10
16
  - Make default coverage report less verbose
11
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
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,10 +127,10 @@ 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.
@@ -149,13 +150,13 @@ To make sure your _whole_ API description is implemented, openapi_first ships wi
149
150
  > [!NOTE]
150
151
  > This is a brand new feature. ✨ Your feedback is very welcome.
151
152
 
152
- This feature tracks all requests/resposes that are validated via openapi_first and tells you about which request/responses are missing.
153
+ This feature tracks all requests/responses that are validated via openapi_first and tells you about which request/responses are missing.
153
154
  Here is how to set it up with [rack-test](https://github.com/rack/rack-test):
154
155
 
155
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.
156
157
  ```ruby
157
158
  require 'openapi_first'
158
- OpenapiFirst::Test.setup do |s|
159
+ OpenapiFirst::Test.setup do |test|
159
160
  test.register('openapi/openapi.yaml')
160
161
  test.minimum_coverage = 100 # (Optional) Setting this will lead to an `exit 2` if coverage is below minimum
161
162
  test.skip_response_coverage { it.status == '500' } # (Optional) Skip certain responses
@@ -168,7 +169,13 @@ Here is how to set it up with [rack-test](https://github.com/rack/rack-test):
168
169
  OpenapiFirst::Test.app(MyApp)
169
170
  end
170
171
  ```
171
- 3. Run your tests. The Coverage feature will tell you about missing request/responses.
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
+ ```
172
179
 
173
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.
174
181
 
@@ -302,7 +309,7 @@ Setup globally:
302
309
  ```ruby
303
310
  OpenapiFirst.configure do |config|
304
311
  config.after_request_parameter_property_validation do |data, property, property_schema|
305
- data[property] = Date.iso8601(data[property]) if propert_schema['format'] == 'date'
312
+ data[property] = Date.iso8601(data[property]) if property_schema['format'] == 'date'
306
313
  end
307
314
  end
308
315
  ```
@@ -318,9 +325,34 @@ That aside, closer integration with specific frameworks like Sinatra, Hanami, Ro
318
325
 
319
326
  ## Alternatives
320
327
 
321
- 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).
322
329
  Here is a [feature comparison between openapi_first and committee](https://gist.github.com/ahx/1538c31f0652f459861713b5259e366a).
323
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
+
324
356
  ## Development
325
357
 
326
358
  Run `bin/setup` to install dependencies.
@@ -50,10 +50,8 @@ module OpenapiFirst
50
50
  version = document['openapi']
51
51
  case version
52
52
  when /\A3\.1\.\d+\z/
53
- @document_schema = JSONSchemer.openapi31_document
54
53
  document.fetch('jsonSchemaDialect') { JSONSchemer::OpenAPI31::BASE_URI.to_s }
55
54
  when /\A3\.0\.\d+\z/
56
- @document_schema = JSONSchemer.openapi30_document
57
55
  JSONSchemer::OpenAPI30::BASE_URI.to_s
58
56
  else
59
57
  raise Error, "Unsupported OpenAPI version #{version.inspect} #{filepath}"
@@ -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
@@ -40,12 +40,30 @@ module OpenapiFirst
40
40
  # @return [Enumerable[Router::Route]]
41
41
  def_delegators :@router, :routes
42
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
+
43
61
  # Validates the request against the API description.
44
62
  # @param [Rack::Request] request The Rack request object.
45
63
  # @param [Boolean] raise_error Whether to raise an error if validation fails.
46
64
  # @return [ValidatedRequest] The validated request object.
47
65
  def validate_request(request, raise_error: false)
48
- 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)
49
67
  if route.error
50
68
  ValidatedRequest.new(request, error: route.error)
51
69
  else
@@ -62,7 +80,8 @@ module OpenapiFirst
62
80
  # @param raise_error [Boolean] Whethir to raise an error if validation fails.
63
81
  # @return [ValidatedResponse] The validated response object.
64
82
  def validate_response(rack_request, rack_response, raise_error: false)
65
- 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)
66
85
  return if route.error # Skip response validation for unknown requests
67
86
 
68
87
  response_match = route.match_response(status: rack_response.status, content_type: rack_response.content_type)
@@ -76,5 +95,13 @@ module OpenapiFirst
76
95
  raise validated.error.exception(validated) if raise_error && validated.invalid?
77
96
  end
78
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
79
106
  end
80
107
  end
@@ -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)
@@ -30,8 +30,7 @@ module OpenapiFirst
30
30
  end
31
31
 
32
32
  def format_plan(plan)
33
- filepath = plan.filepath
34
- puts ['', "API validation coverage for #{filepath}: #{plan.coverage}%"]
33
+ puts ['', "API validation coverage for #{plan.api_identifier}: #{plan.coverage}%"]
35
34
  return if plan.done? && !verbose
36
35
 
37
36
  plan.routes.each do |route|
@@ -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
@@ -7,21 +7,54 @@ module OpenapiFirst
7
7
  module Test
8
8
  # Methods to use in integration tests
9
9
  module Methods
10
- def self.[](*)
10
+ def self.included(base)
11
+ base.include(DefaultApiMethod)
12
+ base.include(AssertionMethod)
13
+ end
14
+
15
+ def self.[](application_under_test = nil, api: nil)
11
16
  mod = Module.new do
12
17
  def self.included(base)
13
- OpenapiFirst::Test::Methods.included(base)
18
+ base.include OpenapiFirst::Test::Methods::AssertionMethod
14
19
  end
15
20
  end
16
- mod.define_method(:app) { OpenapiFirst::Test.app(*) }
21
+
22
+ if api
23
+ mod.define_method(:openapi_first_default_api) { api }
24
+ else
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
+
17
32
  mod
18
33
  end
19
34
 
20
- def self.included(base)
21
- if Test.minitest?(base)
22
- base.include(OpenapiFirst::Test::MinitestHelpers)
23
- else
24
- base.include(OpenapiFirst::Test::PlainHelpers)
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
25
58
  end
26
59
  end
27
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:
@@ -103,18 +103,23 @@ module OpenapiFirst
103
103
  class << self
104
104
  attr_reader :definitions
105
105
 
106
- def register(path, as: :default)
107
- 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
108
111
  raise(
109
112
  AlreadyRegisteredError,
110
113
  "#{definitions[as].filepath.inspect} is already registered " \
111
- "as ':default' so you cannot register #{path.inspect} without " \
114
+ "as ':default' so you cannot register #{path_or_definition.inspect} without " \
112
115
  'giving it a custom name. Please call register with a custom key like: ' \
113
- "OpenapiFirst::Test.register(#{path.inspect}, as: :my_other_api)"
116
+ "OpenapiFirst::Test.register(#{path_or_definition.inspect}, as: :my_other_api)"
114
117
  )
115
118
  end
116
119
 
117
- definitions[as] = OpenapiFirst.load(path)
120
+ definition = OpenapiFirst.load(path_or_definition)
121
+ definitions[as] = definition
122
+ definition
118
123
  end
119
124
 
120
125
  def [](api)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OpenapiFirst
4
- VERSION = '2.6.0'
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.6.0
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-04-06 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