openapi-ruby 4.1.0 → 5.0.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.
@@ -156,8 +156,9 @@ module OpenapiRuby
156
156
 
157
157
  headers["Accept"] ||= "application/json"
158
158
 
159
- path_param_names = context.path_parameters.map { |p| p["name"] }
159
+ path_param_names = context.path_parameters.flat_map { |p| ParameterNames.lookup_names(p) }
160
160
  query_params = params.reject { |k, _| path_param_names.include?(k.to_s) }
161
+ query_params = ParameterNames.rename_keys(query_params, operation.parameters)
161
162
 
162
163
  if body
163
164
  content_type = operation.request_body_definition&.dig("content")&.keys&.first || "application/json"
@@ -192,10 +193,10 @@ module OpenapiRuby
192
193
  raise "Request validation failed:\n#{req_errors.join("\n")}" unless req_errors.empty?
193
194
  end
194
195
 
195
- send(method, path, **request_args)
196
+ openapi_transport.dispatch(method, path, **request_args)
196
197
 
197
- unless response.status == expected_status
198
- raise "Expected status #{expected_status}, got #{response.status}\nResponse body: #{response.body}"
198
+ unless openapi_response.status == expected_status
199
+ raise "Expected status #{expected_status}, got #{openapi_response.status}\nResponse body: #{openapi_response.body}"
199
200
  end
200
201
 
201
202
  if response_ctx.schema_definition
@@ -204,11 +205,11 @@ module OpenapiRuby
204
205
  )
205
206
  errors = validator.validate(
206
207
  response_body: parsed_response_body,
207
- status_code: response.status,
208
+ status_code: openapi_response.status,
208
209
  response_context: response_ctx
209
210
  )
210
211
  unless errors.empty?
211
- raise "Response body validation failed:\n#{errors.join("\n")}\nResponse body: #{response.body}"
212
+ raise "Response body validation failed:\n#{errors.join("\n")}\nResponse body: #{openapi_response.body}"
212
213
  end
213
214
  end
214
215
 
@@ -219,6 +220,17 @@ module OpenapiRuby
219
220
  parsed_response_body
220
221
  end
221
222
 
223
+ # The seam between the DSL and the host's request API. Public so specs
224
+ # that drive requests themselves (rate limiting, pagination loops) can
225
+ # reach the same dispatcher and response the assertions use.
226
+ def openapi_transport
227
+ @openapi_transport ||= Testing::Transport.for(self)
228
+ end
229
+
230
+ def openapi_response
231
+ openapi_transport.response
232
+ end
233
+
222
234
  # submit_openapi_request is public so specs can call it directly
223
235
  # (e.g., for rate limiting tests that need multiple requests)
224
236
  def submit_openapi_request(metadata)
@@ -237,7 +249,7 @@ module OpenapiRuby
237
249
  next if val.nil?
238
250
 
239
251
  case param["in"]
240
- when "query" then params[name] = val
252
+ when "query" then params[ParameterNames.wire_name(param)] = val
241
253
  when "header" then headers[name] = val
242
254
  end
243
255
  end
@@ -308,19 +320,19 @@ module OpenapiRuby
308
320
  raise "Request validation failed:\n#{req_errors.join("\n")}" unless req_errors.empty?
309
321
  end
310
322
 
311
- send(method.to_sym, path, **request_args)
323
+ openapi_transport.dispatch(method, path, **request_args)
312
324
  end
313
325
 
314
326
  def assert_openapi_response(metadata)
315
327
  response_ctx = find_in_metadata(metadata, :openapi_response)
316
328
 
317
329
  expected_status = response_ctx.status_code.to_i
318
- actual_status = response.status
330
+ actual_status = openapi_response.status
319
331
 
320
332
  unless actual_status == expected_status
321
333
  raise "Response validation failed:\n" \
322
334
  "Expected status #{expected_status}, got #{actual_status}\n" \
323
- "Response body: #{response.body}"
335
+ "Response body: #{openapi_response.body}"
324
336
  end
325
337
 
326
338
  if response_ctx.schema_definition
@@ -328,11 +340,11 @@ module OpenapiRuby
328
340
  validator = Testing::ResponseValidator.new(OpenapiRuby::Adapters::RSpec.validation_document_for(schema_name))
329
341
  errors = validator.validate(
330
342
  response_body: parsed_response_body,
331
- status_code: response.status,
343
+ status_code: openapi_response.status,
332
344
  response_context: response_ctx
333
345
  )
334
346
  unless errors.empty?
335
- raise "Response body validation failed:\n#{errors.join("\n")}\nResponse body: #{response.body}"
347
+ raise "Response body validation failed:\n#{errors.join("\n")}\nResponse body: #{openapi_response.body}"
336
348
  end
337
349
  end
338
350
  end
@@ -438,10 +450,10 @@ module OpenapiRuby
438
450
  end
439
451
 
440
452
  def parsed_response_body
441
- return nil if response.body.empty?
442
- JSON.parse(response.body)
453
+ return nil if openapi_response.body.empty?
454
+ JSON.parse(openapi_response.body)
443
455
  rescue JSON::ParserError
444
- response.body
456
+ openapi_response.body
445
457
  end
446
458
  end
447
459
 
@@ -468,6 +480,20 @@ module OpenapiRuby
468
480
  end
469
481
  end
470
482
 
