grape-oas 1.5.0 → 1.6.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.
Files changed (26) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -0
  3. data/UPGRADING.md +38 -0
  4. data/lib/grape_oas/api_model_builder.rb +5 -4
  5. data/lib/grape_oas/api_model_builders/request.rb +8 -13
  6. data/lib/grape_oas/api_model_builders/request_params.rb +38 -41
  7. data/lib/grape_oas/api_model_builders/request_params_support/nested_params_builder.rb +0 -1
  8. data/lib/grape_oas/api_model_builders/request_params_support/param_location_resolver.rb +33 -27
  9. data/lib/grape_oas/api_model_builders/request_params_support/param_schema_builder.rb +7 -26
  10. data/lib/grape_oas/api_model_builders/request_params_support/schema_enhancer.rb +1 -1
  11. data/lib/grape_oas/api_model_builders/response.rb +5 -1
  12. data/lib/grape_oas/api_model_builders/response_parsers/base.rb +17 -0
  13. data/lib/grape_oas/api_model_builders/response_parsers/default_response_parser.rb +4 -7
  14. data/lib/grape_oas/api_model_builders/response_parsers/documentation_responses_parser.rb +5 -2
  15. data/lib/grape_oas/api_model_builders/response_parsers/http_codes_parser.rb +96 -28
  16. data/lib/grape_oas/constants.rb +14 -0
  17. data/lib/grape_oas/exporter/concerns/enum_normalizer.rb +35 -0
  18. data/lib/grape_oas/exporter/concerns/schema_indexer.rb +18 -1
  19. data/lib/grape_oas/exporter/oas2/parameter.rb +55 -30
  20. data/lib/grape_oas/exporter/oas2/schema.rb +9 -26
  21. data/lib/grape_oas/exporter/oas3/schema.rb +26 -39
  22. data/lib/grape_oas/introspectors/entity_introspector_support/exposure_processor.rb +40 -6
  23. data/lib/grape_oas/type_resolvers/array_resolver.rb +7 -14
  24. data/lib/grape_oas/version.rb +1 -1
  25. data/lib/grape_oas.rb +19 -0
  26. metadata +3 -2
@@ -3,20 +3,31 @@
3
3
  module GrapeOAS
4
4
  module ApiModelBuilders
5
5
  module ResponseParsers
6
- # Parser for responses defined via :http_codes, :failure, or :success options
7
- # These are legacy grape-swagger formats that we support for compatibility
6
+ # Parser for responses defined via :http_codes, :failure, :success, or
7
+ # :default / :default_response (the OAS "default" catch-all).
8
8
  class HttpCodesParser
9
9
  include Base
10
10
 
11
+ DEFAULT_RESPONSE_MESSAGE = "Default Response"
12
+ DEFAULT_RESPONSE_CODE = "default"
13
+
11
14
  def applicable?(route)
12
15
  options_applicable?(route) || desc_block?(route)
13
16
  end
14
17
 
15
18
  def parse(route)
16
19
  specs = parse_from_options(route)
17
- return specs unless specs.empty?
20
+ specs = parse_from_desc(route) if specs.empty?
21
+ return specs if specs.any? { |spec| spec[:code].to_s == DEFAULT_RESPONSE_CODE }
22
+
23
+ specs + default_response_specs(route)
24
+ end
18
25
 
19
- parse_from_desc(route)
26
+ def default_response_specs(route)
27
+ data = route.options
28
+ value = default_response_value(data)
29
+ value ||= default_response_value(desc_data(route))
30
+ value ? parse_default_response(value, route) : []
20
31
  end
21
32
 
22
33
  private
@@ -46,15 +57,65 @@ module GrapeOAS
46
57
  def parse_values(data, route)
47
58
  return [] unless data.is_a?(Hash)
48
59
 
