grape-oas 1.2.0 → 1.4.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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -0
  3. data/README.md +6 -0
  4. data/lib/grape_oas/api_model/api.rb +4 -0
  5. data/lib/grape_oas/api_model/schema.rb +18 -3
  6. data/lib/grape_oas/api_model_builders/concerns/content_type_resolver.rb +21 -0
  7. data/lib/grape_oas/api_model_builders/concerns/type_resolver.rb +3 -6
  8. data/lib/grape_oas/api_model_builders/request.rb +21 -12
  9. data/lib/grape_oas/api_model_builders/request_params_support/param_location_resolver.rb +7 -2
  10. data/lib/grape_oas/api_model_builders/request_params_support/param_schema_builder.rb +15 -1
  11. data/lib/grape_oas/api_model_builders/request_params_support/schema_enhancer.rb +34 -50
  12. data/lib/grape_oas/constants.rb +13 -0
  13. data/lib/grape_oas/doc_key_normalizer.rb +14 -0
  14. data/lib/grape_oas/exporter/oas2/parameter.rb +1 -0
  15. data/lib/grape_oas/exporter/oas2/schema.rb +53 -19
  16. data/lib/grape_oas/exporter/oas3/parameter.rb +5 -2
  17. data/lib/grape_oas/exporter/oas3/schema.rb +81 -46
  18. data/lib/grape_oas/introspectors/dry_introspector_support/argument_extractor.rb +2 -31
  19. data/lib/grape_oas/introspectors/dry_introspector_support/constraint_extractor.rb +10 -19
  20. data/lib/grape_oas/introspectors/dry_introspector_support/predicate_handler.rb +2 -10
  21. data/lib/grape_oas/introspectors/entity_introspector.rb +5 -1
  22. data/lib/grape_oas/introspectors/entity_introspector_support/discriminator_handler.rb +2 -25
  23. data/lib/grape_oas/introspectors/entity_introspector_support/exposure_processor.rb +139 -136
  24. data/lib/grape_oas/introspectors/entity_introspector_support/inheritance_builder.rb +6 -30
  25. data/lib/grape_oas/introspectors/entity_introspector_support/nesting_merger.rb +104 -0
  26. data/lib/grape_oas/introspectors/entity_introspector_support/property_extractor.rb +2 -1
  27. data/lib/grape_oas/introspectors/entity_introspector_support/type_schema_resolver.rb +139 -0
  28. data/lib/grape_oas/introspectors/entity_introspector_support.rb +57 -0
  29. data/lib/grape_oas/range_utils.rb +87 -0
  30. data/lib/grape_oas/schema_constraints.rb +36 -0
  31. data/lib/grape_oas/type_resolvers/array_resolver.rb +3 -5
  32. data/lib/grape_oas/values_normalizer.rb +47 -0
  33. data/lib/grape_oas/version.rb +1 -1
  34. data/lib/grape_oas.rb +27 -0
  35. metadata +9 -2
@@ -32,9 +32,26 @@ module GrapeOAS
32
32
  apply_nullable(schema_hash)
33
33
  props = build_properties(@schema.properties)
34
34
  schema_hash["properties"] = props if props
35
- schema_hash["items"] = @schema.items ? build_schema_or_ref(@schema.items) : nil
35
+ if @schema.items
36
+ schema_hash["items"] = build_schema_or_ref(@schema.items, include_metadata: false)
37
+ if !schema_hash["description"] && @schema.items.respond_to?(:description) && @schema.items.description
38
+ schema_hash["description"] = @schema.items.description.to_s
39
+ end
40
+ if @schema.items.respond_to?(:canonical_name) && @schema.items.canonical_name &&
41
+ @schema.items.respond_to?(:nullable) && @schema.items.nullable
42
+ case @nullable_strategy
43
+ when Constants::NullableStrategy::KEYWORD
44
+ schema_hash["nullable"] = true
45
+ when Constants::NullableStrategy::EXTENSION
46
+ schema_hash["x-nullable"] = true
47
+ when Constants::NullableStrategy::TYPE_ARRAY
48
+ schema_hash["type"] = (Array(schema_hash["type"]) | ["null"])
49
+ end
50
+ end
51
+ end
36
52
  schema_hash["required"] = @schema.required if @schema.required && !@schema.required.empty?
