openapi-ruby 4.1.0 → 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.
@@ -192,10 +192,10 @@ module OpenapiRuby
192
192
  raise "Request validation failed:\n#{req_errors.join("\n")}" unless req_errors.empty?
193
193
  end
194
194
 
195
- send(method, path, **request_args)
195
+ openapi_transport.dispatch(method, path, **request_args)
196
196
 
197
- unless response.status == expected_status
198
- raise "Expected status #{expected_status}, got #{response.status}\nResponse body: #{response.body}"
197
+ unless openapi_response.status == expected_status
198
+ raise "Expected status #{expected_status}, got #{openapi_response.status}\nResponse body: #{openapi_response.body}"
199
199
  end
200
200
 
201
201
  if response_ctx.schema_definition
@@ -204,11 +204,11 @@ module OpenapiRuby
204
204
  )
205
205
  errors = validator.validate(
206
206
  response_body: parsed_response_body,
207
- status_code: response.status,
207
+ status_code: openapi_response.status,
208
208
  response_context: response_ctx
209
209
  )
210
210
  unless errors.empty?
211
- raise "Response body validation failed:\n#{errors.join("\n")}\nResponse body: #{response.body}"
211
+ raise "Response body validation failed:\n#{errors.join("\n")}\nResponse body: #{openapi_response.body}"
212
212
  end
213
213
  end
214
214
 
@@ -219,6 +219,17 @@ module OpenapiRuby
219
219
  parsed_response_body
220
220
  end
221
221
 
222
+ # The seam between the DSL and the host's request API. Public so specs
223
+ # that drive requests themselves (rate limiting, pagination loops) can
224
+ # reach the same dispatcher and response the assertions use.
225
+ def openapi_transport
226
+ @openapi_transport ||= Testing::Transport.for(self)
227
+ end
228
+
229
+ def openapi_response
230
+ openapi_transport.response
231
+ end
232
+
222
233
  # submit_openapi_request is public so specs can call it directly
223
234
  # (e.g., for rate limiting tests that need multiple requests)
224
235
  def submit_openapi_request(metadata)
@@ -308,19 +319,19 @@ module OpenapiRuby
308
319
  raise "Request validation failed:\n#{req_errors.join("\n")}" unless req_errors.empty?
309
320
  end
310
321
 
311
- send(method.to_sym, path, **request_args)
322
+ openapi_transport.dispatch(method, path, **request_args)
312
323
  end
313
324
 
314
325
  def assert_openapi_response(metadata)
315
326
  response_ctx = find_in_metadata(metadata, :openapi_response)
316
327
 
317
328
  expected_status = response_ctx.status_code.to_i
318
- actual_status = response.status
329
+ actual_status = openapi_response.status
319
330
 
320
331
  unless actual_status == expected_status
321
332
  raise "Response validation failed:\n" \
322
333
  "Expected status #{expected_status}, got #{actual_status}\n" \
323
- "Response body: #{response.body}"
334
+ "Response body: #{openapi_response.body}"
324
335
  end
325
336
 
326
337
  if response_ctx.schema_definition
@@ -328,11 +339,11 @@ module OpenapiRuby
328
339
  validator = Testing::ResponseValidator.new(OpenapiRuby::Adapters::RSpec.validation_document_for(schema_name))
329
340
  errors = validator.validate(
330
341
  response_body: parsed_response_body,
331
- status_code: response.status,
342
+ status_code: openapi_response.status,
332
343
  response_context: response_ctx
333
344
  )
334
345
  unless errors.empty?
335
- raise "Response body validation failed:\n#{errors.join("\n")}\nResponse body: #{response.body}"
346
+ raise "Response body validation failed:\n#{errors.join("\n")}\nResponse body: #{openapi_response.body}"
336
347
  end
337
348
  end
338
349
  end
@@ -438,10 +449,10 @@ module OpenapiRuby
438
449
  end
439
450
 
440
451
  def parsed_response_body