483
+ # Every host but Rails drives requests through rack-test. Hanami also
484
+ # gets a default `app`; elsewhere (Sinatra, Roda, bare Rack) there is no
485
+ # convention for which app is under test, so the suite defines it.
486
+ def self.install_rack_test!(config, app_module: nil)
487
+ require "rack/test"
488
+
489
+ config.include ::Rack::Test::Methods, type: :openapi
490
+ config.include app_module, type: :openapi if app_module
491
+ rescue LoadError
492
+ # No rack-test in the bundle. Testing::Transport raises with setup
493
+ # instructions if a spec then tries to issue a request.
494
+ nil
495
+ end
496
+
471
497
  def self.install!
472
498
  ::RSpec.configure do |config|
473
499
  config.extend ExampleGroupHelpers, type: :openapi
@@ -475,6 +501,11 @@ module OpenapiRuby
475
501
 
476
502
  if defined?(::RSpec::Rails)
477
503
  config.include ::RSpec::Rails::RequestExampleGroup, type: :openapi
504
+ elsif OpenapiRuby.hanami_host?
505
+ require "openapi_ruby/hanami"
506
+ OpenapiRuby::Hanami.install_rspec!(config)
507
+ else
508
+ install_rack_test!(config)
478
509
  end
479
510
 
480
511
  # 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
@@ -17,6 +17,7 @@ module OpenapiRuby
17
17
  end
18
18
 
19
19
  def add_path(template, operations)
20
+ template = ParameterNames.in_template(template)
20
21
  @paths[template] ||= {}
21
22
  @paths[template].deep_merge!(operations)
22
23
  end
@@ -32,7 +32,7 @@ module OpenapiRuby
32
32
  def to_openapi
33
33
  result = {}
34
34
 
35
- result["parameters"] = @path_parameters if @path_parameters.any?
35
+ result["parameters"] = ParameterNames.parameters_for_document(@path_parameters) if @path_parameters.any?
36
36
 
37
37
  @operations.each do |verb, op|
38
38
  result[verb] = op.to_openapi
@@ -93,7 +93,7 @@ module OpenapiRuby
93
93
  result["summary"] = @summary if @summary
94
94
  result["tags"] = @tags_list if @tags_list.any?
95
95
  result.merge!(@metadata)
96
- result["parameters"] = @parameters if @parameters.any?
96
+ result["parameters"] = ParameterNames.parameters_for_document(@parameters) if @parameters.any?
97
97
  result["security"] = @security_list if @security_list
98
98
 
99
99
  result["requestBody"] = build_request_body if @request_body_definition
@@ -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,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiRuby
4
+ # Parameters are declared in snake_case so `let(:page_size)` and
5
+ # `params: {page_size: 1}` stay idiomatic Ruby. With `camelize_keys` on, the
6
+ # name that reaches the document *and* the wire is camelCased, so parameters
7
+ # match the component keys instead of contradicting them.
8
+ #
9
+ # Header parameters keep their declared spelling: HTTP header names are
10
+ # conventionally hyphenated and matched case-insensitively, so camelizing
11
+ # them would rename headers nobody asked to rename.
12
+ module ParameterNames
13
+ module_function
14
+
15
+ def camelize?
16
+ OpenapiRuby.configuration.camelize_keys
17
+ end
18
+
19
+ def camelize(name)
20
+ return name.to_s unless camelize?
21
+
22
+ Components::KeyTransformer.camelize(name)
23
+ end
24
+
25
+ # The name a declared parameter carries in the document and on the wire.
26
+ def wire_name(param)
27
+ name = param["name"] || param[:name]
28
+ return nil if name.nil?
29
+ return name.to_s if header?(param)
30
+
31
+ camelize(name)
32
+ end
33
+
34
+ # Both spellings of a declared parameter. Value lookups go through this so a
35
+ # caller that passes the declared name still resolves after the wire name
36
+ # diverged from it.
37
+ def lookup_names(param)
38
+ declared = (param["name"] || param[:name])&.to_s
39
+ return [] unless declared
40
+
41
+ [declared, wire_name(param)].compact.uniq
42
+ end
43
+
44
+ def parameters_for_document(parameters)
45
+ return parameters unless camelize?
46
+
47
+ parameters.map do |param|
48
+ wire = wire_name(param)
49
+ next param if wire.nil? || wire == param["name"].to_s
50
+
51
+ param.merge("name" => wire)
52
+ end
53
+ end
54
+
55
+ # `/users/{user_id}` -> `/users/{userId}`. The template variables have to
56
+ # travel with the parameter names: OpenAPI requires each one to be backed by
57
+ # a path parameter of the same name.
58
+ def in_template(template)
59
+ return template unless camelize?
60
+
61
+ template.gsub(/\{(\w+)\}/) { "{#{camelize(::Regexp.last_match(1))}}" }
62
+ end
63
+
64
+ # Rename the keys of a request value hash from their declared spelling to
65
+ # the wire spelling. Keys that match no declared parameter are left alone —
66
+ # undeclared params are passed through as the caller wrote them.
67
+ def rename_keys(values, parameters)
68
+ return values unless camelize?
69
+
70
+ mapping = parameters.each_with_object({}) do |param, acc|
71
+ declared = (param["name"] || param[:name])&.to_s
72
+ next unless declared
73
+
74
+ wire = wire_name(param)
75
+ acc[declared] = wire if wire && wire != declared
76
+ end
77
+ return values if mapping.empty?
78
+
79
+ values.each_with_object({}) do |(key, value), acc|
80
+ renamed = mapping[key.to_s]
81
+ acc[renamed.nil? ? key : renamed] = value
82
+ end
83
+ end
84
+
85
+ def header?(param)
86
+ (param["in"] || param[:in]).to_s == "header"
87
+ end
88
+ end
89
+ end