37
53
  schema_hash["enum"] = normalize_enum(@schema.enum, schema_hash["type"]) if @schema.enum
54
+ schema_hash["default"] = @schema.default unless @schema.default.nil?
38
55
  schema_hash
39
56
  end
40
57
 
@@ -56,49 +73,54 @@ module GrapeOAS
56
73
  schema_hash["discriminator"] = build_discriminator if @schema.discriminator
57
74
  end
58
75
 
59
- def apply_all_constraints(schema_hash)
60
- apply_numeric_constraints(schema_hash)
61
- apply_string_constraints(schema_hash)
62
- apply_array_constraints(schema_hash)
76
+ def apply_all_constraints(schema_hash, schema = @schema)
77
+ apply_numeric_constraints(schema_hash, schema)
78
+ apply_string_constraints(schema_hash, schema)
79
+ apply_array_constraints(schema_hash, schema)
63
80
  end
64
81
 
65
82
  private
66
83
 
67
84
  # Build allOf schema for inheritance
68
85
  def build_all_of_schema
69
- all_of_items = @schema.all_of.map do |item|
70
- build_schema_or_ref(item)
71
- end
72
-
73
- result = { "allOf" => all_of_items }
74
- result["description"] = @schema.description.to_s if @schema.description
86
+ items = @schema.all_of.map { |item| build_schema_or_ref(item) }
87
+ result = { "allOf" => items }
88
+ apply_composition_attributes(result)
89
+ apply_nullable(result)
75
90
  result
76
91
  end
77
92
 
78
93
  # Build oneOf schema for polymorphism
79
94
  def build_one_of_schema
80
- one_of_items = @schema.one_of.map do |item|
81
- build_schema_or_ref(item)
82
- end
83
-
84
- result = { "oneOf" => one_of_items }
85
- result["description"] = @schema.description.to_s if @schema.description
95
+ items = @schema.one_of.map { |item| build_schema_or_ref(item) }
96
+ result = { "oneOf" => items }
97
+ apply_composition_attributes(result)
86
98
  result["discriminator"] = build_discriminator if @schema.discriminator
99
+ apply_nullable(result)
87
100
  result
88
101
  end
89
102
 
90
103
  # Build anyOf schema for polymorphism
91
104
  def build_any_of_schema
92
- any_of_items = @schema.any_of.map do |item|
93
- build_schema_or_ref(item)
94
- end
95
-
96
- result = { "anyOf" => any_of_items }
97
- result["description"] = @schema.description.to_s if @schema.description
105
+ items = @schema.any_of.map { |item| build_schema_or_ref(item) }
106
+ result = { "anyOf" => items }
107
+ apply_composition_attributes(result)
98
108
  result["discriminator"] = build_discriminator if @schema.discriminator
109
+ apply_nullable(result)
99
110
  result
100
111
  end
101
112
 
113
+ def apply_composition_attributes(result)
114
+ result["type"] = nullable_type if @schema.type
115
+ result["format"] = @schema.format if @schema.format
116
+ result["description"] = @schema.description.to_s if @schema.description
117
+ result["default"] = @schema.default unless @schema.default.nil?
118
+ result["enum"] = normalize_enum(@schema.enum, result["type"]) if @schema.enum
119
+ sanitize_enum_against_type(result)
120
+ apply_all_constraints(result)
121
+ result.merge!(@schema.extensions) if @schema.extensions
122
+ end
123
+
102
124
  # Build OAS3 discriminator object
103
125
  def build_discriminator
104
126
  return nil unless @schema.discriminator
@@ -146,13 +168,20 @@ module GrapeOAS
146
168
  end
147
169
  end
148
170
 
149
- def build_schema_or_ref(schema)
171
+ def build_schema_or_ref(schema, include_metadata: true)
150
172
  if schema.respond_to?(:canonical_name) && schema.canonical_name
151
173
  @ref_tracker << schema.canonical_name if @ref_tracker
152
174
  ref_name = schema.canonical_name.gsub("::", "_")
153
175
  ref_hash = { "$ref" => "#/components/schemas/#{ref_name}" }
176
+ return ref_hash unless include_metadata
177
+
154
178
  result = {}
155
179
  result["description"] = schema.description.to_s if schema.description