441
- return nil if response.body.empty?
442
- JSON.parse(response.body)
452
+ return nil if openapi_response.body.empty?
453
+ JSON.parse(openapi_response.body)
443
454
  rescue JSON::ParserError
444
- response.body
455
+ openapi_response.body
445
456
  end
446
457
  end
447
458
 
@@ -468,6 +479,20 @@ module OpenapiRuby
468
479
  end
469
480
  end
470
481
 
482
+ # Every host but Rails drives requests through rack-test. Hanami also
483
+ # gets a default `app`; elsewhere (Sinatra, Roda, bare Rack) there is no
484
+ # convention for which app is under test, so the suite defines it.
485
+ def self.install_rack_test!(config, app_module: nil)
486
+ require "rack/test"
487
+
488
+ config.include ::Rack::Test::Methods, type: :openapi
489
+ config.include app_module, type: :openapi if app_module
490
+ rescue LoadError
491
+ # No rack-test in the bundle. Testing::Transport raises with setup
492
+ # instructions if a spec then tries to issue a request.
493
+ nil
494
+ end
495
+
471
496
  def self.install!
472
497
  ::RSpec.configure do |config|
473
498
  config.extend ExampleGroupHelpers, type: :openapi
@@ -475,6 +500,11 @@ module OpenapiRuby
475
500
 
476
501
  if defined?(::RSpec::Rails)
477
502
  config.include ::RSpec::Rails::RequestExampleGroup, type: :openapi
503
+ elsif OpenapiRuby.hanami_host?
504
+ require "openapi_ruby/hanami"
505
+ OpenapiRuby::Hanami.install_rspec!(config)
506
+ else
507
+ install_rack_test!(config)
478
508
  end
479
509
 
480
510
  # Schema writing is handled by the rake task (openapi_ruby:generate),
@@ -11,6 +11,7 @@ module OpenapiRuby
11
11
  end
12
12
 
13
13
  def load!
14
+ warn_about_autoloaded_paths!
14
15
  define_namespace_modules!
15
16
  load_component_files!
16
17
  @@loaded = true # rubocop:disable Style/ClassVars
@@ -60,6 +61,20 @@ module OpenapiRuby
60
61
 
61
62
  private
62
63
 
64
+ # A component under Hanami's app/ directory loads fine here (we require
65
+ # the file directly) but Zeitwerk expects that file to define a deeper,
66
+ # app-namespaced constant — so the mismatch only surfaces on eager load
67
+ # in production.
68
+ def warn_about_autoloaded_paths!
69
+ return unless OpenapiRuby.hanami_host?
70
+
71
+ offending = @paths.select { |path| path.to_s.match?(%r{(\A|/)app/}) }
72
+ return if offending.empty?
73
+
74
+ warn "[openapi_ruby] component_paths #{offending.inspect} sit under Hanami's autoloaded " \
75
+ "app/ directory. Move them outside it (e.g. config/api_components) to avoid Zeitwerk conflicts."
76
+ end
77
+
63
78
  def define_namespace_modules!
64
79
  @paths.each do |path|
65
80
  expanded = File.expand_path(path)
@@ -48,9 +48,21 @@ module OpenapiRuby
48
48
  # UI (optional)
49
49
  attr_accessor :ui_enabled, :ui_config
50
50
 
51
+ RAILS_COMPONENT_PATHS = ["app/api_components"].freeze
52
+
53
+ # Hanami's Zeitwerk owns every constant under app/ and expects
54
+ # app/api_components/schemas/user.rb to define a deeper constant than
55
+ # Components::Loader's plain require does, so the Hanami default keeps
56
+ # components outside its autoload roots.
57
+ HANAMI_COMPONENT_PATHS = ["config/api_components"].freeze
58
+
59
+ def self.default_component_paths
60
+ (OpenapiRuby.hanami_host? ? HANAMI_COMPONENT_PATHS : RAILS_COMPONENT_PATHS).dup
61
+ end
62
+
51
63
  def initialize
52
64
  @schemas = {}
53
- @component_paths = ["app/api_components"]
65
+ @component_paths = self.class.default_component_paths
54
66
  @component_scope_paths = {}
