openapi-ruby 3.0.3 → 3.1.1

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: f135ed1b9a07f225cf01191cb278cd1eb62188369613e41b86df80dcbd147639
4
- data.tar.gz: '008fe467062f636c4400f2dd65fa40cd09c32d6087d4c1207c9ce2667a9afc85'
3
+ metadata.gz: 953f7f56ecdec3d8ee0f1f1261e49328e53d0aab5041b43f47a6e1138a2e880f
4
+ data.tar.gz: bec8a51340348604307f17791824d9327e7ceee7b58a8f35533ef7036c12156f
5
5
  SHA512:
6
- metadata.gz: fddba781e5ca09c19f5766c5b708b0408c4b0c199663f4cba23b8e1959535283f75a1b6e5550bc4defc7bbabc07442c16ad66374b7e93bebd432d29dadfe93f8
7
- data.tar.gz: 67b1ef59d47959496cfb4b33ac76f3e73d961d0645bec2ae2cbc5c693e4fbb4a4d2d9121bef3f39c2840c6bf50500f8a8acead89cd91e7a5893312ccd7798db9
6
+ metadata.gz: c62b49da036fe1dbb27b3f7ce2937a044427ea89ded288bdab1c33dab6fc041e31c89ecb75fd2e2419731cd0f803428baed9ed5152cdf01d7cd77b42b004642e
7
+ data.tar.gz: 5e2bbbcf02c286120207fc06a9f86c66b2618c9766a78b4b94c215641d0c2a5173aa4c45705e4fd2e6ed909271c63000a65182d159f22f3d716a4742dcbc6bae
data/README.md CHANGED
@@ -63,7 +63,6 @@ OpenapiRuby.configure do |config|
63
63
  config.camelize_keys = true
64
64
  config.schema_output_dir = "swagger"
65
65
  config.schema_output_format = :yaml
66
- config.validate_responses_in_tests = true
67
66
 
68
67
  # Runtime middleware (disabled by default)
69
68
  config.request_validation = :disabled # :enabled, :disabled, :warn_only
@@ -20,9 +20,6 @@ OpenapiRuby.configure do |config|
20
20
  config.schema_output_dir = "swagger"
21
21
  config.schema_output_format = :yaml
22
22
 
23
- # Validate response bodies in tests against defined schemas
24
- config.validate_responses_in_tests = true
25
-
26
23
  # Runtime request/response validation middleware
27
24
  # Options: :disabled, :enabled, :warn_only
28
25
  config.request_validation = :disabled
@@ -87,7 +87,7 @@ module OpenapiRuby
87
87
  assert_equal expected_status, response.status,
88
88
  "Expected status #{expected_status}, got #{response.status}\nResponse body: #{response.body}"
89
89
 
90
- if OpenapiRuby.configuration.validate_responses_in_tests && response_ctx.schema_definition
90
+ if response_ctx.schema_definition
91
91
  validator = Testing::ResponseValidator.new
92
92
  body_data = parse_response_body
93
93
  errors = validator.validate(
@@ -26,7 +26,6 @@ module OpenapiRuby
26
26
  define_method(method) do |summary = nil, &block|
27
27
  path_ctx = metadata[:openapi_path_context]
28
28
  op_context = DSL::OperationContext.new(method, summary)
29
- path_ctx.path_parameters.each { |p| op_context.parameter(p) }
30
29
  path_ctx.operations[method.to_s] = op_context
31
30
 
32
31
  describe "#{method.to_s.upcase} #{path_ctx.path_template}" do
@@ -118,6 +117,7 @@ module OpenapiRuby
118
117
  # Merge individual parameter let values
119
118
  operation&.parameters&.each do |param|
120
119
  name = param["name"]
120
+ next unless name
121
121
  val = resolve_let(name.to_sym)
122
122
  next if val.nil?
123
123
 
@@ -144,6 +144,12 @@ module OpenapiRuby
144
144
  accept = resolve_let(:Accept)
145
145
  headers["Accept"] = accept || "application/json"
146
146
 
147
+ # Always append query params to the URL so the middleware sees them
148
+ # (Rails sends params as request body for non-GET methods).
149
+ if params.any?
150
+ path = "#{path}?#{Rack::Utils.build_nested_query(params)}"
151
+ end
152
+
147
153
  if body
148
154
  content_type = operation&.request_body_definition&.dig("content")&.keys&.first || "application/json"
149
155
  request_args = if content_type.include?("form-data") || content_type.include?("x-www-form-urlencoded")
@@ -154,13 +160,8 @@ module OpenapiRuby
154
160
  headers: headers.merge("Content-Type" => content_type)
155
161
  }
