openapi-ruby 4.0.2 → 4.1.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: 27487aa88423921e2f0274f7d93a5abd534dc3fca2567692d07c73001b66017f
4
- data.tar.gz: 3f62cffd0f03d943b7198e6d230b5ad851746d4e7982f65ea891d213fd509490
3
+ metadata.gz: 7990b0f6b4af407b5df408029221d095428b91e0f0cc71f798f77e6d37901eb5
4
+ data.tar.gz: 4514fb71405d81e25a2e079e9e0ebcb03110f1430c327be386fd4d63c336eff1
5
5
  SHA512:
6
- metadata.gz: 580fb57048ffea5a623ba2b7940b83c8bee9a8e7d73db91ccbe566095fcf64b09ae0228fe9a791df6c2a7a9c03f6a14cdb92d65a24cfd7b881bc245715f1e198
7
- data.tar.gz: be207593a6158ea4b1fd0cc0758d332f5557ba61788b04a32e8eabbc85ae8300060956d20bfad9198952bccd92e0ce41e76fd2a02e298cf4dff17a8a0ec2cd10
6
+ metadata.gz: 6f2f27ba8f415683fb454b707e90eec7b9bb3f6adf5c5c1835b1f4002d83afb898414d4a735cc372d9b6a5a47f636c7aa2d0bf24dee092c5fe2b40180828a218
7
+ data.tar.gz: 666bb48fd0aaa60c3b25ab242e180735513416db9b34aaa22ec9dcabd54cc0dbcb2aeb5a50d36c7ae860f68fda83cb5261e0706561decce219f657fde4003853
data/README.md CHANGED
@@ -473,9 +473,17 @@ Loading a test file normally *is* enough to run it: `rails/test_help` requires `
473
473
 
474
474
  Schemas are **only** written by the rake task — running tests (`bundle exec rspec`, `rails test`) does not generate or overwrite schema files. This prevents partial schema overwrites when running a subset of specs.
475
475
 
476
+ ### No database required
477
+
478
+ The document is built from your declarations, never from the database — but `rails/test_help` verifies the test schema at require time (`maintain_test_schema!`), and many hand-written helpers add `ActiveRecord::Migration.check_all_pending!`. Both open a connection, which would make a database a hard requirement for generating a document that doesn't need one.
479
+
480
+ Generation stubs both out, so `rake openapi_ruby:generate` runs with no database available. Nothing else about your helper changes, and the stubs exist only inside the generation subprocess — normal test runs still verify the schema as usual.
481
+
482
+ Only the schema *check* is skipped. A connection is still available if your declarations genuinely need one (an enum built from a query at load time, say); such a suite needs a database either way.
483
+
476
484
  ### Making generation cheaper (optional)
477
485
 
478
- Generation only needs your `path` / `api_path` declarations to register. Everything else a test helper does — connecting to a database, loading fixtures, `maintain_test_schema!` — is dead weight, and on a large suite it dominates the runtime.
486
+ Generation only needs your `path` / `api_path` declarations to register. Booting the full test framework and loading fixtures is dead weight, and on a large suite it dominates the runtime.
479
487
 
480
488
  Guard that setup with `OpenapiRuby.schema_generating?`, which returns `true` only in the rake task's subprocess (it sets `OPENAPI_RUBY_GENERATING=true`):
481
489
 
@@ -489,7 +497,7 @@ require "rails/test_help"
489
497
  # ...other test-time setup...
490
498
  ```
491
499
 
492
- This is an optimization, not a correctness requirement the suppressor handles the autorun hook either way.
500
+ This is purely an optimization — generation is already correct and database-free without it.
493
501
 
494
502
  One caveat if you do guard: skipping `rails/test_help` also means `fixtures` is undefined, so any test file calling `fixtures :all` in its class body fails to *load*. Point `PATTERN` at just the files carrying `api_path` declarations:
495
503
 