55
67
  @camelize_keys = true
56
68
  @request_validation = :disabled
@@ -5,44 +5,7 @@ module OpenapiRuby
5
5
  isolate_namespace OpenapiRuby
6
6
 
7
7
  initializer "openapi_ruby.middleware" do |app|
8
- config = OpenapiRuby.configuration
9
-
10
- next if ENV["OPENAPI_RUBY_GENERATING"]
11
-
12
- if config.request_validation != :disabled || config.response_validation != :disabled
13
- config.schemas.each do |name, schema_config|
14
- schema_path = resolve_schema_path(config, name)
15
- next unless schema_path && File.exist?(schema_path)
16
-
17
- resolver = Middleware::SchemaResolver.new(
18
- spec_path: schema_path,
19
- strict_reference_validation: config.strict_reference_validation
20
- )
21
-
22
- prefix = schema_config[:prefix]
23
-
24
- if config.request_validation != :disabled
25
- app.middleware.use Middleware::RequestValidation,
26
- schema_resolver: resolver,
27
- mode: config.request_validation,
28
- prefix: prefix
29
- end
30
-
31
- if config.response_validation != :disabled
32
- app.middleware.use Middleware::ResponseValidation,
33
- schema_resolver: resolver,
34
- mode: config.response_validation,
35
- prefix: prefix
36
- end
37
- end
38
- end
39
- end
40
-
41
- private
42
-
43
- def resolve_schema_path(config, schema_name)
44
- ext = (config.schema_output_format == :json) ? "json" : "yaml"
45
- Rails.root.join(config.schema_output_dir, "#{schema_name}.#{ext}").to_s
8
+ Middleware::Installer.install!(app.middleware)
46
9
  end
47
10
  end
48
11
  end
@@ -24,6 +24,41 @@ module OpenapiRuby
24
24
  end
25
25
  end
26
26
 
27
+ # Which framework hosts the app being generated for. Detected from the
28
+ # filesystem as well as loaded constants: the rake process has not booted
29
+ # the app yet. Hanami keeps its app class in config/app.rb where Rails
30
+ # uses config/application.rb; a config.ru with neither is a Rack app
31
+ # (Sinatra, Roda, bare Rack).
32
+ def detect_host
33
+ # Files describe the app being generated for; loaded constants only say
34
+ # what the Rakefile happened to require, which in a mixed bundle can be
35
+ # either framework.
36
+ return "rails" if File.exist?("config/application.rb")
37
+ return "hanami" if File.exist?("config/app.rb")
38
+ return "rack" if File.exist?("config.ru")
39
+
40
+ OpenapiRuby.host.to_s
41
+ end
42
+
43
+ # Environment variables the host reads to decide it is running under test.
44
+ # Sinatra takes APP_ENV first and falls back to RACK_ENV; other Rack apps
45
+ # read one or the other, so a Rack host gets both.
46
+ HOST_ENV_VARS = {
47
+ "rails" => ["RAILS_ENV"],
48
+ "hanami" => ["HANAMI_ENV"],
49
+ "rack" => ["APP_ENV", "RACK_ENV"]
50
+ }.freeze
51
+
52
+ # Env for the generation subprocess: the host's own environment variables
53
+ # plus the flag that tells the gem it is generating rather than serving.
54
+ def subprocess_env(host = detect_host)
55
+ env = HOST_ENV_VARS.fetch(host, HOST_ENV_VARS["rails"]).to_h do |var|
56
+ [var, ENV.fetch(var, "test")]
57
+ end
58
+
59
+ env.merge("OPENAPI_RUBY_GENERATING" => "true")
60
+ end
61
+
27
62
  def default_pattern_for(framework)
28
63
  case framework
29
64
  when "rspec" then "spec/**/*_spec.rb"
@@ -76,13 +111,20 @@ module OpenapiRuby
76
111
 
77
112
  # Loads both adapters and both file globs in one process. Useful
78
113
  # during a phased RSpec → Minitest migration where the suite