156
162
  end
157
- # Append query params to path when body is present
158
- if params.any?
159
- query_string = params.map { |k, v| "#{k}=#{CGI.escape(v.to_s)}" }.join("&")
160
- path = "#{path}?#{query_string}"
161
- end
162
163
  else
163
- request_args = {params: params, headers: headers}
164
+ request_args = {headers: headers}
164
165
  end
165
166
 
166
167
  send(method.to_sym, path, **request_args)
@@ -177,6 +178,19 @@ module OpenapiRuby
177
178
  "Expected status #{expected_status}, got #{actual_status}\n" \
178
179
  "Response body: #{response.body}"
179
180
  end
181
+
182
+ if response_ctx.schema_definition
183
+ schema_name = find_in_metadata(metadata, :openapi_schema_name)
184
+ validator = Testing::ResponseValidator.new(OpenapiRuby::Adapters::RSpec.validation_document_for(schema_name))
185
+ errors = validator.validate(
186
+ response_body: parsed_response_body,
187
+ status_code: response.status,
188
+ response_context: response_ctx
189
+ )
190
+ unless errors.empty?
191
+ raise "Response body validation failed:\n#{errors.join("\n")}\nResponse body: #{response.body}"
192
+ end
193
+ end
180
194
  end
181
195
 
182
196
  private
@@ -269,6 +283,29 @@ module OpenapiRuby
269
283
  end
270
284
  end
271
285
 
286
+ # Build the OpenAPI document hash for a given schema name and cache it.
287
+ # Used by response body validation so $ref schemas can be resolved.
288
+ def self.validation_document_for(schema_name)
289
+ return nil unless schema_name
290
+
291
+ key = schema_name.to_sym
292
+ @validation_documents ||= {}
293
+ @validation_documents[key] ||= begin
294
+ config = OpenapiRuby.configuration
295
+ schema_config = config.schemas[key] || config.schemas[schema_name.to_s]
296
+ return nil unless schema_config
297
+
298
+ builder = OpenapiRuby::Core::DocumentBuilder.new(schema_config)
299
+ OpenapiRuby::DSL::MetadataStore.contexts_for(schema_name).each do |context|
300
+ builder.add_path(context.path_template, context.to_openapi)
301
+ end
302
+ scope = schema_config[:component_scope]
303
+ loader = OpenapiRuby::Components::Loader.new(scope: scope)
304
+ builder.merge_components(loader.to_openapi_hash)
305
+ builder.build.data
306
+ end
307
+ end
308
+
272
309
  def self.install!
273
310
  ::RSpec.configure do |config|
274
311
  config.extend ExampleGroupHelpers, type: :openapi
@@ -13,10 +13,12 @@ module OpenapiRuby
13
13
  def load!
14
14
  define_namespace_modules!
15
15
  load_component_files!
16
+ @@loaded = true # rubocop:disable Style/ClassVars
16
17
  self
17
18
  end
18
19
 
19
20
  def to_openapi_hash
21
+ ensure_loaded!
20
22
  Registry.instance.to_openapi_hash(scope: @scope)
21
23
  end
22
24
 
@@ -158,6 +160,11 @@ module OpenapiRuby
158
160
  if inferred_scope == :shared
159
161
  klass._component_scopes = []
