openapi-ruby 0.1.0 → 2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1f245f12d9c4ef8bfb96c0eff05a0ca82ad6c9c883846f23bcad9c53ce1a8b5c
4
- data.tar.gz: d01ed0f0c3e268762fb212da520177c782c03bc5165c0a6059cb63f812f24ad5
3
+ metadata.gz: f4d1e0c7be33a19d1264716a6864c964451717c477a2d8be113c636d7ac12744
4
+ data.tar.gz: ef006bb3472303c4aeee1faf6291822a22e0c02c9f2bf81fc7f7296462bb5324
5
5
  SHA512:
6
- metadata.gz: 177354fea7faf025cc7922a718e65767139a03cc6bc096fc67ab6c60830ed091718f2b665bb61be96402c0460a1dbf4451d60f7a300cc46752c9bb763133d0d6
7
- data.tar.gz: 32c8a6d4ad9d6cc0155c80051756eab313e0e0b71c71802d1b0efe1fe70f9f03635a0b66cad462b4700fd108a04ce9ffc6f2e2be894d850596c1cf6b0872236a
6
+ metadata.gz: 80cf5378dfb48cdec73b9a19a57449b4891eddf7282f1ff3e251d77f4f04c7e876fa87ec89feef8dcf5bb10cea22ecd12f1c5dc30fd68a175ecc04ba44a26e1d
7
+ data.tar.gz: 0f3bf8c4096823a12426ec2c289be4200ec45b27262e4f4a991b67d08fa17a65d4673dc0fabda1c67cb9e634ceedb4ace95bc5187034b84ea09920efa0711dd7
@@ -43,9 +43,16 @@ module OpenapiRuby
43
43
  request_params = body || params.reject { |k, _| path_param_names(context).include?(k.to_s) }
44
44
  request_headers = headers.dup
45
45
 
46
- if body.is_a?(Hash)
47
- request_params = body.to_json
48
- request_headers["Content-Type"] ||= "application/json"
46
+ if body
47
+ content_type = request_headers["Content-Type"] || context.operations[method.to_s]&.instance_variable_get(:@consumes_list)&.first
48
+
49
+ if content_type&.include?("form-data") || content_type&.include?("x-www-form-urlencoded")
50
+ request_params = body
51
+ request_headers["Content-Type"] ||= content_type
52
+ else
53
+ request_params = body.is_a?(String) ? body : body.to_json
54
+ request_headers["Content-Type"] ||= content_type || "application/json"
55
+ end
49
56
  end
50
57
 
51
58
  send_args = {params: request_params}
@@ -170,9 +170,16 @@ module OpenapiRuby
170
170
  send_args = {params: body || params}
171
171
  send_args[:headers] = headers if headers.any?
172
172
 
173
- if (body && headers["Content-Type"]&.include?("json")) || body.is_a?(Hash)
174
- send_args[:params] = body.is_a?(String) ? body : body.to_json
175
- send_args[:headers] = (headers || {}).merge("Content-Type" => "application/json")
173
+ if body
174
+ content_type = headers["Content-Type"] || operation&.instance_variable_get(:@consumes_list)&.first
175
+
176
+ if content_type&.include?("form-data") || content_type&.include?("x-www-form-urlencoded")
177
+ send_args[:params] = body
178
+ send_args[:headers] = (headers || {}).merge("Content-Type" => content_type)
179
+ else
180
+ send_args[:params] = body.is_a?(String) ? body : body.to_json
181
+ send_args[:headers] = (headers || {}).merge("Content-Type" => content_type || "application/json")
182
+ end
176
183
  end
177
184
 
178
185
  send(method.to_sym, path, **send_args)
@@ -10,6 +10,7 @@ module OpenapiRuby
10
10
  base.class_attribute :_skip_key_transformation, default: false
11
11
  base.class_attribute :_component_type, default: :schemas
12
12
  base.class_attribute :_component_scopes, default: []
13
+ base.class_attribute :_component_scopes_explicitly_set, default: false
13
14
 
14
15
  Registry.instance.register(base) if base.name
15
16
  end
@@ -22,6 +23,7 @@ module OpenapiRuby
22
23
  subclass._skip_key_transformation = _skip_key_transformation
23
24
  subclass._component_type = _component_type
24
25
  subclass._component_scopes = _component_scopes.dup
26
+ subclass._component_scopes_explicitly_set = _component_scopes_explicitly_set
25
27
  Registry.instance.register(subclass) if subclass.name