49
- %i[http_codes failure success].flat_map do |key|
50
- parse_value(data[key], route)
60
+ specs = %i[http_codes failure success].flat_map do |key|
61
+ parse_value(data[key], route, success: key == :success)
62
+ end
63
+ default_value = data[:default_response] || data[:default]
64
+ return specs unless default_value
65
+
66
+ specs.reject { |spec| spec[:code].to_s == DEFAULT_RESPONSE_CODE } +
67
+ parse_default_response(default_value, route)
68
+ end
69
+
70
+ # Grape 3.x stores `desc { default ... }` as :default. Grape 4.0
71
+ # (ruby-grape/grape#2861) renamed that key to :default_response and
72
+ # remaps the deprecated `default` alias at write time. Always emit the
73
+ # OAS "default" status — grape-swagger ignores a numeric `code:` here.
74
+ def parse_default_response(value, route)
75
+ entries_for(value).map { |entry| normalize_default_response_entry(entry, route) }
76
+ end
77
+
78
+ def normalize_default_response_entry(entry, route)
79
+ if entry.is_a?(Hash)
80
+ entry = normalize_hash_keys(entry)
81
+ {
82
+ code: DEFAULT_RESPONSE_CODE,
83
+ message: extract_description(entry) || DEFAULT_RESPONSE_MESSAGE,
84
+ entity: extract_entity(entry, nil),
85
+ headers: entry[:headers],
86
+ examples: entry[:examples],
87
+ as: entry[:as],
88
+ one_of: normalize_one_of(entry[:one_of]),
89
+ is_array: entry.key?(:is_array) ? entry[:is_array] : route.options[:is_array],
90
+ required: entry[:required]
91
+ }
92
+ else
93
+ {
94
+ code: DEFAULT_RESPONSE_CODE,
95
+ message: DEFAULT_RESPONSE_MESSAGE,
96
+ entity: entry,
97
+ headers: nil,
98
+ is_array: route.options[:is_array]
99
+ }
51
100
  end
52
101
  end
53
102
 
54
- def parse_value(value, route)
103
+ def default_response_value(data)
104
+ return unless data.is_a?(Hash)
105
+
106
+ data[:default_response] || data[:default]
107
+ end
108
+
109
+ def normalize_one_of(one_of)
110
+ return one_of unless one_of.is_a?(Array)
111
+
112
+ one_of.map { |entry| normalize_hash_keys(entry) }
113
+ end
114
+
115
+ def parse_value(value, route, success:)
55
116
  return [] unless value
56
117
 
57
- entries_for(value).map { |entry| normalize_entry(entry, route) }
118
+ entries_for(value).map { |entry| normalize_entry(entry, route, success: success) }
58
119
  end
59
120
 
60
121
  def entries_for(value)
@@ -73,12 +134,14 @@ module GrapeOAS
73
134
  def options_applicable?(route)
74
135
  entity_hash = route.options[:entity].is_a?(Hash) ? route.options[:entity] : nil
75
136
  route.options[:http_codes] || route.options[:failure] || route.options[:success] ||
137
+ route.options[:default] || route.options[:default_response] ||
76
138
  (entity_hash && (entity_hash[:code] || entity_hash[:model] || entity_hash[:entity] || entity_hash[:one_of]))
77
139
  end
78
140
 
79
141
  def desc_block?(route)
80
142
  data = desc_data(route)
81
- data && (data[:success] || data[:failure] || data[:http_codes] || data[:entity])
143
+ data && (data[:success] || data[:failure] || data[:http_codes] || data[:entity] ||
144
+ data[:default] || data[:default_response])
82
145
  end
83
146
 
84
147
  def desc_block_has_explicit_success?(route)
@@ -86,6 +149,9 @@ module GrapeOAS
86
149
  end
87
150
 
88
151
  def append_entity_spec(specs, entity_value, route)
152
+ explicit_code = (entity_value[:code] if entity_value.is_a?(Hash)) || route.options[:default_status]
153
+ return specs if !explicit_code && specs.any? { |spec| spec[:code] == "2XX" || (200..299).cover?(spec[:code].to_i) }
154
+
89
155
  entity_spec = build_entity_spec(entity_value, route)
90
156
  return specs if specs.any? { |spec| spec[:code].to_i == entity_spec[:code].to_i }
91
157
 
@@ -96,7 +162,7 @@ module GrapeOAS
96
162
  if entity_value.is_a?(Hash)
97
163
  # Hash format: { code: 201, model: Entity, message: "Created" }
