openapi-ruby 4.0.3 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f4bf806084ec440728b07b1cb5105c2e2f8529852b143a087bb919111afd6498
4
- data.tar.gz: d73357f612fa69222d354a2f122a496c63bf8d8259ddb15bdfb38c898c7fb335
3
+ metadata.gz: 5f7b5b27e899e80be2470549b5a347b3e3cd9a3d3974f8d5de3842510741e252
4
+ data.tar.gz: d322e9f88d6164fe4f6f10203266efa88eb99a6f7b27cf0983376bc3587d2e77
5
5
  SHA512:
6
- metadata.gz: ea9f2ab82f0cdae01a6a578daa13f7274914a77b2476ba4ff7bbaf4cfd991ea685c608dcd71eac23bb2bc9ebf0ce35d0ac764b63a89bbb35add7e95c73a9d048
7
- data.tar.gz: b940f94f696df915e90eccc593ae3901b1bc63229b4158391da62ffddb7fef1932a788358c1bffe771054f3e7d5f68faae36ff549ebb2c1166c078ed8ee58c66
6
+ metadata.gz: 1a1948211044728807f9ff02fa7a0d81af64c4d52619bbcf43895481d8309e4c949c8e40d8daff4c897d694afc53ac02fd48194fa88203bc49d5d7f102200927
7
+ data.tar.gz: 55a9e333eff2da6db2a8032ad3777219b8df7957f3fc49b5dafc9fc3f0cf586d35836df8439b3b40dcb09e0c2ed56b7aff6fbe7ebc67b75264bf62c1cede3cc2
data/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  <h1 align="center">openapi_ruby</h1>
6
6
 
7
7
  <p align="center">
8
- A unified OpenAPI toolkit for Rails that combines test-driven spec generation, reusable schema components as Ruby classes, and runtime request/response validation middleware. Supports OpenAPI 3.0 and 3.1. Works with both RSpec and Minitest.
8
+ A unified OpenAPI toolkit for Rails, Hanami, and Rack that combines test-driven spec generation, reusable schema components as Ruby classes, and runtime request/response validation middleware. Supports OpenAPI 3.0 and 3.1. Works with both RSpec and Minitest.
9
9
  </p>
10
10
 