26
28
  end
27
29
 
@@ -45,13 +47,29 @@ module OpenapiRuby
45
47
  end
46
48
 
47
49
  def component_scopes(*scopes)
50
+ Registry.instance.unregister(self)
48
51
  self._component_scopes = scopes.flatten.map(&:to_sym)
52
+ self._component_scopes_explicitly_set = true
53
+ Registry.instance.register(self)
54
+ end
55
+
56
+ def shared_component
57
+ self._component_scopes = []
58
+ self._component_scopes_explicitly_set = true
49
59
  end
50
60
 
51
61
  def component_name
52
62
  (name || "Anonymous").demodulize
53
63
  end
54
64
 
65
+ def registry_key
66
+ if _component_scopes.empty?
67
+ component_name
68
+ else
69
+ "#{_component_scopes.sort.join("_")}:#{component_name}"
70
+ end
71
+ end
72
+
55
73
  def to_openapi
56
74
  definition = _schema_definition.deep_dup
57
75
 
@@ -12,7 +12,7 @@ module OpenapiRuby
12
12
 
13
13
  def load!
14
14
  define_namespace_modules!
15
- component_files.each { |f| require f }
15
+ load_component_files!
16
16
  self
17
17
  end
18
18
 
@@ -63,13 +63,70 @@ module OpenapiRuby
63
63
  expanded = File.expand_path(path)
64
64
  next unless Dir.exist?(expanded)
65
65
 
66
- Dir.children(expanded).select { |f| File.directory?(File.join(expanded, f)) }.each do |dir|
67
- mod_name = dir.camelize.to_sym
68
- Object.const_set(mod_name, Module.new) unless Object.const_defined?(mod_name)
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)
77
+ end
78
+ end
79
+
80
+ def load_component_files!
81
+ scope_paths = OpenapiRuby.configuration.component_scope_paths
82
+
83
+ if scope_paths.any?
84
+ load_with_scope_inference(scope_paths)
85
+ else
86
+ component_files.each { |f| require f }
87
+ end
88
+ end
89
+
90
+ def load_with_scope_inference(scope_paths)
91
+ @paths.each do |base_path|
92
+ expanded = File.expand_path(base_path)
93
+ next unless Dir.exist?(expanded)
94
+
95
+ files = Dir[File.join(expanded, "**", "*.rb")].sort
96
+
97
+ files.each do |file|
98
+ 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
117
+ end
69
118
  end
70
119
  end
71
120
  end
72
121
 
122
+ def infer_scope(relative_path, scope_paths)
123
+ # Match longest prefix first for specificity
124
+ scope_paths.sort_by { |prefix, _| -prefix.length }.each do |prefix, scope|
125
+ return scope&.to_sym if relative_path.start_with?("#{prefix}/")
126
+ end
127
+ nil
128
+ end
129
+
73
130
  def component_files
74
131
  @paths.flat_map do |path|
75
132
  expanded = File.expand_path(path)
@@ -13,21 +13,21 @@ module OpenapiRuby
13
13
 
14
14
  def register(component_class)
15
15
  type = component_class._component_type
16
- name = component_class.component_name
16
+ key = component_class.registry_key
17
17
 
18
18
  @components[type] ||= {}
19
19
 
20
- if @components[type].key?(name) && @components[type][name] != component_class
21
- raise DuplicateComponentError, "Component '#{name}' already registered under #{type}"
20
+ if @components[type].key?(key) && @components[type][key] != component_class
21
+ raise DuplicateComponentError, "Component '#{component_class.component_name}' already registered under #{type}"
22
22
  end
23
23
 
24
- @components[type][name] = component_class
24
+ @components[type][key] = component_class
25
25
  end
26
26
 
27
27
  def unregister(component_class)
28
28
  type = component_class._component_type
29
- name = component_class.component_name
30
- @components[type]&.delete(name)
29
+ key = component_class.registry_key
30
+ @components[type]&.delete(key)
31
31
  end
32
32
 
33
33
  def components_for(type)
@@ -38,6 +38,10 @@ module OpenapiRuby
38
38
  @components.keys
39
39
  end
40
40
 
41
+ def all_registered_classes
42
+ @components.values.flat_map(&:values)
43
+ end
44
+
41
45
  def grouped_by_type
42
46
  @components.dup
43
47
  end