160
162
  klass._component_scopes_explicitly_set = true
163
+ elsif inferred_scope.is_a?(Array)
164
+ Registry.instance.unregister(klass)
165
+ klass._component_scopes = inferred_scope
166
+ klass._component_scopes_explicitly_set = true
167
+ Registry.instance.register(klass)
161
168
  else
162
169
  Registry.instance.unregister(klass)
163
170
  klass._component_scopes = [inferred_scope]
@@ -181,14 +188,25 @@ module OpenapiRuby
181
188
 
182
189
  def infer_scope(relative_path, scope_paths)
183
190
  scope_paths.sort_by { |prefix, _| -prefix.length }.each do |prefix, scope|
184
- return scope&.to_sym if relative_path.start_with?("#{prefix}/")
191
+ if relative_path.start_with?("#{prefix}/")
192
+ return scope.is_a?(Array) ? scope.map(&:to_sym) : scope&.to_sym
193
+ end
185
194
  end
186
195
  nil
187
196
  end
188
197
 
189
198
  def filter_type(type)
199
+ ensure_loaded!
190
200
  to_openapi_hash[type.to_s] || {}
191
201
  end
202
+
203
+ @@loaded = false # rubocop:disable Style/ClassVars
204
+
205
+ def ensure_loaded!
206
+ return if @@loaded
207
+
208
+ load!
209
+ end
192
210
  end
193
211
  end
194
212
  end
@@ -89,16 +89,18 @@ module OpenapiRuby
89
89
 
90
90
  def to_openapi_hash(scope: nil)
91
91
  result = {}
92
+ # Track which components are scope-specific vs multi-scope/shared
93
+ # so scope-specific ones take precedence on name collisions.
94
+ specificity = {}
95
+
92
96
  @components.each do |type, components|
93
97
  type_key = type.to_s
94
98
  result[type_key] = {}
99
+ specificity[type_key] = {}
100
+
95
101
  components.each_value do |klass|
96
102
  next if klass._schema_hidden
97
- # When filtering by scope:
98
- # - Components with matching scope: included
99
- # - Components explicitly marked as shared (empty scopes + explicitly_set): included
100
- # - Components with non-matching scope: excluded
101
- # - Components with no scope assigned (empty scopes + NOT explicitly_set): excluded
103
+
102
104
  if scope
103
105
  if klass._component_scopes.empty?
104
106
  next unless klass._component_scopes_explicitly_set
@@ -107,7 +109,16 @@ module OpenapiRuby
107
109
  end
108
110
  end
109
111
 
110
- result[type_key][klass.component_name] = klass.to_openapi
112
+ name = klass.component_name
113
+ is_specific = klass._component_scopes.length == 1 && klass._component_scopes.include?(scope)
114
+
115
+ # Scope-specific components take precedence over shared/multi-scope
116
+ if specificity[type_key][name] && !is_specific
117
+ next
118
+ end
119
+
120
+ result[type_key][name] = klass.to_openapi
121
+ specificity[type_key][name] = is_specific
111
122
  end
112
123
  result.delete(type_key) if result[type_key].empty?
113
124
  end
@@ -9,46 +9,51 @@ module OpenapiRuby
9
9
  # Components
10
10
  attr_accessor :component_paths
11
11
  attr_accessor :component_scope_paths
12
- attr_accessor :camelize_keys, :key_transform, :response_validation, :strict_query_params,
13
- :coerce_params, :error_handler, :schema_output_format, :validate_responses_in_tests, :ui_path, :ui_config, :coverage_report_path
14
- attr_accessor :strict_reference_validation
12
+
13
+ # Output / formatting
14
+ attr_accessor :camelize_keys, :schema_output_format, :schema_output_dir
15
15
  attr_accessor :auto_validation_error_response
16
16
  attr_accessor :validation_error_schema
17
17
 
18
18
  # Middleware (runtime validation)