11
11
  Replaces [rswag](https://github.com/rswag/rswag), [rswag-schema-components](https://github.com/101skills-gmbh/rswag-schema-components), and [committee](https://github.com/interagent/committee) with a single gem.
@@ -14,6 +14,7 @@ Replaces [rswag](https://github.com/rswag/rswag), [rswag-schema-components](http
14
14
 
15
15
  - **OpenAPI 3.0 & 3.1** with JSON Schema 2020-12 (via [json_schemer](https://github.com/davishmcclurg/json_schemer))
16
16
  - **Test-framework agnostic** — works with RSpec and Minitest
17
+ - **Host-framework agnostic** — works on Rails, Hanami, Sinatra, and bare Rack
17
18
  - **Schema components** as Ruby classes with inheritance
18
19
  - **Runtime middleware** for request/response validation with deep type checking
19
20
  - **Strong params** derived from schema components
@@ -23,7 +24,7 @@ Replaces [rswag](https://github.com/rswag/rswag), [rswag-schema-components](http
23
24
  ## Requirements
24
25
 
25
26
  - Ruby >= 3.2
26
- - Rails >= 7.0
27
+ - Rails >= 7.0, Hanami >= 2.3, or any Rack app (see [Host Frameworks](#host-frameworks))
27
28
 
28
29
  ## Installation
29
30
 
@@ -47,6 +48,8 @@ This creates:
47
48
  - `openapi/` — output directory for generated specs
48
49
  - Engine mount in `config/routes.rb`
49
50
 
51
+ The install generator is Rails-only — see [Host Frameworks](#host-frameworks) for the equivalent setup elsewhere.
52
+
50
53
  ## Configuration
51
54
 
52
55
  ```ruby
@@ -497,21 +500,94 @@ require "rails/test_help"
497
500
  # ...other test-time setup...
498
501
  ```
499
502
 
500
- This is purely an optimization — generation is already correct and database-free without it.
503
+ This is purely an optimization — generation is already correct and database-free without it. Reach for it only if generation is slow enough to bother you.
501
504
 
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:
505
+ #### When guarding backfires
503
506
 
504
- ```bash
505
- PATTERN="test/integration/api/**/*_test.rb" rake openapi_ruby:generate
507
+ The guard skips a require, so anything that require defines is gone for the whole generation run. That is fine for setup your files only touch while *running*, and fatal for anything they touch while *loading* — a file that fails to load contributes no declarations, and generation fails outright.
508
+
509
+ Loading a spec/test file executes its class body, so these all break under a guard:
510
+
511
+ - `fixtures :all` — `fixtures` is undefined without `rails/test_help`
512
+ - `include Devise::Test::IntegrationHelpers` — needs `rspec/rails` (or `rails/test_help`) already loaded
513
+ - `it_behaves_like "..."` / `include_examples` at the top level — needs the shared examples your helper loaded
514
+
515
+ Two ways out, and they compose:
516
+
517
+ 1. **Narrow `PATTERN`** to just the files carrying `path` / `api_path` declarations, so the files with load-time dependencies are never loaded:
518
+
519
+ ```bash
520
+ PATTERN="test/integration/api/**/*_test.rb" rake openapi_ruby:generate
521
+ ```
522
+
523
+ 2. **Leave that helper unguarded.** A helper whose constants are referenced at load time across the suite is often not worth guarding — you would be trading a working generation run for a faster one. Guarding is optional per helper; guard `test/test_helper.rb` and leave `spec/rails_helper.rb` alone if that is what your suite needs.
524
+
525
+ Suites using FactoryBot rather than fixtures, and keeping helper includes inside `before` blocks, tend not to hit any of this.
526
+
527
+ ### How a request finds its api_path (Style 2)
528
+
529
+ Style 2 separates the `api_path` declaration from the request that exercises
530
+ it, so `assert_api_response` has to match the request back to a declaration. It
531
+ narrows the declared paths by, in order:
532
+
533
+ 1. the verb — only paths declaring it stay in
534
+ 2. the path params — a path needing `{project_id}` is out if none was supplied,
535
+ and a path is out if it doesn't use every key given in `path_params:`
536
+ 3. the expected status — `assert_api_response :put, 422` skips paths that don't
537
+ declare a 422 for that verb
538
+ 4. how many supplied keys the path can explain, as either one of its own path
539
+ params or a parameter declared on the operation
540
+
541
+ That resolves a collection path against a member path, nested resources, and
542
+ sibling paths distinguished by status. It cannot resolve paths that agree on all
543
+ four:
544
+
545
+ ```ruby
546
+ api_path "/timers/{id}" { put("Update") { response(200, "ok") } }
547
+ api_path "/timers/{id}/start" { put("Start") { response(200, "ok") } }
548
+ api_path "/timers/{id}/stop" { put("Stop") { response(200, "ok") } }
549
+ ```
550
+
551
+ Nothing at the call site tells those apart, so that raises
552
+ `OpenapiRuby::AmbiguousApiPath` naming the candidates rather than silently
553
+ picking the first and validating against the wrong response schema. Two ways to
554
+ resolve it. Name the path on the request:
555
+
556
+ ```ruby
557
+ assert_api_response :put, 200, path_params: {id: timer.id}, api_path: "/timers/{id}/start"
558
+ ```
559
+
560
+ Or, in RSpec, declare each path in its own example group — a nested `describe`
561
+ only sees paths declared at or above it:
562
+
563
+ ```ruby
564
+ describe "start" do
565
+ api_path("/timers/{id}/start") { put("Start") { response(200, "ok") } }
566
+
567
+ it { assert_api_response :put, 200, path_params: {id: timer.id} }
568
+ end
569
+
570
+ describe "stop" do
571
+ api_path("/timers/{id}/stop") { put("Stop") { response(200, "ok") } }
572
+
573
+ it { assert_api_response :put, 200, path_params: {id: timer.id} }
574
+ end
575
+ ```
576
+
577
+ To require one path per test class regardless, switch on:
578
+
579
+ ```ruby
580
+ config.single_api_path_per_class = true
506
581
  ```
507
582
 
508
- Suites using FactoryBot rather than fixtures don't hit this.
583
+ `api_path` then raises `OpenapiRuby::MultipleApiPaths` as soon as a class
584
+ declares a second path. Off by default.
509
585
 
510
586
  ### Migrating from RSpec to Minitest (or vice versa)
511
587
 
512
588
  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.
513
589
 
514
- Here the guards described above stop being optional: without them both test frameworks wire themselves into Rails' lazy-load hooks in the same process.
590
+ Here the guards described above carry more weight: without them both test frameworks wire themselves into Rails' lazy-load hooks in the same process. Guard what you can — but the "When guarding backfires" rules still apply, so a helper your suite leans on at load time stays unguarded even in hybrid mode.
515
591
 
516
592
  ```ruby
517
593
  # test/test_helper.rb
@@ -531,7 +607,198 @@ end
531
607
 
532
608
  `OpenapiRuby.schema_generating?` returns `true` when the rake task launched the current process (it sets `OPENAPI_RUBY_GENERATING=true` in the subprocess). With the guards in place, neither test framework boots its full Rails integration during generation — only the DSL needs to be live for `api_path` / `path` to register.
533
609
 
534
- Once the migration completes and only one test framework remains, the rake task auto-detects that framework. The guard is then no longer *required* but it's still worth keeping for the reasons in "Making generation cheaper" above.
610
+ Once the migration completes and only one test framework remains, the rake task auto-detects that framework, and the guard goes back to being a pure optimization.
611
+
612
+ ## Host Frameworks
613
+
614
+ Rails picks up its wiring from the engine. Every other host makes the same calls itself — the gem only ever needs a Rack middleware stack, a way to mount a Rack app, and rack-test.
615
+
616
+ | | Rails | Hanami | Sinatra / Roda / bare Rack |
617
+ |---|---|---|---|
618
+ | Test DSL + generation | ✅ | ✅ | ✅ |
619
+ | Runtime validation middleware | automatic | one call | one call |
620
+ | Schema + Swagger UI endpoints | `mount OpenapiRuby::Engine` | `mount OpenapiRuby::RackApp` | `map`/`run OpenapiRuby::RackApp` |
621
+ | `openapi_ruby:install` / `:component` generators | ✅ | — | — |
622
+ | `openapi_permit` strong params | ✅ | — | — |
623
+
624
+ Versions covered by CI: Rails 7.0–8.0, Hanami 2.3 and 3.0, Sinatra 3.2 and 4.2. Working reference apps live in [`spec/dummy`](spec/dummy), [`spec/hanami_dummy`](spec/hanami_dummy), and [`spec/sinatra_dummy`](spec/sinatra_dummy).
625
+
626
+ ### Hanami
627
+
628
+ **1. Configure.** Anywhere that loads before your app class — `config/openapi_ruby.rb` is a natural home:
629
+
630
+ ```ruby
631
+ require "openapi_ruby/hanami"
632
+
633
+ OpenapiRuby.configure do |config|
634
+ config.schemas = {
635
+ public_api: {
636
+ info: { title: "My API", version: "v1" },
637
+ servers: [{ url: "/api/v1" }],
638
+ prefix: "/api/v1" # scopes the validation middleware to the API
639
+ }
640
+ }
641
+ end
642
+ ```
643
+
644
+ Components default to `config/api_components/` on Hanami instead of `app/api_components/`. Zeitwerk owns everything under `app/` and expects `app/api_components/schemas/user.rb` to define `MyApp::ApiComponents::Schemas::User`, while the component loader requires the file directly — which loads fine in tests and fails on eager load in production. Keeping components outside the autoload roots avoids the clash; openapi_ruby warns if `component_paths` points inside `app/`.
645
+
646
+ **2. Install the middleware** (only needed for runtime validation) in `config/app.rb`:
647
+
648
+ ```ruby
649
+ require "hanami"
650
+ require_relative "openapi_ruby"
651
+
652
+ module MyApp
653
+ class App < Hanami::App
654
+ # Declared before :body_parser so the validation middleware reads and
655
+ # rewinds the request body first.
656
+ OpenapiRuby::Hanami.install_middleware!(config)
657
+
658
+ config.middleware.use :body_parser, :json
659
+ end
660
+ end
661
+ ```
662
+
663
+ **3. Mount the docs endpoints** in `config/routes.rb`:
664
+
665
+ ```ruby
666
+ module MyApp
667
+ class Routes < Hanami::Routes
668
+ mount OpenapiRuby::RackApp, at: "/api-docs"
669
+ end
670
+ end
671
+ ```
672
+
673
+ For request specs, `require "openapi_ruby/rspec"` wires rack-test into `type: :openapi` example groups and points it at `Hanami.app`:
674
+
675
+ ```ruby
676
+ # spec/spec_helper.rb
677
+ ENV["HANAMI_ENV"] ||= "test"
678
+
679
+ require "hanami/prepare"
680
+ require "openapi_ruby/rspec"
681
+ ```
682
+
683
+ Define `let(:app)` in a group to drive a slice instead of the whole app.
684
+
685
+ ### Sinatra, Roda, and bare Rack
686
+
687
+ Nothing here is Sinatra-specific — it is the same three steps against a plain Rack app.
688
+
689
+ **1. Configure**, and say where components live. There is no autoload convention to infer one from:
690
+
691
+ ```ruby
692
+ # config/openapi_ruby.rb
693
+ require "openapi_ruby"
694
+
695
+ OpenapiRuby.configure do |config|
696
+ config.schemas = {
697
+ public_api: {
698
+ info: { title: "My API", version: "v1" },
699
+ servers: [{ url: "/api/v1" }],
700
+ prefix: "/api/v1"
701
+ }
702
+ }
703
+
704
+ config.component_paths = ["api_components"]
705
+ end
706
+ ```
707
+
708
+ **2. Install the middleware** onto the app's stack. `Installer#install!` takes anything that responds to `use`, which a `Sinatra::Base` subclass does:
709
+
710
+ ```ruby
711
+ class App < Sinatra::Base
712
+ OpenapiRuby::Middleware::Installer.install!(self, root: __dir__)
713
+
714
+ # ... routes
715
+ end
716
+ ```
717
+
718
+ For Roda or bare Rack, hand it the builder instead — `OpenapiRuby::Middleware::Installer.install!(builder, root: __dir__)` inside `Rack::Builder.new { ... }`.
719
+
720
+ **3. Mount the docs endpoints** in `config.ru`:
721
+
722
+ ```ruby
723
+ require_relative "app"
724
+
725
+ map "/api-docs" do
726
+ run OpenapiRuby::RackApp
727
+ end
728
+
729
+ map "/" do
730
+ run App
731
+ end
732
+ ```
733
+
734
+ For request specs, `require "openapi_ruby/rspec"` includes rack-test for you; naming the app is the only wiring left, since no Rack host has a convention for which app is under test:
735
+
736
+ ```ruby
737
+ # spec/spec_helper.rb
738
+ ENV["APP_ENV"] ||= "test"
739
+
740
+ require_relative "../app"
741
+ require "openapi_ruby/rspec"
742
+
743
+ module AppUnderTest
744
+ def app
745
+ App
746
+ end
747
+ end
748
+
749
+ RSpec.configure do |config|
750
+ config.include AppUnderTest, type: :openapi
751
+ end
752
+ ```
753
+
754
+ Minitest is the same shape — including the DSL brings rack-test with it, and the class defines `app`:
755
+
756
+ ```ruby
757
+ class ApiTest < Minitest::Test
758
+ include OpenapiRuby::Adapters::Minitest::DSL
759
+
760
+ def app
761
+ App
762
+ end
763
+ end
764
+ ```
765
+
766
+ > **Getting 403 "Host not permitted"?** Sinatra only relaxes its host authorization outside `development`, and rack-test sends `Host: example.org`. Set `APP_ENV=test` (or `RACK_ENV=test`) in your test helper — the snippet above does.
767
+
768
+ ### Specs and generation on every host
769
+
770
+ Specs are written identically regardless of host, in either DSL style:
771
+
772
+ ```ruby
773
+ RSpec.describe "Posts API", type: :openapi do
774
+ openapi_schema :public_api
775
+
776
+ api_path "/posts" do
777
+ get "List posts" do
778
+ tags "Posts"
779
+ produces "application/json"
780
+
781
+ response 200, "returns posts" do
782
+ schema type: :array, items: { "$ref" => "#/components/schemas/Post" }
783
+ end
784
+ end
785
+ end
786
+
787
+ it "returns all posts" do
788
+ assert_api_response :get, 200 do
789
+ expect(parsed_body.length).to eq(2)
790
+ end
791
+ end
792
+ end
793
+ ```
794
+
795
+ The Rails engine loads the rake task on its own. Elsewhere, add it to your Rakefile:
796
+
797
+ ```ruby
798
+ require "openapi_ruby/rake_tasks"
799
+ ```
800
+
801
+ `bundle exec rake openapi_ruby:generate` then behaves as it does on Rails. It detects the host and sets the environment variable that host reads — `RAILS_ENV`, `HANAMI_ENV`, or `APP_ENV`/`RACK_ENV` — for the generation subprocess.
535
802
 
536
803
  ## Runtime Middleware
537
804
 
@@ -573,7 +840,7 @@ Mount the engine to expose the schema endpoints:
573
840
  mount OpenapiRuby::Engine => "/api-docs"
574
841
  ```
575
842
 
576
- Schema files are served at `/api-docs/schemas/:name`.
843
+ Schema files are served at `/api-docs/schemas/:name`. On any other host, mount `OpenapiRuby::RackApp` instead — see [Host Frameworks](#host-frameworks).
577
844
 
578
845
  To also serve the interactive Swagger UI at the mount root, opt in:
579
846
 
data/Rakefile CHANGED
@@ -5,4 +5,36 @@ require "rspec/core/rake_task"
5
5
 
6
6
  RSpec::Core::RakeTask.new(:spec)
7
7
 
8
+ # Each alternate host's dummy app runs in its own bundle, so Rails and the
9
+ # other framework never have to boot in one process.
10
+ #
11
+ # Unbundled: Bundler exports BUNDLE_LOCKFILE next to BUNDLE_GEMFILE, and an
12
+ # inherited one makes the child write its resolution into this bundle's
13
+ # Gemfile.lock.
14
+ def run_dummy_app_specs(gemfile, dir, command)
15
+ gemfile_path = File.expand_path("gemfiles/#{gemfile}", __dir__)
16
+
17
+ Bundler.with_unbundled_env do
18
+ Dir.chdir(dir) do
19
+ sh({"BUNDLE_GEMFILE" => gemfile_path}, command)
20
+ end
21
+ end
22
+ end
23
+
24
+ namespace :spec do
25
+ desc "Run the Hanami dummy app's specs in the Rails-free bundle"
26
+ task :hanami do
27
+ run_dummy_app_specs("hanami.gemfile", "spec/hanami_dummy", "bundle exec rspec")
28
+ end
29
+
30
+ desc "Run the Sinatra dummy app's specs and tests in the Rails-free bundle"
31
+ task :sinatra do
32
+ run_dummy_app_specs("sinatra.gemfile", "spec/sinatra_dummy", "bundle exec rspec")
33
+ run_dummy_app_specs("sinatra.gemfile", "spec/sinatra_dummy", "bundle exec ruby -Itest test/posts_test.rb")
34
+ end
35
+
36
+ desc "Run every host's dummy app suite"
37
+ task hosts: %i[hanami sinatra]
38
+ end
39
+
8
40
  task default: :spec
@@ -3,56 +3,15 @@
3
3
  module OpenapiRuby
4
4
  class SchemasController < ActionController::API
5
5
  def show
6
- schema_name = params[:id]
7
- config = OpenapiRuby.configuration
6
+ document = Serving.schema_document(params[:id], request: request)
7
+ return head :not_found unless document
8
8
 
9
- schema_config = config.schemas[schema_name.to_sym]
10
- return head :not_found unless schema_config
11
-
12
- file_path = schema_file_path(schema_name)
13
- return head :not_found unless file_path && File.exist?(file_path)
14
-
15
- content = File.read(file_path)
16
-
17
- # Apply filter if configured
18
- if schema_config[:openapi_filter]
19
- doc = parse_content(file_path, content)
20
- schema_config[:openapi_filter].call(doc, request)
21
- content = serialize_doc(file_path, doc)
22
- end
23
-
24
- content_type = file_path.end_with?(".json") ? "application/json" : "application/x-yaml"
9
+ content, content_type = document
25
10
  render plain: content, content_type: content_type
26
11
  end
27
12
 
28
13
  def index
29
- schemas = OpenapiRuby.configuration.schemas.keys.map(&:to_s)
30
- render json: {schemas: schemas}
31
- end
32
-
33
- private
34
-
35
- def schema_file_path(schema_name)
36
- config = OpenapiRuby.configuration
37
- ext = (config.schema_output_format == :json) ? "json" : "yaml"
38
- path = Rails.root.join(config.schema_output_dir, "#{schema_name}.#{ext}")
39
- path.to_s
40
- end
41
-
42
- def parse_content(file_path, content)
43
- if file_path.end_with?(".json")
44
- JSON.parse(content)
45
- else
46
- YAML.safe_load(content, permitted_classes: [Date, Time])
47
- end
48
- end
49
-
50
- def serialize_doc(file_path, doc)
51
- if file_path.end_with?(".json")
52
- JSON.pretty_generate(doc)
53
- else
54
- doc.to_yaml
55
- end
14
+ render json: {schemas: Serving.schema_names}
56
15
  end
57
16
  end
58
17
  end
@@ -5,83 +5,26 @@ module OpenapiRuby
5
5
  layout false
6
6
 
7
7
  def index
8
- config = OpenapiRuby.configuration
9
- @schemas = config.schemas
10
- @ui_config = config.ui_config
11
-
12
- render html: swagger_ui_html.html_safe
8
+ html = Serving.swagger_ui_html(
9
+ schema_urls: schema_urls,
10
+ ui_config: OpenapiRuby.configuration.ui_config
11
+ )
12
+ render html: html.html_safe
13
13
  end
14
14
 
15
15
  def oauth2_redirect
16
- file = File.join(OpenapiRuby::Engine.root, "app", "views", "openapi_ruby", "oauth2_redirect.html")
17
- render file: file, layout: false, content_type: "text/html"
16
+ render file: Serving.oauth2_redirect_file, layout: false, content_type: "text/html"
18
17
  end
19
18
 
20
19
  private
21
20
 
22
- def schema_format
23
- (OpenapiRuby.configuration.schema_output_format == :json) ? :json : :yaml
24
- end
25
-
26
- def swagger_ui_html
27
- <<~HTML
28
- <!DOCTYPE html>
29
- <html lang="en">
30
- <head>
31
- <meta charset="UTF-8">
32
- <title>#{@ui_config[:title] || "API Documentation"}</title>
33
- <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
34
- <style>
35
- html { box-sizing: border-box; overflow-y: scroll; }
36
- *, *:before, *:after { box-sizing: inherit; }
37
- body { margin: 0; background: #fafafa; }
38
- </style>
39
- </head>
40
- <body>
41
- <div id="swagger-ui"></div>
42
- <script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
43
- <script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-standalone-preset.js"></script>
44
- <script>
45
- SwaggerUIBundle({
46
- #{schema_urls_js},
47
- dom_id: '#swagger-ui',
48
- deepLinking: true,
49
- presets: [
50
- SwaggerUIBundle.presets.apis,
51
- SwaggerUIStandalonePreset
52
- ],
53
- plugins: [
54
- SwaggerUIBundle.plugins.DownloadUrl
55
- ],
56
- layout: "#{(@schemas.size > 1) ? "StandaloneLayout" : "BaseLayout"}",
57
- #{ui_config_js}
58
- });
59
- </script>
60
- </body>
61
- </html>
62
- HTML
63
- end
64
-
65
- def schema_urls_js
66
- fmt = schema_format
67
- if @schemas.size > 1
68
- urls = @schemas.map { |name, schema_config|
69
- title = schema_config.dig(:info, :title) || name.to_s
70
- url = openapi_ruby.schema_path(name.to_s, format: fmt)
71
- {url: url, name: title}
21
+ def schema_urls
22
+ OpenapiRuby.configuration.schemas.map do |name, schema_config|
23
+ {
24
+ url: openapi_ruby.schema_path(name.to_s, format: Serving.schema_format),
25
+ name: schema_config.dig(:info, :title) || name.to_s
72
26
  }
73
- "urls: #{urls.to_json}"
74
- else
75
- name = @schemas.keys.first.to_s
76
- url = openapi_ruby.schema_path(name, format: fmt)
77
- "url: \"#{url}\""
78
27
  end
79
28
  end
80
-
81
- def ui_config_js
82
- @ui_config.except(:title).map { |k, v|
83
- "#{k}: #{v.to_json}"
84
- }.join(",\n ")
85
- end
86
29
  end
87
30
  end
@@ -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