@@ -499,6 +507,65 @@ PATTERN="test/integration/api/**/*_test.rb" rake openapi_ruby:generate
499
507
 
500
508
  Suites using FactoryBot rather than fixtures don't hit this.
501
509
 
510
+ ### How a request finds its api_path (Style 2)
511
+
512
+ Style 2 separates the `api_path` declaration from the request that exercises
513
+ it, so `assert_api_response` has to match the request back to a declaration. It
514
+ narrows the declared paths by, in order:
515
+
516
+ 1. the verb — only paths declaring it stay in
517
+ 2. the path params — a path needing `{project_id}` is out if none was supplied,
518
+ and a path is out if it doesn't use every key given in `path_params:`
519
+ 3. the expected status — `assert_api_response :put, 422` skips paths that don't
520
+ declare a 422 for that verb
521
+ 4. how many supplied keys the path can explain, as either one of its own path
522
+ params or a parameter declared on the operation
523
+
524
+ That resolves a collection path against a member path, nested resources, and
525
+ sibling paths distinguished by status. It cannot resolve paths that agree on all
526
+ four:
527
+
528
+ ```ruby
529
+ api_path "/timers/{id}" { put("Update") { response(200, "ok") } }
530
+ api_path "/timers/{id}/start" { put("Start") { response(200, "ok") } }
531
+ api_path "/timers/{id}/stop" { put("Stop") { response(200, "ok") } }
532
+ ```
533
+
534
+ Nothing at the call site tells those apart, so that raises
535
+ `OpenapiRuby::AmbiguousApiPath` naming the candidates rather than silently
536
+ picking the first and validating against the wrong response schema. Two ways to
537
+ resolve it. Name the path on the request:
538
+
539
+ ```ruby
540
+ assert_api_response :put, 200, path_params: {id: timer.id}, api_path: "/timers/{id}/start"
541
+ ```
542
+
543
+ Or, in RSpec, declare each path in its own example group — a nested `describe`
544
+ only sees paths declared at or above it:
545
+
546
+ ```ruby
547
+ describe "start" do
548
+ api_path("/timers/{id}/start") { put("Start") { response(200, "ok") } }
549
+
550
+ it { assert_api_response :put, 200, path_params: {id: timer.id} }
551
+ end
552
+
553
+ describe "stop" do
554
+ api_path("/timers/{id}/stop") { put("Stop") { response(200, "ok") } }
555
+
556
+ it { assert_api_response :put, 200, path_params: {id: timer.id} }
557
+ end
558
+ ```
559
+
560
+ To require one path per test class regardless, switch on:
561
+
562
+ ```ruby
563
+ config.single_api_path_per_class = true
564
+ ```
565
+
566
+ `api_path` then raises `OpenapiRuby::MultipleApiPaths` as soon as a class
567
+ declares a second path. Off by default.
568
+
502
569
  ### Migrating from RSpec to Minitest (or vice versa)
503
570
 