19
- attr_accessor :request_validation
19
+ attr_accessor :request_validation, :response_validation, :coerce_params
20
20
 
21
- # Test / Generation
22
- attr_accessor :schema_output_dir
21
+ # OpenAPI meta-schema validation of generated specs and middleware-loaded
22
+ # documents. One of :disabled, :enabled (raise on errors), :warn_only
23
+ # (default, log warnings). Boolean values are accepted for backwards
24
+ # compatibility: `true` → :warn_only, `false` → :disabled.
25
+ attr_reader :strict_reference_validation
23
26
 
24
- # UI (optional)
25
- attr_accessor :ui_enabled
27
+ def strict_reference_validation=(value)
28
+ @strict_reference_validation = case value
29
+ when true, :warn_only then :warn_only
30
+ when false, :disabled then :disabled
31
+ when :enabled then :enabled
32
+ else
33
+ raise ConfigurationError,
34
+ "strict_reference_validation must be :disabled, :enabled, :warn_only, or a boolean"
35
+ end
36
+ end
26
37
 
27
- # Coverage
28
- attr_accessor :coverage_enabled
38
+ # UI (optional)
39
+ attr_accessor :ui_enabled, :ui_path, :ui_config
29
40
 
30
41
  def initialize
31
42
  @schemas = {}
32
43
  @component_paths = ["app/api_components"]
33
44
  @component_scope_paths = {}
34
45
  @camelize_keys = true
35
- @key_transform = nil
36
46
  @request_validation = :disabled
37
47
  @response_validation = :disabled
38
- @strict_query_params = false
39
48
  @coerce_params = true
40
- @error_handler = nil
41
49
  @schema_output_dir = "swagger"
42
50
  @schema_output_format = :yaml
43
- @validate_responses_in_tests = true
44
51
  @ui_enabled = false
45
52
  @ui_path = "/api-docs"
46
53
  @ui_config = {}
47
- @strict_reference_validation = true
54
+ @strict_reference_validation = :warn_only
48
55
  @auto_validation_error_response = true
49
56
  @validation_error_schema = nil
50
- @coverage_enabled = false
51
- @coverage_report_path = "tmp/openapi_coverage.json"
52
57
  end
53
58
 
54
59
  def validate!
@@ -60,12 +60,18 @@ module OpenapiRuby
60
60
  }
61
61
  }
62
62
 
63
- # Add 400 to every operation that doesn't already define one
63
+ # Add 400 to operations that have parameters or a request body
64
64
  @paths.each_value do |path_item|
65
+ path_params = path_item["parameters"]
66
+
65
67
  path_item.each do |key, operation|
66
68
  next unless operation.is_a?(Hash) && operation.key?("responses")
67
69
  next if key == "parameters"
68
70
 
71
+ has_params = operation.key?("parameters") || path_params
72
+ has_body = operation.key?("requestBody")
73
+ next unless has_params || has_body
74
+
69
75
  operation["responses"]["400"] ||= {"$ref" => "#/components/responses/SchemaValidationError"}
70
76
  end
71
77
  end
@@ -23,8 +23,6 @@ module OpenapiRuby
23
23
  HTTP_METHODS.each do |method|
24
24
  define_method(method) do |summary = nil, &block|
25
25
  op = OperationContext.new(method, summary)
26
- # Copy path-level parameters to operation
27
- @path_parameters.each { |p| op.parameter(p) }
28
26
  op.instance_eval(&block) if block
29
27
  @operations[method.to_s] = op
30
28
  op
@@ -34,8 +32,8 @@ module OpenapiRuby
34
32
  def to_openapi
35
33
  result = {}
36
34
 
37
- # Path-level parameters are already copied into each operation (line 27),
38
- # so we don't output them at the path level to avoid duplicates.
35
+ result["parameters"] = @path_parameters if @path_parameters.any?
36
+
39
37
  @operations.each do |verb, op|
40
38
  result[verb] = op.to_openapi
41
39
  end