@@ -8,8 +8,12 @@ module OpenapiRuby
8
8
 
9
9
  # Components
10
10
  attr_accessor :component_paths
11
+ attr_accessor :component_scope_paths
11
12
  attr_accessor :camelize_keys, :key_transform, :response_validation, :strict_query_params,
12
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
15
+ attr_accessor :auto_validation_error_response
16
+ attr_accessor :validation_error_schema
13
17
 
14
18
  # Middleware (runtime validation)
15
19
  attr_accessor :request_validation
@@ -26,6 +30,7 @@ module OpenapiRuby
26
30
  def initialize
27
31
  @schemas = {}
28
32
  @component_paths = ["app/api_components"]
33
+ @component_scope_paths = {}
29
34
  @camelize_keys = true
30
35
  @key_transform = nil
31
36
  @request_validation = :disabled
@@ -39,6 +44,9 @@ module OpenapiRuby
39
44
  @ui_enabled = false
40
45
  @ui_path = "/api-docs"
41
46
  @ui_config = {}
47
+ @strict_reference_validation = true
48
+ @auto_validation_error_response = true
49
+ @validation_error_schema = nil
42
50
  @coverage_enabled = false
43
51
  @coverage_report_path = "tmp/openapi_coverage.json"
44
52
  end
@@ -3,13 +3,15 @@
3
3
  module OpenapiRuby
4
4
  module Core
5
5
  class Document
6
- OPENAPI_VERSION = "3.1.0"
6
+ MIN_OPENAPI_VERSION = "3.1.0"
7
+ DEFAULT_OPENAPI_VERSION = "3.1.0"
7
8
 
8
9
  attr_reader :data
9
10
 
10
- def initialize(info: {}, servers: [])
11
+ def initialize(info: {}, servers: [], openapi_version: DEFAULT_OPENAPI_VERSION)
12
+ validate_version!(openapi_version)
11
13
  @data = {
12
- "openapi" => OPENAPI_VERSION,
14
+ "openapi" => openapi_version,
13
15
  "info" => normalize_info(info),
14
16
  "paths" => {}
15
17
  }
@@ -34,7 +36,13 @@ module OpenapiRuby
34
36
  end
35
37
 
36
38
  def to_h
37
- @data
39
+ result = @data.dup
40
+ result["paths"] = result["paths"].sort.to_h if result["paths"]
41
+ if result["components"]
42
+ result["components"] = result["components"].transform_values { |v| v.sort.to_h }
43
+ end
44
+ result["tags"] = result["tags"].sort_by { |t| t["name"].to_s } if result["tags"]
45
+ result
38
46
  end
39
47
 
40
48
  def to_json(*_args)
@@ -59,6 +67,13 @@ module OpenapiRuby
59
67
 
60
68
  private
61
69
 
70
+ def validate_version!(version)
71
+ if Gem::Version.new(version) < Gem::Version.new(MIN_OPENAPI_VERSION)
72
+ raise OpenapiRuby::ConfigurationError,
73
+ "OpenAPI version must be >= #{MIN_OPENAPI_VERSION}, got #{version}"
74
+ end
75
+ end
76
+
62
77
  def normalize_info(info)
63
78
  result = info.transform_keys(&:to_s)
64
79
  result["title"] ||= ""
@@ -7,7 +7,8 @@ module OpenapiRuby
7
7
  @spec_config = spec_config
8
8
  @document = Document.new(
9
9
  info: spec_config[:info] || {},
10
- servers: spec_config[:servers] || []
10
+ servers: spec_config[:servers] || [],
11
+ openapi_version: spec_config[:openapi_version] || Document::DEFAULT_OPENAPI_VERSION
11
12
  )
12
13
  @paths = {}
13
14
  @components = {}
@@ -33,6 +34,7 @@ module OpenapiRuby
33
34
  end
34
35
 
35
36
  def build
37
+ inject_validation_error_responses! if OpenapiRuby.configuration.auto_validation_error_response
36
38
  @paths.each { |template, path_item| @document.add_path(template, path_item) }
37
39
  @document.set_components(@components)
38
40
  @document.set_security(@security)
@@ -43,6 +45,48 @@ module OpenapiRuby
43
45
  def to_h
44
46
  build.to_h
45
47
  end