504
571
  When both `spec/spec_helper.rb` and `test/test_helper.rb` are present, the rake task auto-selects `FRAMEWORK=hybrid` — it requires both adapters and loads both glob patterns (`spec/**/*_spec.rb,test/**/*_test.rb`) into one process. Style 1 `path(...)` and Style 2 `api_path(...)` definitions register into the same `MetadataStore`, so a single schema file holds paths contributed by either DSL.
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiRuby
4
+ module Adapters
5
+ # Shared by the Minitest and RSpec Style 2 adapters.
6
+ #
7
+ # Style 2 separates the `api_path` declaration from the request that
8
+ # exercises it, so the request has to be matched back to a declaration.
9
+ # Everything used to do that here is a hard fact about the request, never a
10
+ # guess: the verb, which path params the template needs against which the
11
+ # caller supplied, whether the candidate declares the status the assertion
12
+ # demands, and whether the remaining params are declared on the operation.
13
+ #
14
+ # That leaves one case it cannot decide. `/timers/{id}` and
15
+ # `/timers/{id}/start` under the same verb, the same status and the same
16
+ # `{id}` are indistinguishable from the call site — the information simply
17
+ # isn't there. Picking one silently sends the request to the wrong endpoint
18
+ # and validates it against the wrong response schema, so a test passes while
19
+ # exercising something else. Raise instead, and name the candidates; the
20
+ # author resolves it by passing `api_path:` or by scoping the declarations.
21
+ module ContextResolution
22
+ module_function
23
+
24
+ def resolve(contexts, method, path_params, owner:, params: {}, expected_status: nil, api_path: nil)
25
+ if api_path
26
+ selected = find_declared(contexts, api_path)
27
+ raise OpenapiRuby::Error, unknown_path_message(contexts, api_path, owner) unless selected
28
+ return selected
29
+ end
30
+
31
+ candidates = contexts.select { |ctx| ctx.operations.key?(method.to_s) }
32
+ return candidates.first if candidates.size <= 1
33
+
34
+ supplied = keys_of(params) | keys_of(path_params)
35
+ required = keys_of(path_params)
36
+
37
+ candidates = narrow(candidates) { |ctx| path_params_fit?(ctx, required, supplied) }
38
+ if expected_status
39
+ candidates = narrow(candidates) { |ctx| declares_status?(ctx, method, expected_status) }
40
+ end
41
+ candidates = fewest_unaccounted(candidates, method, supplied)
42
+
43
+ return candidates.first if candidates.size == 1
44
+
45
+ raise OpenapiRuby::AmbiguousApiPath, ambiguity_message(candidates, method, owner)
46
+ end
47
+
48
+ # A template only fits if it needs no path param the caller did not supply,
49
+ # and uses every param the caller explicitly declared as one.
50
+ def path_params_fit?(context, required, supplied)
51
+ template = template_params(context)
52
+
53
+ (required - template).empty? && (template - supplied).empty?
54
+ end
55
+
56
+ def declares_status?(context, method, expected_status)
57
+ context.operations[method.to_s].responses.key?(expected_status.to_s)
58
+ end
59
+
60
+ # Prefer the candidate that can explain the most supplied keys as either a
61
+ # path param of its own template or a parameter declared on it. A key that
62
+ # fits nowhere means the request was probably meant for a sibling path.
63
+ def fewest_unaccounted(candidates, method, supplied)
64
+ ranked = candidates.group_by { |ctx| (supplied - accounted_keys(ctx, method)).size }
65
+
66
+ ranked[ranked.keys.min]
67
+ end
68
+
69
+ def accounted_keys(context, method)
70
+ declared = context.path_parameters + (context.operations[method.to_s]&.parameters || [])
71
+
72
+ template_params(context) | declared.filter_map { |param| param["name"]&.to_s }
73
+ end
74
+
75
+ def template_params(context)
76
+ context.path_template.scan(/\{(\w+)\}/).flatten
77
+ end
78
+
79
+ def find_declared(contexts, api_path)
80
+ template = api_path.respond_to?(:path_template) ? api_path.path_template : api_path.to_s
81
+
82
+ contexts.find { |ctx| ctx.path_template == template }
83
+ end
84
+
85
+ def narrow(candidates)
86
+ narrowed = candidates.select { |ctx| yield(ctx) }
87
+
88
+ narrowed.empty? ? candidates : narrowed
89
+ end
90
+
91
+ def keys_of(params)
92
+ params.keys.map(&:to_s)
93
+ end
94
+
95
+ def ambiguity_message(matches, method, owner)
96
+ paths = matches.map { |ctx| ctx.path_template.inspect }.join(", ")
97
+
98
+ "#{method.to_s.upcase} matches more than one api_path in #{owner}: #{paths}. " \
99
+ "Requests are matched on the verb, the path params supplied and the declared " \
100
+ "response status, none of which tell these apart. Pass api_path: to pick one, " \
101
+ "or declare each api_path in its own class or nested describe block."
102
+ end
103
+
104
+ def unknown_path_message(contexts, api_path, owner)
105
+ template = api_path.respond_to?(:path_template) ? api_path.path_template : api_path.to_s
106
+ declared = contexts.map { |ctx| ctx.path_template.inspect }.join(", ")
107
+
108
+ "No api_path #{template.inspect} declared in #{owner}. " \
109
+ "Declared: #{declared.empty? ? "none" : declared}."
110
+ end
111
+ end
112
+ end
113
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "openapi_ruby"
4
+ require_relative "context_resolution"
4
5
  require "cgi"
