openapi-ruby 3.0.2 → 3.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: c80ade10ad457a2ce7b09f51f788c9bad884da32e462071349a3a3cac7627573
4
- data.tar.gz: f26291466286fe326b0aa0e4223d50e6e6f4864c85d0cfec10e0f53cde8689a9
3
+ metadata.gz: e392a1235f97dc71aaf0ec1790ee0bd64c4bcbae412063af363af91e95cb608d
4
+ data.tar.gz: 90f056e8bc51aa18d6de20a38ac706c079955567066e034d9408a8bd75b2f966
5
5
  SHA512:
6
- metadata.gz: d2fe17e3e72da93b6b50fef93dd2641f2814d1aaf964364d9cf324f1b9562336956b1706d264baf6515f1d9d8ceee07abf830f83c83117594adb5f2806313981
7
- data.tar.gz: 61780c593073693c446d64f5088d495b0083c246989d5799f97b780945351a8f07aeee3e55d5cf1b87bc5c018e21bb4625029d108aed50a1172db9a54be09f5b
6
+ metadata.gz: bde611e9f9b188969e3bbc8a6f1062efe7738ebc15e7eef40079406cccd0eab35051c767db9abfbcba0eea1e91ec39a265f7654468f8478efef8462e63b67f42
7
+ data.tar.gz: c02da938cd7edd9053fc89730916dbc31b87829fc945c36248269e6a77a569cf77ea07c14938550517a16909615ee926fc05b1b54bf848a3997ac7cfa1bf4c47
@@ -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
 
@@ -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
 
@@ -137,6 +139,17 @@ module OpenapiRuby
137
139
  class_to_file[klass] = source_file if source_file
138
140
  end
139
141
 
142
+ # Correct cross-scope inheritance misattribution: when Admin::V1::Schemas::ItemPrice
143
+ # inherits from V1::Schemas::ItemPrice, loading the admin file auto-loads the parent
144
+ # via Ruby autoloading. The diff-based tracking then maps the parent to the admin file.
145
+ # Fix by preferring the conventional file path when it exists and differs.
146
+ class_to_file.each do |klass, file|
147
+ conventional_file = find_source_file_for(klass)
148
+ if conventional_file && conventional_file != file && file_scope_map.key?(conventional_file)
149
+ class_to_file[klass] = conventional_file
150
+ end
151
+ end
152
+
140
153
  # Assign scopes to all registered components based on their source file.
141
154
  class_to_file.each do |klass, file|
142
155
  next if klass._component_scopes_explicitly_set
@@ -146,6 +159,12 @@ module OpenapiRuby
146
159
 
147
160
  if inferred_scope == :shared
148
161
  klass._component_scopes = []
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)
149
168
  else
150
169
  Registry.instance.unregister(klass)
151
170
  klass._component_scopes = [inferred_scope]
@@ -169,14 +188,25 @@ module OpenapiRuby
169
188
 
170
189
  def infer_scope(relative_path, scope_paths)
171
190
  scope_paths.sort_by { |prefix, _| -prefix.length }.each do |prefix, scope|
172
- 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
173
194
  end
174
195
  nil
175
196
  end
176
197
 
177
198
  def filter_type(type)
199
+ ensure_loaded!
178
200
  to_openapi_hash[type.to_s] || {}
179
201
  end
202
+
203
+ @@loaded = false # rubocop:disable Style/ClassVars
204
+
205
+ def ensure_loaded!
206
+ return if @@loaded
207
+
208
+ load!
209
+ end
180
210
  end
181
211
  end
182
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
@@ -15,7 +15,7 @@ module OpenapiRuby
15
15
  "info" => normalize_info(info),
16
16
  "paths" => {}
17
17
  }
18
- @data["servers"] = servers.map { |s| s.transform_keys(&:to_s) } if servers.any?
18
+ @data["servers"] = servers.map { |s| deep_stringify_keys(s) } if servers.any?
19
19
  end
20
20
 
21
21
  def add_path(template, path_item)
@@ -75,11 +75,22 @@ module OpenapiRuby
75
75
  end
76
76
 
77
77
  def normalize_info(info)
78
- result = info.transform_keys(&:to_s)
78
+ result = deep_stringify_keys(info)
79
79
  result["title"] ||= ""
80
80
  result["version"] ||= "0.0.0"
81
81
  result
82
82
  end
83
+
84
+ def deep_stringify_keys(obj)
85
+ case obj
86
+ when Hash
87
+ obj.each_with_object({}) { |(k, v), h| h[k.to_s] = deep_stringify_keys(v) }
88
+ when Array
89
+ obj.map { |v| deep_stringify_keys(v) }
90
+ else
91
+ obj
92
+ end
93
+ end
83
94
  end
84
95
  end
85
96
  end
@@ -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)
@@ -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
@@ -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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OpenapiRuby
4
- VERSION = "3.0.2"
4
+ VERSION = "3.1.0"
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.2
4
+ version: 3.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Morten Hartvig