openapi-ruby 2.1.0 → 2.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: 2fa2e6a3989eed9e11e17ff251bccf6044dd6ecb280bb32fb390f11179955afb
4
- data.tar.gz: 15bcdc40a54580d95d5e71ec88665d9c1911b7d7630e38d4ebe56bcdf031aba2
3
+ metadata.gz: d7c1b8cfa3a8ecc15d1e378b9354d359333b5ac8d43f9cdd1de609d60830a839
4
+ data.tar.gz: c987648c9d766474bb5665d115647513c1ae7d6c3d4c5ea4478ec83d3b7fed22
5
5
  SHA512:
6
- metadata.gz: 0d1ba7835ecebbfe335a86fddc39b772c730517729bc0f0a03902e31b5d35e6b53929cd6e693f4841c054a32ecf8eeaf821fe9913b7b6fd9d2faacead2834bd1
7
- data.tar.gz: b18fdcc9aa4d8706b934bf775bfcd4c4a1505b4387277ef7f9c5efc4b80f678cad0a038b178e35a70b8343b5d55e4b8df137cfc93081b61170dd7a0573960f0c
6
+ metadata.gz: 01dc4b64bd6d67a48659d1d1ffb8f77dc93609d3ff790664d60ad312c8b38f2ff986ec013ae52cf14dda243f396637c39366c21985086098bb7c3effa0f4a813
7
+ data.tar.gz: d5df229413a0836644665b8ff29976764c547383ea6443eea20d156d6db8671405f8e84ddec26a25d11b1b3456d84157e44571ebb32ecc58265209e04b20e5b1
data/README.md CHANGED
@@ -1,6 +1,12 @@
1
- # openapi_ruby
1
+ <p align="center">
2
+ <img src="logo.svg" alt="openapi_ruby" width="200">
3
+ </p>
2
4
 
3
- A unified OpenAPI 3.1 toolkit for Rails that combines test-driven spec generation, reusable schema components as Ruby classes, and runtime request/response validation middleware. Works with both RSpec and Minitest.
5
+ <h1 align="center">openapi_ruby</h1>
6
+
7
+ <p align="center">
8
+ A unified OpenAPI 3.1 toolkit for Rails that combines test-driven spec generation, reusable schema components as Ruby classes, and runtime request/response validation middleware. Works with both RSpec and Minitest.
9
+ </p>
4
10
 
5
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.
6
12
 
@@ -44,6 +44,7 @@ module OpenapiRuby
44
44
  <body>
45
45
  <div id="swagger-ui"></div>
46
46
  <script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
47
+ <script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-standalone-preset.js"></script>
47
48
  <script>