5
6
  require "uri"
6
7
 
@@ -20,16 +21,30 @@ module OpenapiRuby
20
21
  end
21
22
 
22
23
  def api_path(template, &block)
24
+ guard_single_api_path!(template)
25
+
23
26
  context = OpenapiRuby::DSL::Context.new(template, schema_name: _openapi_schema_name)
24
27
  context.instance_eval(&block) if block
25
28
  self._openapi_contexts = _openapi_contexts + [context]
26
29
  OpenapiRuby::DSL::MetadataStore.register(context)
27
30
  context
28
31
  end
32
+
33
+ private def guard_single_api_path!(template)
34
+ return unless OpenapiRuby.configuration.single_api_path_per_class
35
+
36
+ existing = _openapi_contexts.first
37
+ return if existing.nil?
38
+
39
+ raise OpenapiRuby::MultipleApiPaths,
40
+ "#{name || self} already declares api_path #{existing.path_template.inspect}; " \
41
+ "declare #{template.inspect} in its own class."
42
+ end
29
43
  end
30
44
 
31
- def assert_api_response(method, expected_status, params: {}, headers: {}, body: nil, path_params: {}, &block)
32
- context = find_context_for(method, path_params)
45
+ def assert_api_response(method, expected_status, params: {}, headers: {}, body: nil, path_params: {},
46
+ api_path: nil, &block)
47
+ context = find_context_for(method, path_params, params, expected_status, api_path)
33
48
  raise OpenapiRuby::Error, "No api_path defined for #{method.upcase} in #{self.class}" unless context
34
49
 
35
50
  operation = context.operations[method.to_s]
@@ -123,18 +138,11 @@ module OpenapiRuby
123
138
 
124
139
  private
125
140
 
126
- def find_context_for(method, path_params)
127
- has_path_params = path_params.any?
128
-
129
- self.class._openapi_contexts.find do |ctx|
130
- next false unless ctx.operations.key?(method.to_s)
131
-
132
- if has_path_params
133
- ctx.path_template.include?("{")
134
- else
135
- !ctx.path_template.include?("{")
136
- end
137
- end
141
+ def find_context_for(method, path_params, params, expected_status, api_path)
142
+ OpenapiRuby::Adapters::ContextResolution.resolve(
143
+ self.class._openapi_contexts, method, path_params,
144
+ params: params, expected_status: expected_status, api_path: api_path, owner: self.class
145
+ )
138
146
  end
139
147
 
140
148
  def expand_path(template, params)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "openapi_ruby"
4
+ require_relative "context_resolution"
4
5
  require "cgi"
5
6
  require "uri"
6
7
 
@@ -21,8 +22,11 @@ module OpenapiRuby
21
22
  schema_name = metadata[:openapi_schema_name]
22
23
  context = DSL::Context.new(template, schema_name: schema_name)
23
24
  context.instance_eval(&block) if block
24
- metadata[:openapi_api_contexts] ||= []
25
- metadata[:openapi_api_contexts] << context
25
+ # Replace rather than push: RSpec copies parent metadata into child
26
+ # groups by reference, so mutating the array in place would leak this
27
+ # declaration back up to the parent and sideways to its siblings.
28
+ # Building a new one is what makes a nested describe an actual scope.
29
+ metadata[:openapi_api_contexts] = (metadata[:openapi_api_contexts] || []) + [context]
26
30
  DSL::MetadataStore.register(context)