48
+
49
+ private
50
+
51
+ def inject_validation_error_responses!
52
+ # Add ValidationError response component
53
+ @components["responses"] ||= {}
54
+ @components["responses"]["ValidationError"] ||= {
55
+ "description" => "Request validation failed",
56
+ "content" => {
57
+ "application/json" => {
58
+ "schema" => validation_error_component_schema
59
+ }
60
+ }
61
+ }
62
+
63
+ # Add 400 to every operation that doesn't already define one
64
+ @paths.each_value do |path_item|
65
+ path_item.each do |key, operation|
66
+ next unless operation.is_a?(Hash) && operation.key?("responses")
67
+ next if key == "parameters"
68
+
69
+ operation["responses"]["400"] ||= {"$ref" => "#/components/responses/ValidationError"}
70
+ end
71
+ end
72
+ end
73
+
74
+ def validation_error_component_schema
75
+ custom = OpenapiRuby.configuration.validation_error_schema
76
+ return custom if custom
77
+
78
+ {
79
+ "type" => "object",
80
+ "properties" => {
81
+ "error" => {"type" => "string"},
82
+ "details" => {
83
+ "type" => "array",
84
+ "items" => {"type" => "string"}
85
+ }
86
+ },
87
+ "required" => %w[error details]
88
+ }
89
+ end
46
90
  end
47
91
  end
48
92
  end
@@ -8,21 +8,29 @@ module OpenapiRuby
8
8
  config = OpenapiRuby.configuration
9
9
 
10
10
  if config.request_validation != :disabled || config.response_validation != :disabled
11
- schema_path = default_schema_path(config)
11
+ config.schemas.each do |name, schema_config|
12
+ schema_path = resolve_schema_path(config, name)
13
+ next unless schema_path && File.exist?(schema_path)
12
14
 
13
- if schema_path && File.exist?(schema_path)
14
- resolver = Middleware::SchemaResolver.new(spec_path: schema_path)
15
+ resolver = Middleware::SchemaResolver.new(
16
+ spec_path: schema_path,
17
+ strict_reference_validation: config.strict_reference_validation
18
+ )
19
+
20
+ prefix = schema_config[:prefix]
15
21
 
16
22
  if config.request_validation != :disabled
17
23
  app.middleware.use Middleware::RequestValidation,
18
24
  schema_resolver: resolver,
19
- mode: config.request_validation
25
+ mode: config.request_validation,
26
+ prefix: prefix
20
27
  end
21
28
 
22
29
  if config.response_validation != :disabled
23
30
  app.middleware.use Middleware::ResponseValidation,
24
31
  schema_resolver: resolver,
25
- mode: config.response_validation
32
+ mode: config.response_validation,
33
+ prefix: prefix
26
34
  end
27
35
  end
28
36
  end
@@ -46,10 +54,7 @@ module OpenapiRuby
46
54
 
47
55
  private
48
56
 
49
- def default_schema_path(config)
50
- return nil if config.schemas.empty?
51
-
52
- schema_name = config.schemas.keys.first
57
+ def resolve_schema_path(config, schema_name)
53
58
  ext = (config.schema_output_format == :json) ? "json" : "yaml"
54
59
  Rails.root.join(config.schema_output_dir, "#{schema_name}.#{ext}").to_s
55
60
  end
@@ -23,6 +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
27
  output_path = File.join(output_dir, filename)
27
28
  FileUtils.mkdir_p(output_dir)
28
29
  File.write(output_path, format_output(document))
@@ -56,6 +57,14 @@ module OpenapiRuby
56
57
  "#{@schema_name}.#{ext}"
57
58
  end
58
59
 
60
+ def validate_document!(document)
61
+ errors = document.validate
62
+ return if errors.empty?
63
+
64
+ 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
+ end
67
+
59
68
  def format_output(document)
60
69
  if OpenapiRuby.configuration.schema_output_format == :json
61
70
  document.to_json
@@ -10,13 +10,22 @@ module OpenapiRuby
10
10
  @coerce = options.fetch(:coerce, OpenapiRuby.configuration.coerce_params)
11
11
  @error_handler = options[:error_handler] || ErrorHandler.new
12
12
  @mode = options.fetch(:mode, OpenapiRuby.configuration.request_validation)
13
+ @prefix = options[:prefix]
13
14
  end
14
15
 
15
16
  def call(env)
16
17
  return @app.call(env) if @mode == :disabled
17
18
 
18
19
  request = Rack::Request.new(env)