180
+ result["default"] = schema.default unless schema.default.nil?
181
+ result["enum"] = normalize_enum(schema.enum, schema.type) if schema.enum
182
+ sanitize_enum_against_type(result, type: schema.type)
183
+ apply_all_constraints(result, schema)
184
+ result.merge!(schema.extensions) if schema.extensions
156
185
  apply_nullable_to_ref(result, schema)
157
186
  if result.empty?
158
187
  ref_hash
@@ -161,10 +190,16 @@ module GrapeOAS
161
190
  result
162
191
  end
163
192
  else
164
- Schema.new(schema, @ref_tracker, nullable_strategy: @nullable_strategy).build
193
+ built = Schema.new(schema, @ref_tracker, nullable_strategy: @nullable_strategy).build
194
+ strip_items_metadata(built) unless include_metadata
195
+ built
165
196
  end
166
197
  end
167
198
 
199
+ def strip_items_metadata(hash)
200
+ hash.delete("description")
201
+ end
202
+
168
203
  def apply_nullable_to_ref(result, schema)
169
204
  return unless schema.respond_to?(:nullable) && schema.nullable
170
205
 
@@ -197,40 +232,40 @@ module GrapeOAS
197
232
  result
198
233
  end
199
234
 
200
- def apply_numeric_constraints(hash)
201
- hash["minimum"] = @schema.minimum unless @schema.minimum.nil?
202
- hash["maximum"] = @schema.maximum unless @schema.maximum.nil?
235
+ def apply_numeric_constraints(hash, schema = @schema)
236
+ hash["minimum"] = schema.minimum unless schema.minimum.nil?
237
+ hash["maximum"] = schema.maximum unless schema.maximum.nil?
203
238
 
204
239
  if @nullable_strategy == Constants::NullableStrategy::TYPE_ARRAY
205
- if @schema.exclusive_minimum && !@schema.minimum.nil?
206
- hash["exclusiveMinimum"] = @schema.minimum
240
+ if schema.exclusive_minimum && !schema.minimum.nil?
241
+ hash["exclusiveMinimum"] = schema.minimum
207
242
  hash.delete("minimum")
208
243
  end
209
- if @schema.exclusive_maximum && !@schema.maximum.nil?
210
- hash["exclusiveMaximum"] = @schema.maximum
244
+ if schema.exclusive_maximum && !schema.maximum.nil?
245
+ hash["exclusiveMaximum"] = schema.maximum
211
246
  hash.delete("maximum")
212
247
  end
213
248
  else
214
- hash["exclusiveMinimum"] = @schema.exclusive_minimum if @schema.exclusive_minimum
215
- hash["exclusiveMaximum"] = @schema.exclusive_maximum if @schema.exclusive_maximum
249
+ hash["exclusiveMinimum"] = schema.exclusive_minimum if schema.exclusive_minimum
250
+ hash["exclusiveMaximum"] = schema.exclusive_maximum if schema.exclusive_maximum
216
251
  end
217
252
  end
218
253
 
219
- def apply_string_constraints(hash)
220
- hash["minLength"] = @schema.min_length unless @schema.min_length.nil?
221
- hash["maxLength"] = @schema.max_length unless @schema.max_length.nil?
222
- hash["pattern"] = @schema.pattern if @schema.pattern
254
+ def apply_string_constraints(hash, schema = @schema)
255
+ hash["minLength"] = schema.min_length unless schema.min_length.nil?
256
+ hash["maxLength"] = schema.max_length unless schema.max_length.nil?
257
+ hash["pattern"] = schema.pattern if schema.pattern
223
258
  end
224
259
 
225
- def apply_array_constraints(hash)
226
- hash["minItems"] = @schema.min_items unless @schema.min_items.nil?
227
- hash["maxItems"] = @schema.max_items unless @schema.max_items.nil?
260
+ def apply_array_constraints(hash, schema = @schema)
261
+ hash["minItems"] = schema.min_items unless schema.min_items.nil?
262
+ hash["maxItems"] = schema.max_items unless schema.max_items.nil?
228
263
  end
229
264
 
230
265
  # Ensure enum values match the declared type; drop enum if incompatible to avoid invalid specs
231
- def sanitize_enum_against_type(hash)
266
+ def sanitize_enum_against_type(hash, type: nil)
232
267
  enum_vals = hash["enum"]
233
- type_val = hash["type"]
268
+ type_val = type || hash["type"]
234
269
  return unless enum_vals && type_val
235
270
 
236
271
  base_type = if type_val.is_a?(Array)