27
31
  context
28
32
  end
@@ -123,9 +127,10 @@ module OpenapiRuby
123
127
  # Minitest-style assertion: looks up the api_path context, makes the
124
128
  # request, validates the response status + body, then yields to the
125
129
  # block for additional expectations.
126
- def assert_api_response(method, expected_status, params: {}, headers: {}, body: nil, path_params: {}, &block)
130
+ def assert_api_response(method, expected_status, params: {}, headers: {}, body: nil, path_params: {},
131
+ api_path: nil, &block)
127
132
  meta = ::RSpec.current_example.metadata
128
- context = find_api_context_for(meta, method, path_params)
133
+ context = find_api_context_for(meta, method, path_params, params, expected_status, api_path)
129
134
  raise OpenapiRuby::Error, "No api_path defined for #{method.upcase} in this example group" unless context
130
135
 
131
136
  operation = context.operations[method.to_s]
@@ -334,19 +339,14 @@ module OpenapiRuby
334
339
 
335
340
  private
336
341
 
337
- def find_api_context_for(metadata, method, path_params)
342
+ def find_api_context_for(metadata, method, path_params, params, expected_status, api_path)
338
343
  contexts = find_in_metadata(metadata, :openapi_api_contexts) || []
339
- has_path_params = path_params.any?
340
344
 
341
- contexts.find do |ctx|
342
- next false unless ctx.operations.key?(method.to_s)
343
-
344
- if has_path_params
345
- ctx.path_template.include?("{")
346
- else
347
- !ctx.path_template.include?("{")
348
- end
349
- end
345
+ OpenapiRuby::Adapters::ContextResolution.resolve(
346
+ contexts, method, path_params,
347
+ params: params, expected_status: expected_status, api_path: api_path,
348
+ owner: metadata[:full_description] || "this example group"
349
+ )
350
350
  end
351
351
 
352
352
  def expand_path(template, params)
@@ -18,6 +18,12 @@ module OpenapiRuby
18
18
  # Middleware (runtime validation)
19
19
  attr_accessor :request_validation, :response_validation, :coerce_params
20
20
 
21
+ # Style 2 only: require each test class to declare a single api_path.
22
+ # Off by default — resolution handles more than one path per class, so this
23
+ # is for suites that want the convention enforced anyway.
24
+ # See Adapters::ContextResolution for how a request is matched.
25
+ attr_accessor :single_api_path_per_class
26
+
21
27
  # Test DSL: validate that requests match the declared operation before sending.
22
28
  # Enabled by default; set to false to disable.
23
29
  attr_accessor :test_request_validation
@@ -51,6 +57,7 @@ module OpenapiRuby
51
57
  @response_validation = :disabled
52
58
  @coerce_params = true
53
59
  @test_request_validation = true
60
+ @single_api_path_per_class = false
54
61
  @schema_output_dir = "openapi"
55
62
  @schema_output_format = :yaml
56
63
  @ui_enabled = false
@@ -14,6 +14,13 @@ module OpenapiRuby
14
14
  end
15
15
  end
16
16
 
17
+ # Raised when a Style 2 request matches more than one declared api_path.
18
+ class AmbiguousApiPath < Error; end
19
+
20
+ # Raised when a class declares a second api_path and
21
+ # `config.single_api_path_per_class` is on.
22
+ class MultipleApiPaths < Error; end
23
+
17
24
  class SchemaValidationError < Error
18
25
  attr_reader :validation_errors
19
26
 
@@ -42,12 +42,15 @@ module OpenapiRuby
42
42
  end
43
43
  end
44
44
 
45
- # Prepended to every generated script. Loading the consumer's test files
46
- # must not run them — see AutorunSuppressor.
45
+ # Prepended to every generated script, before any consumer file is
46
+ # required. Loading the consumer's test files must neither run them
47
+ # (AutorunSuppressor) nor drag in a database (TestSchemaSuppressor).
47
48
  def suppress_autorun