@@ -7,6 +7,8 @@ module OpenapiRuby
7
7
  initializer "openapi_ruby.middleware" do |app|
8
8
  config = OpenapiRuby.configuration
9
9
 
10
+ next if ENV["OPENAPI_RUBY_GENERATING"]
11
+
10
12
  if config.request_validation != :disabled || config.response_validation != :disabled
11
13
  config.schemas.each do |name, schema_config|
12
14
  schema_path = resolve_schema_path(config, name)
@@ -36,9 +38,6 @@ module OpenapiRuby
36
38
  end
37
39
  end
38
40
 
39
- # Components are loaded on demand via Components::Loader (e.g. in test helpers),
40
- # not at boot time — avoids cross-file dependency ordering issues.
41
-
42
41
  private
43
42
 
44
43
  def resolve_schema_path(config, schema_name)
@@ -23,7 +23,7 @@ module OpenapiRuby
23
23
 
24
24
  def write!
25
25
  document = build_document
26
- validate_document!(document) if OpenapiRuby.configuration.strict_reference_validation
26
+ validate_document!(document) unless OpenapiRuby.configuration.strict_reference_validation == :disabled
27
27
  output_path = File.join(output_dir, filename)
28
28
  FileUtils.mkdir_p(output_dir)
29
29
  File.write(output_path, format_output(document))
@@ -38,9 +38,10 @@ module OpenapiRuby
38
38
  builder.add_path(context.path_template, context.to_openapi)
39
39
  end
40
40
 
41
- # Merge components from registry
41
+ # Merge components from registry (Loader ensures files are loaded)
42
42
  scope = @schema_config[:component_scope]
43
- components = Components::Registry.instance.to_openapi_hash(scope: scope)
43
+ loader = Components::Loader.new(scope: scope)
44
+ components = loader.to_openapi_hash
44
45
  builder.merge_components(components)
45
46
 
46
47
  builder.build
@@ -62,7 +63,12 @@ module OpenapiRuby
62
63
  return if errors.empty?
63
64
 
64
65
  error_messages = errors.first(10).map { |e| e["error"] || e.to_s }
65
- warn "[openapi_ruby] Generated schema '#{@schema_name}' has validation errors:\n#{error_messages.join("\n")}"
66
+ message = "[openapi_ruby] Generated schema '#{@schema_name}' has validation errors:\n#{error_messages.join("\n")}"
67
+ if OpenapiRuby.configuration.strict_reference_validation == :enabled
68
+ raise OpenapiRuby::ConfigurationError, message
69
+ else
70
+ warn message
71
+ end
66
72
  end
67
73
 
68
74
  def format_output(document)
@@ -21,7 +21,11 @@ module OpenapiRuby
21
21
  private
22
22
 
23
23
  def build_matchers(templates)
24
- templates.map do |template|
24
+ # Sort templates so that static paths come before parameterized ones.
25
+ # This ensures `/admin_users/me` matches before `/admin_users/{id}`.
26
+ sorted = templates.sort_by { |t| [t.count("{"), t] }
27
+
28
+ sorted.map do |template|
25
29
  pattern = Regexp.new("\\A" + template.gsub(/\{(\w+)\}/) { "(?<#{::Regexp.last_match(1)}>[^/]+)" } + "\\z")
26
30
  [template, pattern]
27
31
  end
@@ -139,8 +139,8 @@ module OpenapiRuby
139
139
  errors = []
140
140
  # Coerce string values for validation based on schema type
141
141
  coerced = coerce_for_validation(value, schema)
142
- schemer = JSONSchemer.schema(schema)
143
- schemer.validate(coerced).each do |err|
142
+ schema_validator = resolve_schema(schema)
143
+ schema_validator.validate(coerced).each do |err|
144
144
  msg = err["error"] || err["type"] || "validation failed"
145
145
  errors << "Invalid #{context}: #{msg}"
146
146
  end
@@ -170,9 +170,26 @@ module OpenapiRuby
170
170
  end
171
171
 