79
- # holds both DSL styles. Consumers should guard
114
+ # holds both DSL styles. Consumers can guard
80
115
  # `require "rails/test_help"` and `require "rspec/rails"` in
81
116
  # their test helpers with `unless OpenapiRuby.schema_generating?`
82
117
  # so the two test frameworks don't both register Rails lazy
83
118
  # hooks in the same process — only the DSL needs to be live for
84
119
  # schema generation.
85
120
  #
121
+ # Guarding is per-helper and optional: a suite that references
122
+ # what the guarded require defines at *load* time (shared
123
+ # examples, `fixtures :all`, `include Devise::Test::...` in a
124
+ # class body) fails to load those files at all, which is worse
125
+ # than the hook conflict. Such a helper stays unguarded and the
126
+ # pattern narrows instead.
127
+ #
86
128
  # Each glob runs with its own framework's directory at the head
87
129
  # of $LOAD_PATH so the typical `require "openapi_helper"` /
88
130
  # `require "rails_helper"` / `require "test_helper"` resolves
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openapi_ruby"
4
+
5
+ module OpenapiRuby
6
+ # Hanami 2 integration. Rails receives this wiring from the engine's
7
+ # initializer and from rspec-rails; Hanami has no engines, so the host app
8
+ # makes the calls itself.
9
+ #
10
+ # ::Hanami is spelled with leading colons throughout — inside this namespace
11
+ # a bare `Hanami` would resolve to OpenapiRuby::Hanami.
12
+ module Hanami
13
+ # Included into :openapi example groups so rack-test knows which app to
14
+ # drive. A `let(:app)` in the suite takes precedence over this.
15
+ module RackTestApp
16
+ def app
17
+ ::Hanami.app
18
+ end
19
+ end
20
+
21
+ module_function
22
+
23
+ # Mounts the runtime validation middleware. In config/app.rb, after
24
+ # requiring the file that holds your OpenapiRuby.configure block:
25
+ #
26
+ # module MyApp
27
+ # class App < Hanami::App
28
+ # OpenapiRuby::Hanami.install_middleware!(config)
29
+ # end
30
+ # end
31
+ #
32
+ # Takes the app config (preferred — it carries the root) or a bare
33
+ # middleware stack.
34
+ def install_middleware!(config, root: nil)
35
+ stack = config.respond_to?(:middleware) ? config.middleware : config
36
+ root ||= config_root(config) || OpenapiRuby.app_root
37
+
38
+ Middleware::Installer.install!(stack, root: root)
39
+ end
40
+
41
+ # Wires rack-test into :openapi example groups. Called for you by
42
+ # `require "openapi_ruby/rspec"`; exposed for suites that configure RSpec
43
+ # by hand.
44
+ def install_rspec!(rspec_config = nil)
45
+ if rspec_config
46
+ Adapters::RSpec.install_rack_test!(rspec_config, app_module: RackTestApp)
47
+ else
48
+ ::RSpec.configure { |config| Adapters::RSpec.install_rack_test!(config, app_module: RackTestApp) }
49
+ end
50
+ end
51
+
52
+ def config_root(config)
53
+ root = config.respond_to?(:root) ? config.root : nil
54
+ root unless root.to_s.empty?
55
+ end
56
+
57
+ # Requiring this file declares the host, so the component default no longer
58
+ # depends on ::Hanami being loaded before the configuration object was
59
+ # built. Only a pristine default is replaced.
60
+ def apply_component_path_default!
61
+ config = OpenapiRuby.configuration
62
+ return unless config.component_paths == Configuration::RAILS_COMPONENT_PATHS
63
+
64
+ config.component_paths = Configuration::HANAMI_COMPONENT_PATHS.dup
65
+ end
66
+ end
67
+ end
68
+
69
+ OpenapiRuby::Hanami.apply_component_path_default!
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiRuby
4
+ class << self
5
+ # Which framework the gem is running inside. Rails wins a tie: an app with
6
+ # both constants loaded is a Rails app that happens to have another
7
+ # framework's gems in the bundle. :rack covers Sinatra, Roda, and bare Rack
8
+ # — anything whose only shared surface is the Rack SPEC.
9
+ def host
10
+ if defined?(::Rails)
11
+ :rails
12
+ elsif defined?(::Hanami)
13
+ :hanami
14
+ else
15
+ :rack
16
+ end
17
+ end
18
+
19
+ def rails_host?
20
+ host == :rails
21
+ end
22
+
23
+ def hanami_host?
24
+ host == :hanami
25
+ end
26
+
27
+ def rack_host?
28
+ host == :rack
29
+ end
30
+
31
+ # Application root, used to resolve the configured (relative)
32
+ # schema_output_dir. Falls back to the working directory: a bare Rack app
33
+ # has no root of its own, and the generation subprocess loads test files
34
+ # without booting an app at all.
35
+ def app_root
36
+ root = if defined?(::Rails) && ::Rails.respond_to?(:root) && ::Rails.root
37
+ ::Rails.root
38
+ elsif hanami_booted?
39
+ ::Hanami.app.root
40
+ else
41
+ Dir.pwd
42
+ end
43
+
44
+ root.to_s
45
+ end
46
+
47
+ private
48
+
49
+ def hanami_booted?
50
+ defined?(::Hanami) && ::Hanami.respond_to?(:app?) && ::Hanami.app?
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiRuby
4
+ module Middleware
5
+ # Installs the runtime validation middleware onto a Rack stack. Anything
6
+ # responding to #use works — Rails' `app.middleware` and Hanami's
7
+ # `config.middleware` share that much API, so no host branching is needed.
8
+ module Installer
9
+ module_function
10
+
11
+ def install!(stack, root: OpenapiRuby.app_root)
12
+ config = OpenapiRuby.configuration
13
+
14
+ return if ENV["OPENAPI_RUBY_GENERATING"]
15
+ return if config.request_validation == :disabled && config.response_validation == :disabled
16
+
17
+ config.schemas.each do |name, schema_config|
18
+ schema_path = resolve_schema_path(config, name, root)
19
+ next unless schema_path && File.exist?(schema_path)
20
+
21
+ resolver = SchemaResolver.new(
22
+ spec_path: schema_path,
23
+ strict_reference_validation: config.strict_reference_validation
24
+ )
25
+
26
+ prefix = schema_config[:prefix]
27
+
28
+ if config.request_validation != :disabled
29
+ stack.use RequestValidation,
30
+ schema_resolver: resolver,
31
+ mode: config.request_validation,
32
+ prefix: prefix
33
+ end
34
+
35
+ if config.response_validation != :disabled
36
+ stack.use ResponseValidation,
37
+ schema_resolver: resolver,
38
+ mode: config.response_validation,
39
+ prefix: prefix
40
+ end
41
+ end
42
+ end
43
+
44
+ def resolve_schema_path(config, schema_name, root)
45
+ ext = (config.schema_output_format == :json) ? "json" : "yaml"
46
+ File.join(root.to_s, config.schema_output_dir, "#{schema_name}.#{ext}")
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack"
4
+
5
+ module OpenapiRuby
6
+ # Serves the generated schema documents and the Swagger UI as a plain Rack
7
+ # app, for hosts with no engine to mount:
8
+ #
9
+ # # Hanami — config/routes.rb
10
+ # mount OpenapiRuby::RackApp, at: "/api-docs"
11
+ #
12
+ # Routes mirror the Rails engine's (config/routes.rb) so both hosts expose
13
+ # the same paths. Schema URLs are derived from SCRIPT_NAME, so the app works
14
+ # at any mount point.
15
+ #
16
+ # Deliberately not namespaced under OpenapiRuby::Rack: that constant would
17
+ # shadow the top-level ::Rack for every file in this gem.
18
+ class RackApp
19
+ SCHEMA_PATH = %r{\A/schemas/(?<name>.+)\z}
20
+ SCHEMA_EXTENSION = /\.(json|ya?ml)\z/
21
+
22
+ def self.call(env)
23
+ (@app ||= new).call(env)
24
+ end
25
+
26
+ def call(env)
27
+ request = ::Rack::Request.new(env)
28
+ return method_not_allowed unless request.get? || request.head?
29
+
30
+ status, headers, body = case request.path_info
31
+ when "", "/" then ui(request)
32
+ when "/schemas" then schema_index
33
+ when "/oauth2-redirect.html" then oauth2_redirect
34
+ when SCHEMA_PATH then schema(::Regexp.last_match(:name), request)
35
+ else not_found
36
+ end
37
+
38
+ # Drop the body ourselves: Rails runs Rack::Head above the engine, a bare
39
+ # Hanami mount has nothing between the router and here.
40
+ [status, headers, request.head? ? [] : body]
41
+ end
42
+
43
+ private
44
+
45
+ def ui(request)
46
+ return not_found unless OpenapiRuby.configuration.ui_enabled
47
+
48
+ html = Serving.swagger_ui_html(
49
+ schema_urls: schema_urls(request),
50
+ ui_config: OpenapiRuby.configuration.ui_config
51
+ )
52
+ respond(200, "text/html", html)
53
+ end
54
+
55
+ def oauth2_redirect
56
+ return not_found unless OpenapiRuby.configuration.ui_enabled
57
+
58
+ respond(200, "text/html", File.read(Serving.oauth2_redirect_file))
59
+ end
60
+
61
+ def schema_index
62
+ respond(200, "application/json", {schemas: Serving.schema_names}.to_json)
63
+ end
64
+
65
+ def schema(name, request)
66
+ document = Serving.schema_document(name.sub(SCHEMA_EXTENSION, ""), request: request)
67
+ return not_found unless document
68
+
69
+ content, content_type = document
70
+ respond(200, content_type, content)
71
+ end
72
+
73
+ def schema_urls(request)
74
+ OpenapiRuby.configuration.schemas.map do |name, schema_config|
75
+ {
76
+ url: "#{request.script_name}/schemas/#{name}.#{Serving.schema_format}",
77
+ name: schema_config.dig(:info, :title) || name.to_s
78
+ }
79
+ end
80
+ end
81
+
82
+ def respond(status, content_type, body, headers = {})
83
+ [status, {"content-type" => content_type}.merge(headers), [body]]
84
+ end
85
+
86
+ def not_found
87
+ respond(404, "application/json", {error: "Not found"}.to_json)
88
+ end
89
+
90
+ def method_not_allowed
91
+ respond(405, "application/json", {error: "Method not allowed"}.to_json, "allow" => "GET, HEAD")
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rake"
4
+ require "shellwords"
5
+ require "openapi_ruby/generator/rake_task_support"
6
+
7
+ module OpenapiRuby
8
+ # The Rails engine picks up lib/tasks/*.rake on its own; other hosts add
9
+ #
10
+ # require "openapi_ruby/rake_tasks"
11
+ #
12
+ # to their Rakefile. Both routes end up here, so the task is defined once.
13
+ module RakeTasks
14
+ extend Rake::DSL
15
+
16
+ def self.install!
17
+ return if Rake::Task.task_defined?("openapi_ruby:generate")
18
+
19
+ namespace :openapi_ruby do
20
+ desc "Generate OpenAPI schema files from spec definitions and components"
21
+ task :generate do
22
+ support = OpenapiRuby::Generator::RakeTaskSupport
23
+ framework = ENV.fetch("FRAMEWORK") { support.detect_test_framework }.to_s
24
+ pattern = ENV.fetch("PATTERN") { support.default_pattern_for(framework) }
25
+
26
+ # Spawn a subprocess so the host's env defaults to "test" cleanly,
27
+ # just like rswag did with RSpec::Core::RakeTask.
28
+ script = support.generate_script(framework, pattern)
29
+ command = "bundle exec ruby -e #{Shellwords.escape(script)}"
30
+
31
+ puts "Generating OpenAPI schemas (#{framework})..."
32
+ system(support.subprocess_env, command) || abort("Schema generation failed")
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+
39
+ OpenapiRuby::RakeTasks.install!