48
49
  <<~RUBY.chomp
49
50
  require "openapi_ruby/generator/autorun_suppressor"
51
+ require "openapi_ruby/generator/test_schema_suppressor"
50
52
  OpenapiRuby::Generator::AutorunSuppressor.install!
53
+ OpenapiRuby::Generator::TestSchemaSuppressor.install!
51
54
  RUBY
52
55
  end
53
56
 
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiRuby
4
+ module Generator
5
+ # Schema generation never runs tests, so keeping the test database's schema
6
+ # current is wasted work — but `rails/test_help` does it unconditionally at
7
+ # require time (`rails/testing/maintain_test_schema` → `maintain_test_schema!`),
8
+ # which opens a database connection. That makes a database a hard
9
+ # requirement for generating a document that doesn't depend on one.
10
+ #
11
+ # `maintain_test_schema!` is a no-op when the setting is off, so turning it
12
+ # off for the generation subprocess drops the requirement.
13
+ #
14
+ # This only skips the schema *check*. A connection is still available if the
15
+ # consumer's declarations genuinely need one (say, an enum built from a
16
+ # query at load time) — such a suite needs a database either way.
17
+ module TestSchemaSuppressor
18
+ module_function
19
+
20
+ def install!
21
+ return unless defined?(::ActiveSupport) && ::ActiveSupport.respond_to?(:on_load)
22
+
23
+ # Registered as a load hook so it applies whenever the consumer's helper
24
+ # boots Rails, rather than depending on require order.
25
+ ::ActiveSupport.on_load(:active_record) do
26
+ OpenapiRuby::Generator::TestSchemaSuppressor.disable!
27
+ end
28
+ end
29
+
30
+ # Neutralises the methods rather than clearing
31
+ # `ActiveRecord.maintain_test_schema`. The flag is reapplied from app
32
+ # config by the `active_record.set_configs` initializer, which runs after
33
+ # any load hook we can register from here — so setting it is silently
34
+ # undone before `rails/test_help` reads it.
35
+ def disable!
36
+ ::ActiveRecord::Migration.singleton_class.prepend(MigrationSilencer)
37
+ end
38
+
39
+ module MigrationSilencer
40
+ # Called by rails/test_help at require time.
41
+ def maintain_test_schema!
42
+ nil
43
+ end
44
+
45
+ # Not called by Rails during boot, but a common addition to a
46
+ # hand-written test_helper. Same intent as the above: assert the test
47
+ # database matches the migrations. Nothing is loaded from the database
48
+ # to build the document, so there is nothing to verify.
49
+ def check_all_pending!
50
+ nil
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OpenapiRuby
4
- VERSION = "4.0.2"
4
+ VERSION = "4.1.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: openapi-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.0.2
4
+ version: 4.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Morten Hartvig
@@ -88,6 +88,7 @@ files:
88
88
  - lib/generators/openapi_ruby/install/templates/openapi_helper.rb.tt
89
89
  - lib/openapi-ruby.rb
90
90
  - lib/openapi_ruby.rb
91
+ - lib/openapi_ruby/adapters/context_resolution.rb
91
92
  - lib/openapi_ruby/adapters/minitest.rb
92
93
  - lib/openapi_ruby/adapters/rspec.rb
93
94
  - lib/openapi_ruby/components/base.rb
@@ -108,6 +109,7 @@ files:
108
109
  - lib/openapi_ruby/generator/autorun_suppressor.rb
109
110
  - lib/openapi_ruby/generator/rake_task_support.rb
110
111
  - lib/openapi_ruby/generator/schema_writer.rb
112
+ - lib/openapi_ruby/generator/test_schema_suppressor.rb
111
113
  - lib/openapi_ruby/middleware/coercion.rb
112
114
  - lib/openapi_ruby/middleware/error_handler.rb
113
115
  - lib/openapi_ruby/middleware/path_matcher.rb