@@ -244,13 +279,13 @@ module GrapeOAS
244
279
  when Constants::SchemaTypes::ARRAY, Constants::SchemaTypes::OBJECT, nil
245
280
  hash.delete("enum")
246
281
  when Constants::SchemaTypes::INTEGER
247
- hash.delete("enum") unless enum_vals.all? { |v| v.is_a?(Integer) }
282
+ hash.delete("enum") unless enum_vals.all?(Integer)
248
283
  when Constants::SchemaTypes::NUMBER
249
- hash.delete("enum") unless enum_vals.all? { |v| v.is_a?(Numeric) }
284
+ hash.delete("enum") unless enum_vals.all?(Numeric)
250
285
  when Constants::SchemaTypes::BOOLEAN
251
286
  hash.delete("enum") unless enum_vals.all? { |v| [true, false].include?(v) }
252
287
  else # string and fallback
253
- hash.delete("enum") unless enum_vals.all? { |v| v.is_a?(String) }
288
+ hash.delete("enum") unless enum_vals.all?(String)
254
289
  end
255
290
  end
256
291
 
@@ -13,8 +13,6 @@ module GrapeOAS
13
13
  LITERAL_TAGS = %i[value val literal class left right].freeze
14
14
  # AST node tags for regex patterns
15
15
  PATTERN_TAGS = %i[regexp regex].freeze
16
- # Maximum size for converting ranges to enum arrays
17
- MAX_ENUM_RANGE_SIZE = 100
18
16
 
19
17
  def extract_numeric(arg)
20
18
  return arg if arg.is_a?(Numeric)
@@ -36,43 +34,16 @@ module GrapeOAS
36
34
  def extract_list(arg)
37
35
  if list_node?(arg)
38
36
  inner = arg[1]
39
- # For non-numeric ranges (e.g., 'a'..'z'), expand to array
40
- # Numeric ranges should use min/max constraints instead
41
- return range_to_enum_array(inner) if inner.is_a?(Range)
37
+ return RangeUtils.expand_range_to_enum(inner) if inner.is_a?(Range)
42
38
 
43
39
  return inner
44
40
  end
45
41
  return arg if arg.is_a?(Array)
46
- return range_to_enum_array(arg) if arg.is_a?(Range)
42
+ return RangeUtils.expand_range_to_enum(arg) if arg.is_a?(Range)
47
43
 
48
44
  nil
49
45
  end
50
46
 
51
- # Converts a non-numeric bounded Range to an array for enum values.
52
- # Returns nil for numeric ranges (should use min/max instead).
53
- # Returns nil for unbounded (endless/beginless) or excessively large ranges.
54
- def range_to_enum_array(range)
55
- # Reject unbounded ranges (endless/beginless)
56
- return nil if range.begin.nil? || range.end.nil?
57
-
58
- # Numeric ranges should use min/max constraints, not enum
59
- return nil if range.begin.is_a?(Numeric) || range.end.is_a?(Numeric)
60
-
61
- # Use bounded iteration to avoid memory exhaustion on large ranges.
62
- # Take one more than max to detect oversized ranges without full enumeration.
63
- begin
64
- array = range.take(MAX_ENUM_RANGE_SIZE + 1)
65
- rescue TypeError
66
- # Range can't be iterated (e.g., non-discrete types)
67
- return nil
68
- end
69
-
70
- # Reject ranges exceeding the size limit
71
- return nil if array.size > MAX_ENUM_RANGE_SIZE
72
-
73
- array
74
- end
75
-
76
47
  def extract_literal(arg)
77
48
  return arg unless arg.is_a?(Array)
78
49
  return arg[1] if arg.length == 2 && LITERAL_TAGS.include?(arg.first)
@@ -7,25 +7,16 @@ module GrapeOAS
7
7
  # Delegates AST walking to AstWalker and merging to ConstraintMerger.
8
8
  class ConstraintExtractor
9
9
  # Value object holding all possible constraints extracted from a Dry contract.