48
49
  SwaggerUIBundle({
49
50
  #{schema_urls_js},
@@ -51,7 +52,10 @@ module OpenapiRuby
51
52
  deepLinking: true,
52
53
  presets: [
53
54
  SwaggerUIBundle.presets.apis,
54
- SwaggerUIBundle.SwaggerUIStandalonePreset
55
+ SwaggerUIStandalonePreset
56
+ ],
57
+ plugins: [
58
+ SwaggerUIBundle.plugins.DownloadUrl
55
59
  ],
56
60
  layout: "#{(@schemas.size > 1) ? "StandaloneLayout" : "BaseLayout"}",
57
61
  #{ui_config_js}
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "openapi_ruby"
@@ -4,13 +4,20 @@ module OpenapiRuby
4
4
  module Components
5
5
  module Base
6
6
  def self.included(base)
7
- base.extend ClassMethods
8
- base.class_attribute :_schema_definition, default: {}
9
- base.class_attribute :_schema_hidden, default: false
10
- base.class_attribute :_skip_key_transformation, default: false
11
- base.class_attribute :_component_type, default: :schemas
12
- base.class_attribute :_component_scopes, default: []
13
- base.class_attribute :_component_scopes_explicitly_set, default: false
7
+ # Guard against re-including in subclasses that already inherited Base.
8
+ # Re-running class_attribute with default: would overwrite inherited values.
9
+ already_included = base.respond_to?(:_schema_definition)
10
+
11
+ base.extend ClassMethods unless already_included
12
+
13
+ unless already_included
14
+ base.class_attribute :_schema_definition, default: {}
15
+ base.class_attribute :_schema_hidden, default: false
16
+ base.class_attribute :_skip_key_transformation, default: false
17
+ base.class_attribute :_component_type, default: :schemas
18
+ base.class_attribute :_component_scopes, default: []
19
+ base.class_attribute :_component_scopes_explicitly_set, default: false
20
+ end
14
21
 
15
22
  Registry.instance.register(base) if base.name
16
23
  end
@@ -28,7 +35,10 @@ module OpenapiRuby
28
35
  end
29
36
 
30
37
  def schema(definition = nil)
31
- self._schema_definition = _schema_definition.deep_merge(deep_stringify(definition)) if definition
38
+ if definition
39
+ stringified = deep_stringify(definition)
40
+ self._schema_definition = deep_merge_with_array_concat(_schema_definition, stringified)
41
+ end
32
42
  _schema_definition
33
43
  end
34
44
 
@@ -58,6 +68,11 @@ module OpenapiRuby
58
68
  self._component_scopes_explicitly_set = true
59
69
  end
60
70
 
71
+ def transform_enum_key(key)
72
+ key = ActiveSupport::Inflector.underscore(key.to_s)
73
+ key.parameterize(separator: "_").upcase
74
+ end
75
+
61
76
  def component_name
62
77
  (name || "Anonymous").demodulize
63
78
  end
@@ -115,6 +130,21 @@ module OpenapiRuby
115
130
  !_skip_key_transformation && OpenapiRuby.configuration.camelize_keys
116
131
  end
117
132
 
133
+ # Deep merge that concatenates arrays instead of replacing them,
134
+ # matching the behavior of rswag-schema_components' deeper_merge.
135
+ # This ensures inherited schema properties (like `required`) are merged correctly.
136
+ def deep_merge_with_array_concat(base, override)
137
+ base.merge(override) do |_key, old_val, new_val|
138
+ if old_val.is_a?(Hash) && new_val.is_a?(Hash)
139
+ deep_merge_with_array_concat(old_val, new_val)
140
+ elsif old_val.is_a?(Array) && new_val.is_a?(Array)
141
+ (old_val + new_val).uniq
142
+ else
143
+ new_val
144
+ end
145
+ end
146
+ end
147
+
118
148
  def deep_stringify(value)
119
149
  case value
120
150
  when Hash
@@ -9,15 +9,20 @@ module OpenapiRuby
9
9
  transform_keys(hash) { |key| camelize(key) }
10
10
  end
11
11
 
12
- def transform_keys(value, &block)
12
+ def transform_keys(value, parent_key: nil, &block)
13
13
  case value
14
14
  when Hash
15
15
  value.each_with_object({}) do |(k, v), result|
16
16
  new_key = block.call(k.to_s)
17
- result[new_key] = transform_keys(v, &block)
17
+ result[new_key] = transform_keys(v, parent_key: k.to_s, &block)
18
18
  end
19
19
  when Array
20
- value.map { |v| transform_keys(v, &block) }
20
+ if parent_key == "required"
21
+ # Values in "required" arrays are property names that must also be transformed
22
+ value.map { |v| v.is_a?(String) ? block.call(v) : transform_keys(v, &block) }
23
+ else
24
+ value.map { |v| transform_keys(v, &block) }
25
+ end
21
26
  else
22
27
  value
23
28
  end
@@ -27,8 +32,12 @@ module OpenapiRuby
27
32
  key = key.to_s
28
33
  return key if key.start_with?("$")
29
34
 
30
- parts = key.split("_")
31
- parts[0] + parts[1..].map(&:capitalize).join
35
+ # Preserve leading underscore prefix (e.g., _destroy stays _destroy)
36
+ prefix = key.start_with?("_") ? "_" : ""
37
+ stripped = key.delete_prefix("_")
38
+
39
+ parts = stripped.split("_")
40
+ "#{prefix}#{parts[0]}#{parts[1..].map(&:capitalize).join}"
32
41
  end
33
42
  end
34
43
  end
@@ -63,77 +63,82 @@ module OpenapiRuby
63
63
  expanded = File.expand_path(path)
64
64
  next unless Dir.exist?(expanded)
65
65
 
66
- define_nested_modules(expanded, expanded)
67
- end
68
- end
69
-
70
- def define_nested_modules(base_path, current_path)
71
- Dir.children(current_path).select { |f| File.directory?(File.join(current_path, f)) }.each do |dir|
72
- mod_name = dir.camelize.to_sym
73
- Object.const_set(mod_name, Module.new) unless Object.const_defined?(mod_name)
74
-
75
- child_path = File.join(current_path, dir)
76
- define_nested_modules(base_path, child_path)
66
+ Dir.glob(File.join(expanded, "**/")).each do |dir_path|
67
+ relative = dir_path.sub("#{expanded}/", "").chomp("/")
68
+ next if relative.empty?
69
+
70
+ const_name = relative.camelize
71
+ const_name.split("::").inject(Object) do |parent, name|
72
+ if parent.const_defined?(name, false)
73
+ parent.const_get(name, false)
74
+ else
75
+ parent.const_set(name, Module.new)
76
+ end
77
+ end
78
+ end
77
79
  end
78
80
  end
79
81
 
80
82
  def load_component_files!
81
83
  scope_paths = OpenapiRuby.configuration.component_scope_paths
82
84
 
85
+ # Collect all files with their base paths, then sort globally by relative
86
+ # path to ensure consistent load order across multiple base paths.
87
+ # This prevents cross-directory inheritance issues (e.g., a subclass in
88
+ # packs/ai_feedback loading before its superclass in packs/api).
89
+ all_files = collect_all_files
90
+
83
91
  if scope_paths.any?
84
- load_with_scope_inference(scope_paths)
92
+ load_with_scope_inference(all_files, scope_paths)
85
93
  else
86
- component_files.each { |f| require f }
94
+ all_files.each { |entry| require entry[:file] }
87
95
  end
88
96
  end
89
97
 
90
- def load_with_scope_inference(scope_paths)
98
+ def collect_all_files
99
+ files = []
91
100
  @paths.each do |base_path|
92
101
  expanded = File.expand_path(base_path)
93
102
  next unless Dir.exist?(expanded)
94
103
 
95
- files = Dir[File.join(expanded, "**", "*.rb")].sort
96
-
97
- files.each do |file|
104
+ Dir[File.join(expanded, "**", "*.rb")].each do |file|
98
105
  relative = file.sub("#{expanded}/", "")
99
- inferred_scope = infer_scope(relative, scope_paths)
100
-
101
- registered_before = Registry.instance.all_registered_classes.dup
102
- require file
103
- registered_after = Registry.instance.all_registered_classes
104
-
105
- new_classes = registered_after - registered_before
106
- new_classes.each do |klass|
107
- next if klass._component_scopes_explicitly_set
108
-
109
- if inferred_scope == :shared
110
- # Shared components have empty scopes (included in all specs)
111
- klass._component_scopes = []
112
- elsif inferred_scope
113
- Registry.instance.unregister(klass)
114
- klass._component_scopes = [inferred_scope]
115
- Registry.instance.register(klass)
116
- end
106
+ files << {file: file, base_path: expanded, relative: relative}
107
+ end
108
+ end
109
+ files.sort_by { |entry| entry[:relative] }
110
+ end
111
+
112
+ def load_with_scope_inference(all_files, scope_paths)
113
+ all_files.each do |entry|
114
+ inferred_scope = infer_scope(entry[:relative], scope_paths)
115
+
116
+ registered_before = Registry.instance.all_registered_classes.dup
117
+ require entry[:file]
118
+ registered_after = Registry.instance.all_registered_classes
119
+
120
+ new_classes = registered_after - registered_before
121
+ new_classes.each do |klass|
122
+ next if klass._component_scopes_explicitly_set
123
+
124
+ if inferred_scope == :shared
125
+ klass._component_scopes = []
126
+ elsif inferred_scope
127
+ Registry.instance.unregister(klass)
128
+ klass._component_scopes = [inferred_scope]
129
+ Registry.instance.register(klass)
117
130
  end
118
131
  end
119
132
  end
120
133
  end
121
134
 
122
135
  def infer_scope(relative_path, scope_paths)
123
- # Match longest prefix first for specificity
124
136
  scope_paths.sort_by { |prefix, _| -prefix.length }.each do |prefix, scope|
125
137
  return scope&.to_sym if relative_path.start_with?("#{prefix}/")
126
138
  end
127
139
  nil
128
140
  end
129
141
 
130
- def component_files
131
- @paths.flat_map do |path|
132
- expanded = File.expand_path(path)
133
- Dir[File.join(expanded, "**", "*.rb")]
134
- end
135
- end
136
-
137
142
  def filter_type(type)
138
143
  to_openapi_hash[type.to_s] || {}
139
144
  end
@@ -13,21 +13,22 @@ module OpenapiRuby
13
13
 
14
14
  def register(component_class)
15
15
  type = component_class._component_type
16
- key = component_class.registry_key
16
+ name = component_class.name || "Anonymous"
17
17
 
18
18
  @components[type] ||= {}
19
19
 
20
- if @components[type].key?(key) && @components[type][key] != component_class
21
- raise DuplicateComponentError, "Component '#{component_class.component_name}' already registered under #{type}"
22
- end
20
+ check_for_duplicate!(component_class, type)
23
21
 
24
- @components[type][key] = component_class
22
+ # Use the full class name as key to avoid collisions between
23
+ # same-named components in different scopes (e.g., Internal::V1::Schemas::PaginatedCollection
24
+ # vs Mobile::V1::Schemas::PaginatedCollection). Scope filtering happens in to_openapi_hash.
25
+ @components[type][name] = component_class
25
26
  end
26
27
 
27
28
  def unregister(component_class)
28
29
  type = component_class._component_type
29
- key = component_class.registry_key
30
- @components[type]&.delete(key)
30
+ name = component_class.name || "Anonymous"
31
+ @components[type]&.delete(name)
31
32
  end
32
33
 
33
34
  def components_for(type)
@@ -50,6 +51,41 @@ module OpenapiRuby
50
51
  @components = {}
51
52
  end
52
53
 
54
+ private
55
+
56
+ def check_for_duplicate!(component_class, type)
57
+ short_name = component_class.component_name
58
+ new_scopes = component_class._component_scopes
59
+ new_scopes_set = component_class._component_scopes_explicitly_set
60
+
61
+ @components[type]&.each_value do |existing|
62
+ next if existing.name == component_class.name
63
+ next unless existing.component_name == short_name
64
+
65
+ existing_scopes = existing._component_scopes
66
+ existing_scopes_set = existing._component_scopes_explicitly_set
67
+
68
+ # Skip when exactly one side has explicitly configured scopes — the other
69
+ # is still at its default (freshly included) and may get scopes set later via
70
+ # component_scopes, which unregisters/re-registers and retriggers this check.
71
+ next if new_scopes_set != existing_scopes_set
72
+
73
+ if scopes_overlap?(new_scopes, existing_scopes)
74
+ raise DuplicateComponentError,
75
+ "Component '#{short_name}' is already registered as #{type} " \
76
+ "(existing: #{existing.name}, new: #{component_class.name})"
77
+ end
78
+ end
79
+ end
80
+
81
+ def scopes_overlap?(a, b)
82
+ return true if a.empty? && b.empty?
83
+ return true if a.empty? || b.empty?
84
+ (a & b).any?
85
+ end
86
+
87
+ public
88
+
53
89
  def to_openapi_hash(scope: nil)
54
90
  result = {}
55
91
  @components.each do |type, components|
@@ -36,21 +36,8 @@ module OpenapiRuby
36
36
  end
37
37
  end
38
38
 
39
- initializer "openapi_ruby.components" do
40
- config = OpenapiRuby.configuration
41
- config.component_paths.each do |path|
42
- expanded = Rails.root.join(path)
43
- next unless expanded.exist?
44
-
45
- # Auto-define modules for subdirectories (Schemas, Parameters, etc.)
46
- expanded.children.select(&:directory?).each do |dir|
47
- mod_name = dir.basename.to_s.camelize.to_sym
48
- Object.const_set(mod_name, Module.new) unless Object.const_defined?(mod_name)
49
- end
50
-
51
- Dir[expanded.join("**", "*.rb")].sort.each { |f| require f }
52
- end
53
- end
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.
54
41
 
55
42
  private
56
43
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OpenapiRuby
4
- VERSION = "2.1.0"
4
+ VERSION = "2.2.0"
5
5
  end
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: 2.1.0
4
+ version: 2.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Morten Hartvig
@@ -86,6 +86,7 @@ files:
86
86
  - lib/generators/openapi_ruby/install/install_generator.rb
87
87
  - lib/generators/openapi_ruby/install/templates/initializer.rb.tt
88
88
  - lib/generators/openapi_ruby/install/templates/openapi_helper.rb.tt
89
+ - lib/openapi-ruby.rb
89
90
  - lib/openapi_ruby.rb
90
91
  - lib/openapi_ruby/adapters/minitest.rb
91
92
  - lib/openapi_ruby/adapters/rspec.rb