172
172
  def coerce_for_validation(value, schema)
173
- return value unless value.is_a?(String)
173
+ resolved = resolve_schema_definition(schema)
174
+
175
+ if value.is_a?(Hash) && resolved.is_a?(Hash)
176
+ properties = resolved["properties"] || {}
177
+ value.each_with_object({}) do |(k, v), coerced|
178
+ prop_schema = properties[k] || properties[k.to_s]
179
+ coerced[k] = prop_schema ? coerce_for_validation(v, prop_schema) : v
180
+ end
181
+ elsif value.is_a?(String)
182
+ coerce_string(value, resolved)
183
+ else
184
+ value
185
+ end
186
+ rescue ArgumentError, TypeError
187
+ value
188
+ end
174
189
 
175
- case schema["type"]
190
+ def coerce_string(value, schema)
191
+ type = schema.is_a?(Hash) ? schema["type"] : nil
192
+ case type
176
193
  when "integer"
177
194
  Integer(value)
178
195
  when "number"
@@ -190,6 +207,19 @@ module OpenapiRuby
190
207
  value
191
208
  end
192
209
 
210
+ def resolve_schema_definition(schema)
211
+ return schema unless schema.is_a?(Hash) && schema["$ref"]
212
+
213
+ ref_path = schema["$ref"].sub("#/", "").split("/")
214
+ ref_path.reduce(document) { |doc, key| doc&.dig(key) } || schema
215
+ rescue
216
+ schema
217
+ end
218
+
219
+ def document
220
+ @resolver.respond_to?(:document) ? @resolver.document : {}
221
+ end
222
+
193
223
  def read_request_body(request)
194
224
  return nil unless request.body
195
225
 
@@ -3,7 +3,7 @@
3
3
  module OpenapiRuby
4
4
  module Middleware
5
5
  class SchemaResolver
6
- def initialize(spec_path: nil, document: nil, strict_reference_validation: true)
6
+ def initialize(spec_path: nil, document: nil, strict_reference_validation: :warn_only)
7
7
  @spec_path = spec_path
8
8
  @document = document
9
9
  @strict_reference_validation = strict_reference_validation
@@ -41,15 +41,19 @@ module OpenapiRuby
41
41
  private
42
42
 
43
43
  def validate_document!(doc)
44
- return unless @strict_reference_validation
44
+ return if @strict_reference_validation == :disabled
45
45
 
46
46
  schemer = JSONSchemer.openapi(doc)
47
47
  errors = schemer.validate.to_a
48
48
  return if errors.empty?
49
49
 
50
50
  error_messages = errors.first(5).map { |e| e["error"] || e.to_s }
51
- raise OpenapiRuby::ConfigurationError,
52
- "OpenAPI document validation failed:\n#{error_messages.join("\n")}"
51
+ message = "OpenAPI document validation failed:\n#{error_messages.join("\n")}"
52
+ if @strict_reference_validation == :enabled
53
+ raise OpenapiRuby::ConfigurationError, message
54
+ else
55
+ warn message
56
+ end
53
57
  end
54
58
 
55
59
  def load_document
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OpenapiRuby
4
- VERSION = "3.0.3"
4
+ VERSION = "3.1.1"
5
5
  end
@@ -8,7 +8,7 @@ namespace :openapi_ruby do
8
8
 
9
9
  # Spawn a subprocess so RAILS_ENV defaults to "test" cleanly,
10
10
  # just like rswag did with RSpec::Core::RakeTask.
11
- env = {"RAILS_ENV" => ENV.fetch("RAILS_ENV", "test")}
11
+ env = {"RAILS_ENV" => ENV.fetch("RAILS_ENV", "test"), "OPENAPI_RUBY_GENERATING" => "true"}
12
12
  script = generate_script(framework, pattern)
13
13
  command = "bundle exec ruby -e #{Shellwords.escape(script)}"
14
14
 
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: 3.0.3
4
+ version: 3.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Morten Hartvig