98
164
  {
99
- code: entity_value[:code] || 200,
165
+ code: entity_value[:code] || default_response_code(route, success: true),
100
166
  message: entity_value[:message],
101
167
  entity: extract_entity(entity_value, nil),
102
168
  headers: entity_value[:headers],
@@ -109,7 +175,7 @@ module GrapeOAS
109
175
  else
110
176
  # Plain entity class
111
177
  {
112
- code: 200,
178
+ code: default_response_code(route, success: true),
113
179
  message: nil,
114
180
  entity: entity_value,
115
181
  headers: nil,
@@ -121,24 +187,26 @@ module GrapeOAS
121
187
  end
122
188
  end
123
189
 
124
- def normalize_entry(entry, route)
125
- case entry
126
- when Hash
127
- normalize_hash_entry(entry, route)
128
- when Array
129
- normalize_array_entry(entry, route)
130
- when Class, Module
131
- # Plain entity class (e.g., success TestEntity)
132
- normalize_entity_entry(entry, route)
133
- else
134
- normalize_plain_entry(entry, route)
135
- end
190
+ def normalize_entry(entry, route, success:)
191
+ spec = case entry
192
+ when Hash
193
+ normalize_hash_entry(entry, route, success: success)
194
+ when Array
195
+ normalize_array_entry(entry, route)
196
+ when Class, Module
197
+ # Plain entity class (e.g., success TestEntity)
198
+ normalize_entity_entry(entry, route, success: success)
199
+ else
200
+ normalize_plain_entry(entry, route)
201
+ end
202
+ spec[:code] = normalize_status_code(spec[:code])
203
+ spec
136
204
  end
137
205
 
138
- def normalize_hash_entry(entry, route)
139
- default_code = (route.options[:default_status] || 200).to_s
206
+ def normalize_hash_entry(entry, route, success:)
207
+ default_code = default_response_code(route, success: success)
140
208
  {
141
- code: extract_status_code(entry, default_code),
209
+ code: extract_status_code(entry, default_code.to_s),
142
210
  message: extract_description(entry),
143
211
  entity: extract_entity(entry, route.options[:entity]),
144
212
  headers: entry[:headers],
@@ -163,10 +231,10 @@ module GrapeOAS
163
231
  }
164
232
  end
165
233
 
166
- def normalize_entity_entry(entity_class, route)
234
+ def normalize_entity_entry(entity_class, route, success:)
167
235
  # Plain entity class (e.g., success TestEntity)
168
236
  {
169
- code: route.options[:default_status] || 200,
237
+ code: default_response_code(route, success: success),
170
238
  message: nil,
171
239
  entity: entity_class,
172
240
  headers: nil,
@@ -72,6 +72,20 @@ module GrapeOAS
72
72
  VARIANT_COLLECTION = /\A(?<container>Array|Set)\[(?<inner>#{CONST_NAME}(?:,\s*#{CONST_NAME})+)\]\z/
73
73
  end
74
74
 
75
+ def self.nil_type?(type)
76
+ return true if type.nil?
77
+
78
+ name = type.to_s
79
+ name == "NilClass" || name == "Nil" || name.end_with?("::Nil")
80
+ end
81
+
82
+ def self.nullable_type(types)
83
+ return unless types.is_a?(Array) && types.size == 2
84
+
85
+ nil_types, other_types = types.partition { |type| nil_type?(type) }
86
+ other_types.first if nil_types.size == 1
87
+ end
88
+
75
89
  # Default values for OpenAPI spec when not provided by user
76
90
  module Defaults
77
91
  LICENSE_NAME = "Proprietary"
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GrapeOAS
4
+ module Exporter
5
+ module Concerns
6
+ module EnumNormalizer
7
+ private
8
+
9
+ def normalize_enum(enum_values, type, preserve_nil: false)
10
+ return nil unless enum_values.is_a?(Array)
11
+
12
+ base_type = base_type_for(type)
13
+ normalized = enum_values.each_with_object([]) do |value, values|
14
+ next if value.nil?
15
+
16
+ normalized_value = case base_type
17
+ when Constants::SchemaTypes::INTEGER then value.to_i if value.respond_to?(:to_i)
18
+ when Constants::SchemaTypes::NUMBER then value.to_f if value.respond_to?(:to_f)
19
+ else value
20
+ end
21
+ values << normalized_value unless normalized_value.nil?
22
+ end
23
+
24
+ normalized.uniq!
25
+ normalized << nil if preserve_nil && enum_values.include?(nil)
26
+ normalized unless normalized.empty?
27
+ end
28
+
29
+ def base_type_for(type)
30
+ type.is_a?(Array) ? (type - [Constants::SchemaTypes::NULL]).first : type
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -7,13 +7,30 @@ module GrapeOAS
7
7
  # Handles building schema indexes from operations and collecting nested schema references.
8
8
  module SchemaIndexer
9
9
  def find_schema_by_canonical_name(canonical_name)
10
- @ref_schemas[canonical_name] || schema_index[canonical_name]
10
+ schema = @ref_schemas[canonical_name] || schema_index[canonical_name]
11
+ ensure_unique_schema_ref_name!(canonical_name) if schema
12
+ schema
11
13
  end
12
14
 
13
15
  def schema_index
14
16
  @schema_index ||= build_schema_index
15
17
  end
16
18
 
19
+ def ensure_unique_schema_ref_name!(canonical_name)
20
+ ref_name = GrapeOAS.schema_ref_name.call(canonical_name)
21
+ canonical_names_by_ref = @canonical_names_by_ref ||= {}
22
+ existing_name = canonical_names_by_ref[ref_name]
23
+
24
+ if existing_name && existing_name != canonical_name
25
+ raise ArgumentError,
26
+ "Schema reference name #{ref_name.inspect} is generated for both " \
27
+ "#{existing_name.inspect} and #{canonical_name.inspect}; " \
28
+ "configure GrapeOAS.schema_ref_name to generate unique names"
29
+ end
30
+
31
+ canonical_names_by_ref[ref_name] = canonical_name
32
+ end
33
+
17
34
  def build_schema_index
18
35
  index = {}
19
36
  # Index schemas from operations
@@ -4,6 +4,8 @@ module GrapeOAS
4
4
  module Exporter
5
5
  module OAS2
6
6
  class Parameter
7
+ include Concerns::EnumNormalizer
8
+
7
9
  PRIMITIVE_MAPPINGS = {
8
10
  Constants::SchemaTypes::INTEGER => { type: Constants::SchemaTypes::INTEGER },
9
11
  "long" => { type: Constants::SchemaTypes::INTEGER, format: "int64" },
@@ -28,7 +30,7 @@ module GrapeOAS
28
30
  FORM_MEDIA_TYPES = %w[application/x-www-form-urlencoded multipart/form-data].freeze
29
31
 
30
32
  def build
31
- params = Array(@op.parameters).map { |param| build_parameter(param) }
33
+ params = representable_parameters.map { |param| build_parameter(param) }
32
34
  if @op.request_body
33
35
  if form_only_request?
34
36
  params.concat(build_form_parameters(@op.request_body))
@@ -41,10 +43,53 @@ module GrapeOAS
41
43
 
42
44
  private
43
45
 
46
+ # Swagger 2.0 restricts `in` to query|header|path|formData|body and
47
+ # non-body parameters to primitive types or arrays of primitives.
48
+ # Drop unsupported parameters rather than emit an invalid document,
49
+ # since ParamLocationResolver resolves per-parameter without knowing
50
+ # the target OAS version.
51
+ def representable_parameters
52
+ Array(@op.parameters).reject do |param|
53
+ if param.location == "cookie"
54
+ GrapeOAS.logger.warn("Dropping cookie parameter '#{param.name}': not representable in OAS 2.0")
55
+ true
56
+ elsif param.location != "body" &&
57
+ unrepresentable?(param.schema, location: param.location)
58
+ GrapeOAS.logger.warn(
59
+ "Dropping parameter '#{param.name}': schema is not representable as an " \
60
+ "OAS 2.0 #{param.location} parameter",
61
+ )
62
+ true
63
+ else
64
+ false
65
+ end
66
+ end
67
+ end
68
+
69
+ def first_alternative_schema(schema)
70
+ return schema if schema.nil? || schema.type
71
+ return schema if schema.all_of&.any?
72
+ return first_alternative_schema(schema.one_of.first) if schema.one_of&.any?
73
+ return first_alternative_schema(schema.any_of.first) if schema.any_of&.any?
74
+
75
+ schema
76
+ end
77
+
78
+ def unrepresentable?(schema, location:, array_item: false)
79
+ schema = first_alternative_schema(schema)
80
+ return true unless schema&.type
81
+ return true if schema.all_of&.any?
82
+ return unrepresentable?(schema.items, location: location, array_item: true) if schema.type == Constants::SchemaTypes::ARRAY
83
+ return true if schema.type == Constants::SchemaTypes::OBJECT
84
+
85
+ schema.type == Constants::SchemaTypes::FILE && (location != "formData" || array_item)
86
+ end
87
+
44
88
  def build_parameter(param)
45
- type = param.schema&.type
46
- format = param.schema&.format
47
- primitive_types = PRIMITIVE_MAPPINGS.keys + %w[object string boolean file json array number]
89
+ schema = param.location == "body" ? param.schema : first_alternative_schema(param.schema)
90
+ type = schema&.type
91
+ format = schema&.format
92
+ primitive_types = PRIMITIVE_MAPPINGS.keys + %w[string boolean file json array number]
48
93
  is_primitive = type && primitive_types.include?(type)
49
94
 
50
95
  if is_primitive && param.location != "body"
@@ -57,8 +102,8 @@ module GrapeOAS
57
102
  "type" => mapping ? mapping[:type] : type,
58
103
  "format" => format || (mapping ? mapping[:format] : nil)
59
104
  }
60
- apply_schema_constraints(result, param.schema)
61
- apply_collection_format(result, param, type)
105
+ apply_schema_constraints(result, schema)
106
+ apply_collection_format(result, param, schema)
62
107
  result.compact
63
108
  else
64
109
  {
@@ -66,7 +111,7 @@ module GrapeOAS
66
111
  "in" => param.location,
67
112
  "required" => param.required,
68
113
  "description" => param.description,
69
- "schema" => build_schema_or_ref(param.schema)
114
+ "schema" => build_schema_or_ref(schema)
70
115
  }.tap do |h|
71
116
  h["type"] = type if type
72
117
  h["format"] = format if format
@@ -91,30 +136,10 @@ module GrapeOAS
91
136
  result["default"] = schema.default if schema.respond_to?(:default) && !schema.default.nil?
92
137
  end
93
138
 
94
- def normalize_enum(enum_vals, type)
95
- return nil unless enum_vals.is_a?(Array)
96
-
97
- result = enum_vals.each_with_object([]) do |v, acc|
98
- next if v.nil?
99
-
100
- coerced_v = case type
101
- when Constants::SchemaTypes::INTEGER then v.to_i if v.respond_to?(:to_i)
102
- when Constants::SchemaTypes::NUMBER then v.to_f if v.respond_to?(:to_f)
103
- else v
104
- end
105
- acc << coerced_v unless coerced_v.nil?
106
- end
107
-
108
- result.uniq!
109
- return nil if result.empty?
110
-
111
- result
112
- end
113
-
114
- def apply_collection_format(result, param, type)
115
- return unless type == Constants::SchemaTypes::ARRAY
139
+ def apply_collection_format(result, param, schema)
140
+ return unless schema.type == Constants::SchemaTypes::ARRAY
116
141
 
117
- result["items"] = build_schema_or_ref(param.schema.items) if param.schema.items
142
+ result["items"] = build_schema_or_ref(schema.items) if schema.items
118
143
  return unless param.collection_format
119
144
 
120
145
  valid_formats = %w[csv ssv tsv pipes multi brackets]
@@ -4,6 +4,8 @@ module GrapeOAS
4
4
  module Exporter
5
5
  module OAS2
6
6
  class Schema
7
+ include Concerns::EnumNormalizer
8
+
7
9
  def initialize(schema, ref_tracker = nil, nullable_strategy: nil, composition_extensions: false)
8
10
  @schema = schema
9
11
  @ref_tracker = ref_tracker
@@ -38,7 +40,7 @@ module GrapeOAS
38
40
  "description" => @schema.description&.to_s,
39
41
  "properties" => build_properties(@schema.properties)
40
42
  }
41
- schema_hash["enum"] = normalize_enum(@schema.enum, @schema.type, nullable: nullable?) if @schema.enum
43
+ schema_hash["enum"] = normalize_enum(@schema.enum, @schema.type, preserve_nil: enum_allows_null?(@schema)) if @schema.enum
42
44
  if @schema.items
43
45
  schema_hash["items"] = build_schema_or_ref(@schema.items, include_metadata: false)
44
46
  if !schema_hash["description"] && @schema.items.respond_to?(:description) && @schema.items.description
@@ -99,6 +101,10 @@ module GrapeOAS
99
101
  schema_nullable?(@schema)
100
102
  end
101
103
 
104
+ def enum_allows_null?(schema)
105
+ @nullable_strategy == Constants::NullableStrategy::EXTENSION && schema_nullable?(schema)
106
+ end
107
+
102
108
  # OAS2 keeps a first-alternative fallback for tools that ignore extensions.
103
109
  def build_first_of_schema(composition_type)
104
110
  schemas = composition_type == :any_of ? @schema.any_of : @schema.one_of
@@ -130,7 +136,7 @@ module GrapeOAS
130
136
  result["format"] = @schema.format if @schema.format
131
137
  result["description"] = @schema.description.to_s if @schema.description
132
138
  result["default"] = @schema.default unless @schema.default.nil?
133
- result["enum"] = normalize_enum(@schema.enum, @schema.type, nullable: nullable?) if @schema.enum
139
+ result["enum"] = normalize_enum(@schema.enum, @schema.type, preserve_nil: enum_allows_null?(@schema)) if @schema.enum
134
140
  result.delete("enum") if result.key?("enum") && result["enum"].nil?
135
141
  apply_constraints(result)
136
142
  apply_extensions(result)
@@ -156,7 +162,7 @@ module GrapeOAS
156
162
  result["x-nullable"] = true if @nullable_strategy == Constants::NullableStrategy::EXTENSION && schema_nullable?(schema)
157
163
  result["description"] = schema.description.to_s if schema.description
158
164
  result["default"] = schema.default unless schema.default.nil?
159
- result["enum"] = normalize_enum(schema.enum, schema.type, nullable: schema_nullable?(schema)) if schema.enum
165
+ result["enum"] = normalize_enum(schema.enum, schema.type, preserve_nil: enum_allows_null?(schema)) if schema.enum
160
166
  result.delete("enum") if result.key?("enum") && result["enum"].nil?
161
167
  apply_constraints(result, schema)
162
168
  result.merge!(schema.extensions) if schema.extensions
@@ -175,29 +181,6 @@ module GrapeOAS
175
181
  built
176
182
  end
177
183
  end
178
-
179
- def normalize_enum(enum_vals, type, nullable: false)
180
- return nil unless enum_vals.is_a?(Array)
181
-
182
- has_nil = nullable && enum_vals.include?(nil)
183
-
184
- result = enum_vals.each_with_object([]) do |v, acc|
185
- next if v.nil?
186
-
187
- coerced_v = case type
188
- when Constants::SchemaTypes::INTEGER then v.to_i if v.respond_to?(:to_i)
189
- when Constants::SchemaTypes::NUMBER then v.to_f if v.respond_to?(:to_f)
190
- else v
191
- end
192
- acc << coerced_v unless coerced_v.nil?
193
- end
194
-
195
- result.uniq!
196
- result.push(nil) if has_nil
197
- return nil if result.empty?
198
-
199
- result
200
- end
201
184
  end
202
185
  end
203
186
  end
@@ -4,6 +4,8 @@ module GrapeOAS
4
4
  module Exporter
5
5
  module OAS3
6
6
  class Schema
7
+ include Concerns::EnumNormalizer
8
+
7
9
  def initialize(schema, ref_tracker = nil, nullable_strategy: Constants::NullableStrategy::KEYWORD)
8
10
  @schema = schema
9
11
  @ref_tracker = ref_tracker
@@ -41,7 +43,11 @@ module GrapeOAS
41
43
 
42
44
  end
43
45
  schema_hash["required"] = @schema.required if @schema.required && !@schema.required.empty?
44
- schema_hash["enum"] = normalize_enum(@schema.enum, schema_hash["type"], nullable: nullable?) if @schema.enum
46
+ if @schema.enum
47
+ schema_hash["enum"] = normalize_enum(
48
+ @schema.enum, schema_hash["type"], preserve_nil: enum_allows_null?(@schema, schema_hash["type"]),
49
+ )
50
+ end
45
51
  schema_hash["default"] = @schema.default unless @schema.default.nil?
46
52
  schema_hash
47
53
  end
@@ -88,7 +94,11 @@ module GrapeOAS
88
94
  if schema_nullable?(schema) && @nullable_strategy == Constants::NullableStrategy::TYPE_ARRAY
89
95
  enum_type = Array(enum_type) | ["null"]
90
96
  end
91
- result["enum"] = normalize_enum(schema.enum, enum_type, nullable: schema_nullable?(schema)) if schema.enum
97
+ if schema.enum
98
+ result["enum"] = normalize_enum(
99
+ schema.enum, enum_type, preserve_nil: enum_allows_null?(schema, enum_type, null_union: true),
100
+ )
101
+ end
92
102
  sanitize_enum_against_type(result, type: schema.type)
93
103
  apply_all_constraints(result, schema)
94
104
  result.merge!(schema.extensions) if schema.extensions
@@ -110,12 +120,6 @@ module GrapeOAS
110
120
 
111
121
  private
112
122
 
113
- # Returns the primary non-null type from a type value.
114
- # Assumes at most one non-null type in the array (e.g. ["integer", "null"]).
115
- def base_type_for(type)
116
- type.is_a?(Array) ? (type - ["null"]).first : type
117
- end
118
-
119
123
  # Rewrites `type: file` (or `type: ["file", "null"]`) to the
120
124
  # version-appropriate representation. Type detection lives here;
121
125
  # version-specific attributes are set by `apply_file_schema_attributes!`.
@@ -170,7 +174,11 @@ module GrapeOAS
170
174
  result["format"] = @schema.format if @schema.format
171
175
  result["description"] = @schema.description.to_s if @schema.description
172
176
  result["default"] = @schema.default unless @schema.default.nil?
173
- result["enum"] = normalize_enum(@schema.enum, result["type"], nullable: nullable?) if @schema.enum
177
+ if @schema.enum
178
+ result["enum"] = normalize_enum(
179
+ @schema.enum, result["type"], preserve_nil: enum_allows_null?(@schema, result["type"], null_union: true),
180
+ )
181
+ end
174
182
  sanitize_enum_against_type(result)
175
183
  apply_all_constraints(result)
176
184
  apply_composition_extensions(result)
@@ -305,38 +313,17 @@ module GrapeOAS
305
313
  { "type" => Constants::SchemaTypes::OBJECT, "nullable" => true, "enum" => [nil] }
306
314
  end
307
315
 
308
- def normalize_enum(enum_vals, type, nullable: false)
309
- return nil unless enum_vals.is_a?(Array)
310
-
311
- nullable = (nullable || (type.is_a?(Array) && type.include?(Constants::SchemaTypes::NULL))) &&
312
- enum_null_supported?(type)
313
- resolved_type = base_type_for(type)
314
-
315
- has_nil = nullable && enum_vals.include?(nil)
316
+ def enum_allows_null?(schema, type, null_union: false)
317
+ has_null_type = type.is_a?(Array) && type.include?(Constants::SchemaTypes::NULL)
316
318
 
317
- result = enum_vals.each_with_object([]) do |v, acc|
318
- next if v.nil?
319
-
320
- coerced_v = case resolved_type
321
- when Constants::SchemaTypes::INTEGER then v.to_i if v.respond_to?(:to_i)
322
- when Constants::SchemaTypes::NUMBER then v.to_f if v.respond_to?(:to_f)
323
- else v
324
- end
325
- acc << coerced_v unless coerced_v.nil?
319
+ case @nullable_strategy
320
+ when Constants::NullableStrategy::KEYWORD
321
+ has_null_type || (schema_nullable?(schema) && (!type.nil? || null_union))
322
+ when Constants::NullableStrategy::TYPE_ARRAY
323
+ has_null_type || (schema_nullable?(schema) && null_union)
324
+ else
325
+ false
326
326
  end
327
-
328
- result.uniq!
329
- result.push(nil) if has_nil
330
- return nil if result.empty?
331
-
332
- result
333
- end
334
-
335
- def enum_null_supported?(type)
336
- return true if @nullable_strategy == Constants::NullableStrategy::KEYWORD
337
-
338
- @nullable_strategy == Constants::NullableStrategy::TYPE_ARRAY &&
339
- type.is_a?(Array) && type.include?(Constants::SchemaTypes::NULL)
340
327
  end
341
328
 
342
329
  def apply_numeric_constraints(hash, schema = @schema)