19
- result = @resolver.find_operation(request.request_method, request.path_info)
20
+
21
+ # Skip if request doesn't match prefix
22
+ if @prefix && !request.path_info.start_with?(@prefix)
23
+ return @app.call(env)
24
+ end
25
+
26
+ # Strip prefix for path matching
27
+ match_path = @prefix ? request.path_info.sub(@prefix, "") : request.path_info
28
+ result = @resolver.find_operation(request.request_method, match_path)
20
29
 
21
30
  if result.nil?
22
31
  return strict? ? @error_handler.not_found(request.path_info) : @app.call(env)
@@ -194,6 +203,11 @@ module OpenapiRuby
194
203
 
195
204
  if media_type&.include?("json")
196
205
  JSON.parse(content)
206
+ elsif media_type&.include?("x-www-form-urlencoded")
207
+ Rack::Utils.parse_nested_query(content)
208
+ elsif media_type&.include?("form-data")
209
+ # Multipart form data is already parsed by Rack into params
210
+ nil
197
211
  else
198
212
  content
199
213
  end
@@ -9,11 +9,19 @@ module OpenapiRuby
9
9
  @error_handler = options[:error_handler] || ErrorHandler.new
10
10
  @mode = options.fetch(:mode, OpenapiRuby.configuration.response_validation)
11
11
  @validate_success_only = options.fetch(:validate_success_only, true)
12
+ @prefix = options[:prefix]
12
13
  end
13
14
 
14
15
  def call(env)
15
16
  return @app.call(env) if @mode == :disabled
16
17
 
18
+ request = Rack::Request.new(env)
19
+
20
+ # Skip if request doesn't match prefix
21
+ if @prefix && !request.path_info.start_with?(@prefix)
22
+ return @app.call(env)
23
+ end
24
+
17
25
  status, headers, body = @app.call(env)
18
26
 
19
27
  # Skip validation for certain status codes
@@ -3,15 +3,16 @@
3
3
  module OpenapiRuby
4
4
  module Middleware
5
5
  class SchemaResolver
6
- def initialize(spec_path: nil, document: nil)
6
+ def initialize(spec_path: nil, document: nil, strict_reference_validation: true)
7
7
  @spec_path = spec_path
8
8
  @document = document
9
+ @strict_reference_validation = strict_reference_validation
9
10
  @path_matcher = nil
10
11
  @schemer = nil
11
12
  end
12
13
 
13
14
  def document
14
- @document ||= load_document
15
+ @document ||= load_document.tap { |doc| validate_document!(doc) }
15
16
  end
16
17
 
17
18
  def schemer
@@ -39,6 +40,18 @@ module OpenapiRuby
39
40
 
40
41
  private
41
42
 
43
+ def validate_document!(doc)
44
+ return unless @strict_reference_validation
45
+
46
+ schemer = JSONSchemer.openapi(doc)
47
+ errors = schemer.validate.to_a
48
+ return if errors.empty?
49
+
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")}"
53
+ end
54
+
42
55
  def load_document
43
56
  raise ConfigurationError, "No spec_path configured for middleware" unless @spec_path
44
57
 
@@ -62,9 +62,17 @@ module OpenapiRuby
62
62
  def build_body
63
63
  return nil unless @body_value
64
64
 
65
- if @body_value.is_a?(Hash) || @body_value.is_a?(Array)
65
+ content_type = @headers&.fetch("Content-Type", nil)
66
+ consumes = @operation.instance_variable_get(:@consumes_list)
67
+ content_type ||= consumes&.first
68
+
69
+ if content_type&.include?("form-data") || content_type&.include?("x-www-form-urlencoded")
70
+ @body_value
71
+ elsif @body_value.is_a?(Hash) || @body_value.is_a?(Array)
72
+ @body_value.to_json
73
+ else
74
+ @body_value
66
75
  end
67
- @body_value
68
76
  end
69
77
 
70
78
  def deep_stringify(value)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OpenapiRuby
4
- VERSION = "0.1.0"
4
+ VERSION = "2.0.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: 0.1.0
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Morten Hartvig
@@ -140,7 +140,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
140
140
  - !ruby/object:Gem::Version
141
141
  version: '0'
142
142
  requirements: []
143
- rubygems_version: 4.0.6
143
+ rubygems_version: 3.6.9
144
144
  specification_version: 4
145
145
  summary: OpenAPI 3.1 toolkit for Rails — spec generation, schema components, and runtime
146
146
  validation