10
- ConstraintSet = Struct.new(
11
- :enum,
12
- :nullable,
13
- :min_size,
14
- :max_size,
15
- :minimum,
16
- :maximum,
17
- :exclusive_minimum,
18
- :exclusive_maximum,
19
- :pattern,
20
- :excluded_values,
21
- :unhandled_predicates,
22
- :required,
23
- :type_predicate,
24
- :parity,
25
- :format,
26
- :extensions,
27
- keyword_init: true,
28
- )
10
+ class ConstraintSet
11
+ attr_accessor :enum, :nullable, :min_size, :max_size,
12
+ :minimum, :maximum, :exclusive_minimum, :exclusive_maximum,
13
+ :pattern, :excluded_values, :unhandled_predicates,
14
+ :required, :type_predicate, :parity, :format, :extensions
15
+
16
+ def initialize(**attrs)
17
+ attrs.each { |k, v| public_send(:"#{k}=", v) }
18
+ end
19
+ end
29
20
 
30
21
  def self.extract(contract)
31
22
  new(contract).extract
@@ -83,15 +83,7 @@ module GrapeOAS
83
83
  return if rng.begin && !rng.begin.is_a?(Numeric)
84
84
  return if rng.end && !rng.end.is_a?(Numeric)
85
85
 
86
- apply_range_constraints(rng)
87
- end
88
-
89
- def apply_range_constraints(rng)
90
- return unless rng
91
-
92
- constraints.minimum = rng.begin if rng.begin
93
- constraints.maximum = rng.end if rng.end
94
- constraints.exclusive_maximum = rng.exclude_end? if rng.end
86
+ RangeUtils.apply_numeric_range(constraints, rng)
95
87
  end
96
88
 
97
89
  def apply_excluded_from_list(args)
@@ -135,7 +127,7 @@ module GrapeOAS
135
127
 
136
128
  def handle_range(args)
137
129
  rng = ArgumentExtractor.extract_range(args.first)
138
- apply_range_constraints(rng)
130
+ RangeUtils.apply_numeric_range(constraints, rng) if rng
139
131
  end
140
132
 
141
133
  def handle_multiple_of(args)
@@ -88,12 +88,16 @@ module GrapeOAS
88
88
  def initialize_or_reuse_schema
89
89
  @registry[@entity_class] ||= ApiModel::Schema.new(
90
90
  type: Constants::SchemaTypes::OBJECT,
91
- canonical_name: @entity_class.name,
91
+ canonical_name: resolve_canonical_name,
92
92
  description: nil,
93
93
  nullable: nil,
94
94
  )
95
95
  end
96
96
 
97
+ def resolve_canonical_name
98
+ EntityIntrospectorSupport.resolve_canonical_name(@entity_class)
99
+ end
100
+
97
101
  def populate_schema(schema)
98
102
  doc = entity_doc
99
103
  apply_schema_metadata(schema, doc)
@@ -5,26 +5,12 @@ module GrapeOAS
5
5
  module EntityIntrospectorSupport
6
6
  # Handles discriminator fields in entity inheritance for polymorphic schemas.
7
7
  class DiscriminatorHandler
8
- # Checks if an entity inherits from a parent that uses discriminator.
9
- #
10
- # @param entity_class [Class] the entity class to check
11
- # @return [Boolean] true if parent has a discriminator field
12
- def self.inherits_with_discriminator?(entity_class)
13
- parent = find_parent_entity(entity_class)
14
- parent && new(parent).discriminator?
15
- end
16
-
17
8
  # Finds the parent entity class if one exists.
18
9
  #
19
10
  # @param entity_class [Class] the entity class
20
11
  # @return [Class, nil] the parent entity class or nil
21
12
  def self.find_parent_entity(entity_class)
22
- return nil unless defined?(Grape::Entity)
23
-
24
- parent = entity_class.superclass
25
- return nil unless parent && parent < Grape::Entity && parent != Grape::Entity
26
-
27
- parent
13
+ EntityIntrospectorSupport.find_parent_entity(entity_class)
28
14
  end
29
15
 
30
16
  def initialize(entity_class)
@@ -65,17 +51,8 @@ module GrapeOAS
65
51
 
66
52
  private
67
53
 
68
- # Gets the exposures defined on the entity class.
69
- #
70
- # @return [Array] list of entity exposures
71
54
  def exposures
72
- return [] unless @entity_class.respond_to?(:root_exposures)
73
-
74
- root = @entity_class.root_exposures
75
- list = root.instance_variable_get(:@exposures) || []
76
- Array(list)
77
- rescue NoMethodError
78
- []
55
+ EntityIntrospectorSupport.exposures(@entity_class)
79
56
  end
80
57
  end
81
58
  end