grape-oas 1.2.0 → 1.3.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 +26 -0
  3. data/README.md +6 -0
  4. data/lib/grape_oas/api_model/schema.rb +17 -2
  5. data/lib/grape_oas/api_model_builders/request_params_support/param_schema_builder.rb +11 -1
  6. data/lib/grape_oas/api_model_builders/request_params_support/schema_enhancer.rb +23 -50
  7. data/lib/grape_oas/constants.rb +4 -0
  8. data/lib/grape_oas/doc_key_normalizer.rb +14 -0
  9. data/lib/grape_oas/exporter/oas2/schema.rb +18 -3
  10. data/lib/grape_oas/exporter/oas3/schema.rb +33 -6
  11. data/lib/grape_oas/introspectors/dry_introspector_support/argument_extractor.rb +2 -31
  12. data/lib/grape_oas/introspectors/dry_introspector_support/constraint_extractor.rb +10 -19
  13. data/lib/grape_oas/introspectors/dry_introspector_support/predicate_handler.rb +2 -10
  14. data/lib/grape_oas/introspectors/entity_introspector_support/discriminator_handler.rb +2 -25
  15. data/lib/grape_oas/introspectors/entity_introspector_support/exposure_processor.rb +139 -136
  16. data/lib/grape_oas/introspectors/entity_introspector_support/inheritance_builder.rb +5 -29
  17. data/lib/grape_oas/introspectors/entity_introspector_support/nesting_merger.rb +104 -0
  18. data/lib/grape_oas/introspectors/entity_introspector_support/property_extractor.rb +2 -1
  19. data/lib/grape_oas/introspectors/entity_introspector_support/type_schema_resolver.rb +139 -0
  20. data/lib/grape_oas/introspectors/entity_introspector_support.rb +33 -0
  21. data/lib/grape_oas/range_utils.rb +87 -0
  22. data/lib/grape_oas/schema_constraints.rb +36 -0
  23. data/lib/grape_oas/values_normalizer.rb +47 -0
  24. data/lib/grape_oas/version.rb +1 -1
  25. data/lib/grape_oas.rb +27 -0
  26. metadata +9 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6f97a967ef31212399ee7765f184a358e755b4364578cbc43d3fdaf75a919638
4
- data.tar.gz: c56afb98329feaa1227d6ee252cbf5866ddc0144eac15dc6db8e34f9718d4731
3
+ metadata.gz: e5289691e338b77f7408ed384429c297bfcac143962085e4b02edc1b980b35ff
4
+ data.tar.gz: 1d03adce8e12922b769ce6679013d14cb4e9bb4bc3ceaa2a6d4fdc2a26d34625
5
5
  SHA512:
6
- metadata.gz: efed55aaa9f34d9045e495b2c8cbccea7b4da8381b4d2c58628e20785947319f42cc3a1e9952bac87219cb290cdbb81ac31bca7107acc3be3db13e027087c1ac
7
- data.tar.gz: aa0eebcf7dd0b7122346150adfdf6e5dbfc8ed0287470ecedfe18ff9a95afca3376479899e0dc1e6298be3e65da3711238988b329839b4af68d48dd622bcd1f0
6
+ metadata.gz: 850eee859637b8e5cab7606b8819931c5b59479aeab6247907fde7578c43b613694a0ab7b2ffb24c1530ac1e878982f555810a7afb83b34cf378d2f6b35cc020
7
+ data.tar.gz: 1298c51a115ec42502e011e50b41ae3b9bf9a553c834c49b7c3b628d285b7e2853ee8251cf55c742766d951f2ecfc525252aee1950a61ffe8fbd064a84550110
data/CHANGELOG.md CHANGED
@@ -5,6 +5,32 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+
9
+ ## [1.3.0] - 2026-03-27
10
+
11
+ ### Added
12
+
13
+ - [#48](https://github.com/numbata/grape-oas/pull/48): Add configurable `GrapeOAS.logger` for schema generation warnings - [@numbata](https://github.com/numbata).
14
+ - [#43](https://github.com/numbata/grape-oas/pull/43): Bump actions/upload-artifact from 6 to 7 - [@dependabot[bot]](https://github.com/dependabot[bot]).
15
+ - [#51](https://github.com/numbata/grape-oas/pull/51): Add inline nesting exposure support — block-based `expose :key do ... end` now produces inline object schemas with preserved enum values, min/max constraints, and metadata - [@numbata](https://github.com/numbata).
16
+
17
+ ### Changed
18
+
19
+ - [#52](https://github.com/numbata/grape-oas/pull/52): Extract `SchemaConstraints` — centralizes numeric/string constraint application (min/max, exclusive flags, length, pattern) and adds `exclusive_minimum`/`exclusive_maximum` support - [@numbata](https://github.com/numbata).
20
+ - [#50](https://github.com/numbata/grape-oas/pull/50): Extract `ValuesNormalizer` — consolidates Proc/Set/Hash value normalization into a single module used by both request params and entity exposures - [@numbata](https://github.com/numbata).
21
+
22
+ ### Fixed
23
+
24
+ - [#55](https://github.com/numbata/grape-oas/pull/55): Preserve enum values on cached entity schemas — dup the shared schema before applying enum instead of silently discarding the constraint; emit a warning naming the entity - [@numbata](https://github.com/numbata).
25
+ - [#56](https://github.com/numbata/grape-oas/pull/56): Make `NestingMerger` depth-cap warning actionable — include property name and depth cap value in the message - [@numbata](https://github.com/numbata).
26
+ - [#50](https://github.com/numbata/grape-oas/pull/50): Fix `[false]` enum silently dropped — `[false].any?` returns `false` in Ruby, causing boolean-only enum constraints to be discarded - [@numbata](https://github.com/numbata).
27
+
28
+ - [#49](https://github.com/numbata/grape-oas/pull/49): Fix OOM on wide string range expansion in `values:` — replace unbounded `range.to_a` with bounded enumeration; non-numeric ranges on numeric schemas and numeric ranges on non-numeric schemas now warn and are ignored instead of silently producing invalid output - [@numbata](https://github.com/numbata).
29
+ - [#46](https://github.com/numbata/grape-oas/pull/46): Fix `is_array: true` in request param documentation being ignored for primitive types — only entity types were wrapped in array schema - [@numbata](https://github.com/numbata).
30
+ - [#42](https://github.com/numbata/grape-oas/pull/42): Fix array items `description` and `nullable` placement — hoist to outer array schema instead of wrapping `items` in `allOf`; fix `:description` field naming collision in `PropertyExtractor` - [@numbata](https://github.com/numbata).
31
+ - [#44](https://github.com/numbata/grape-oas/pull/44): Fix RuboCop 1.85 offenses - [@numbata](https://github.com/numbata).
32
+ - [#47](https://github.com/numbata/grape-oas/pull/47): Fix duplicate entries in `Schema#required` array when the same property is added multiple times with `required: true` - [@numbata](https://github.com/numbata).
33
+
8
34
  ## [1.2.0] - 2026-03-02
9
35
 
10
36
  ### Added
data/README.md CHANGED
@@ -150,6 +150,12 @@ class Entity::User < Grape::Entity
150
150
  expose :id, documentation: { type: Integer }
151
151
  expose :name, documentation: { type: String }
152
152
  expose :posts, using: Entity::Post, documentation: { is_array: true }
153
+
154
+ # Block-based nesting produces an inline object schema
155
+ expose :meta do
156
+ expose :role, documentation: { type: String, values: %w[admin user guest] }
157
+ expose :verified, documentation: { type: 'Boolean' }
158
+ end
153
159
  end
154
160
  ```
155
161
 
@@ -51,10 +51,25 @@ module GrapeOAS
51
51
  end
52
52
 
53
53
  def add_property(name, schema, required: false)
54
- @properties[name.to_s] = schema
55
- @required << name.to_s if required
54
+ key = name.to_s
55
+ @properties[key] = schema
56
+ @required << key if required && !@required.include?(key)
56
57
  schema
57
58
  end
59
+
60
+ protected
61
+
62
+ # Ensure dup produces an independent copy — without this, the shallow
63
+ # copy shares @properties, @required, and @defs with the original.
64
+ # Property values are duped one level deep (via transform_values(&:dup)),
65
+ # which triggers initialize_copy recursively on each nested Schema,
66
+ # producing a full deep copy of the property tree.
67
+ def initialize_copy(source)
68
+ super
69
+ @properties = source.properties.transform_values(&:dup)
70
+ @required = source.required.dup
71
+ @defs = source.defs.dup
72
+ end
58
73
  end
59
74
  end
60
75
  end
@@ -33,6 +33,7 @@ module GrapeOAS
33
33
 
34
34
  return build_entity_array_schema(spec, raw_type, doc_type) if entity_array_type?(type_source, doc_type, spec)
35
35
  return build_doc_entity_array_schema(doc_type) if doc[:is_array] && grape_entity?(doc_type)
36
+ return build_primitive_array_schema(doc_type, raw_type) if doc[:is_array]
36
37
  return build_entity_schema(doc_type) if grape_entity?(doc_type)
37
38
  return build_entity_schema(raw_type) if grape_entity?(raw_type)
38
39
  return build_elements_array_schema(spec) if array_with_elements?(raw_type, spec)
@@ -92,6 +93,15 @@ module GrapeOAS
92
93
  ApiModel::Schema.new(type: Constants::SchemaTypes::ARRAY, items: items_schema)
93
94
  end
94
95
 
96
+ def build_primitive_array_schema(doc_type, raw_type)
97
+ type_source = doc_type || raw_type
98
+ item_type = resolve_schema_type(type_source)
99
+ ApiModel::Schema.new(
100
+ type: Constants::SchemaTypes::ARRAY,
101
+ items: ApiModel::Schema.new(type: item_type, format: Constants.format_for_type(type_source)),
102
+ )
103
+ end
104
+
95
105
  def build_simple_array_schema
96
106
  ApiModel::Schema.new(
97
107
  type: Constants::SchemaTypes::ARRAY,
@@ -191,7 +201,7 @@ module GrapeOAS
191
201
  klass = Object.const_get(const_name, false)
192
202
  klass if klass.is_a?(Class) && klass <= Grape::Entity
193
203
  rescue NameError => e
194
- warn "[grape-oas] Could not resolve entity constant '#{const_name}': #{e.message}"
204
+ GrapeOAS.logger.warn("Could not resolve entity constant '#{const_name}': #{e.message}")
195
205
  nil
196
206
  end
197
207
  end
@@ -19,7 +19,7 @@ module GrapeOAS
19
19
 
20
20
  apply_additional_properties(schema, doc)
21
21
  apply_format_and_example(schema, doc)
22
- apply_constraints(schema, doc)
22
+ SchemaConstraints.apply(schema, doc)
23
23
  apply_values(schema, spec)
24
24
  end
25
25
 
@@ -50,40 +50,25 @@ module GrapeOAS
50
50
  schema.examples = doc[:example] if doc[:example] && schema.respond_to?(:examples=)
51
51
  end
52
52
 
53
- def apply_constraints(schema, doc)
54
- schema.minimum = doc[:minimum] if doc.key?(:minimum) && schema.respond_to?(:minimum=)
55
- schema.maximum = doc[:maximum] if doc.key?(:maximum) && schema.respond_to?(:maximum=)
56
- schema.min_length = doc[:min_length] if doc.key?(:min_length) && schema.respond_to?(:min_length=)
57
- schema.max_length = doc[:max_length] if doc.key?(:max_length) && schema.respond_to?(:max_length=)
58
- schema.pattern = doc[:pattern] if doc.key?(:pattern) && schema.respond_to?(:pattern=)
59
- end
60
-
61
- # Applies values from spec[:values] - converts Range to min/max,
62
- # evaluates Proc (arity 0), and sets enum for arrays.
63
- # Skips Proc/Lambda validators (arity > 0) used for custom validation.
64
- # For array schemas, applies enum to items (since values constrain array elements).
65
- # For oneOf schemas, applies enum to each non-null variant.
66
53
  def apply_values(schema, spec)
67
- values = spec[:values]
54
+ values = ValuesNormalizer.normalize(spec[:values], context: "parameter values")
68
55
  return unless values
69
56
 
70
- # Handle Hash format { value: ..., message: ... } - extract the value
71
- values = values[:value] if values.is_a?(Hash) && values.key?(:value)
72
-
73
- # Handle Proc/Lambda
74
- if values.respond_to?(:call)
75
- # Skip validators (arity > 0) - they validate individual values
76
- return if values.arity != 0
77
-
78
- # Evaluate arity-0 procs - they return enum arrays
79
- values = values.call
80
- end
81
-
82
57
  if values.is_a?(Range)
83
- apply_range_values(schema, values)
84
- else
85
- enum_values = defined?(Set) && values.is_a?(Set) ? values.to_a : values
86
- apply_enum_values(schema, enum_values) if enum_values.is_a?(Array) && enum_values.any?
58
+ if one_of_schema?(schema)
59
+ schema.one_of.each do |variant|
60
+ next if null_type_schema?(variant)
61
+ next unless range_compatible_with_schema?(values, variant)
62
+
63
+ RangeUtils.apply_to_schema(variant, values)
64
+ end
65
+ elsif array_schema_with_items?(schema)
66
+ RangeUtils.apply_to_schema(schema.items, values)
67
+ else
68
+ RangeUtils.apply_to_schema(schema, values)
69
+ end
70
+ elsif values.is_a?(Array) && !values.empty?
71
+ apply_enum_values(schema, values)
87
72
  end
88
73
  end
89
74
 
@@ -98,7 +83,7 @@ module GrapeOAS
98
83
  compatible_values = filter_compatible_values(variant, values)
99
84
 
100
85
  # Only apply enum if there are compatible values
101
- variant.enum = compatible_values if compatible_values.any? && variant.respond_to?(:enum=)
86
+ variant.enum = compatible_values if !compatible_values.empty? && variant.respond_to?(:enum=)
102
87
  end
103
88
  elsif array_schema_with_items?(schema)
104
89
  # For array schemas, apply enum to items (values constrain array elements)
@@ -110,7 +95,7 @@ module GrapeOAS
110
95
  end
111
96
 
112
97
  def one_of_schema?(schema)
113
- schema.respond_to?(:one_of) && schema.one_of.is_a?(Array) && schema.one_of.any?
98
+ schema.respond_to?(:one_of) && schema.one_of.is_a?(Array) && !schema.one_of.empty?
114
99
  end
115
100
 
116
101
  def null_type_schema?(schema)
@@ -159,26 +144,14 @@ module GrapeOAS
159
144
  end
160
145
  end
161
146
 
162
- # Converts a Range to minimum/maximum constraints.
163
- # For numeric ranges (Integer, Float), uses min/max.
164
- # For other ranges (e.g., 'a'..'z'), expands to enum array.
165
- # Handles endless/beginless ranges (e.g., 1.., ..10).
166
- def apply_range_values(schema, range)
167
- first_val = range.begin
168
- last_val = range.end
169
-
170
- if first_val.is_a?(Numeric) || last_val.is_a?(Numeric)
171
- schema.minimum = first_val if first_val && schema.respond_to?(:minimum=)
172
- schema.maximum = last_val if last_val && schema.respond_to?(:maximum=)
173
- elsif first_val && last_val && schema.respond_to?(:enum=)
174
- # Non-numeric bounded range (e.g., 'a'..'z') - expand to enum
175
- schema.enum = range.to_a
176
- end
177
- end
178
-
179
147
  def extract_defs(doc)
180
148
  doc[:defs] || doc[:$defs]
181
149
  end
150
+
151
+ def range_compatible_with_schema?(range, schema)
152
+ numeric_type = RangeUtils::NUMERIC_TYPES.include?(schema.type)
153
+ RangeUtils.numeric_range?(range) ? numeric_type : !numeric_type
154
+ end
182
155
  end
183
156
  end
184
157
  end
@@ -47,6 +47,10 @@ module GrapeOAS
47
47
  EXTENSION = :extension
48
48
  end
49
49
 
50
+ # Maximum number of elements to expand from a non-numeric Range into an enum array.
51
+ # Prevents OOM on wide string ranges (e.g. "a".."zzzzzz").
52
+ MAX_ENUM_RANGE_SIZE = 100
53
+
50
54
  # Default values for OpenAPI spec when not provided by user
51
55
  module Defaults
52
56
  LICENSE_NAME = "Proprietary"
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GrapeOAS
4
+ # Normalizes documentation hash keys so callers can use symbol access
5
+ # uniformly. String keys that look like OpenAPI extensions ("x-*") are
6
+ # kept as strings; all other keys are converted to symbols.
7
+ module DocKeyNormalizer
8
+ def self.normalize(doc)
9
+ return doc if doc.empty?
10
+
11
+ doc.transform_keys { |k| k.to_s.start_with?("x-") ? k.to_s : k.to_sym }
12
+ end
13
+ end
14
+ end
@@ -33,9 +33,19 @@ module GrapeOAS
33
33
  "format" => @schema.format,
34
34
  "description" => @schema.description&.to_s,
35
35
  "properties" => build_properties(@schema.properties),
36
- "items" => (@schema.items ? build_schema_or_ref(@schema.items) : nil),
37
36
  "enum" => normalize_enum(@schema.enum, @schema.type)
38
37
  }
38
+ if @schema.items
39
+ schema_hash["items"] = build_schema_or_ref(@schema.items, include_metadata: false)
40
+ if !schema_hash["description"] && @schema.items.respond_to?(:description) && @schema.items.description
41
+ schema_hash["description"] = @schema.items.description.to_s
42
+ end
43
+ if @schema.items.respond_to?(:canonical_name) && @schema.items.canonical_name &&
44
+ @nullable_strategy == Constants::NullableStrategy::EXTENSION &&
45
+ @schema.items.respond_to?(:nullable) && @schema.items.nullable
46
+ schema_hash["x-nullable"] = true
47
+ end
48
+ end
39
49
  if schema_hash["properties"].nil? || schema_hash["properties"].empty? || @schema.type != Constants::SchemaTypes::OBJECT
40
50
  schema_hash.delete("properties")
41
51
  end
@@ -90,6 +100,7 @@ module GrapeOAS
90
100
 
91
101
  result = { "allOf" => all_of_items }
92
102
  result["description"] = @schema.description.to_s if @schema.description
103
+ result["x-nullable"] = true if @nullable_strategy == Constants::NullableStrategy::EXTENSION && nullable?
93
104
  result
94
105
  end
95
106
 
@@ -102,11 +113,13 @@ module GrapeOAS
102
113
  end
103
114
  end
104
115
 
105
- def build_schema_or_ref(schema)
116
+ def build_schema_or_ref(schema, include_metadata: true)
106
117
  if schema.respond_to?(:canonical_name) && schema.canonical_name
107
118
  @ref_tracker << schema.canonical_name if @ref_tracker
108
119
  ref_name = schema.canonical_name.gsub("::", "_")
109
120
  ref_hash = { "$ref" => "#/definitions/#{ref_name}" }
121
+ return ref_hash unless include_metadata
122
+
110
123
  result = {}
111
124
  if @nullable_strategy == Constants::NullableStrategy::EXTENSION && schema.respond_to?(:nullable) && schema.nullable
112
125
  result["x-nullable"] = true
@@ -119,7 +132,9 @@ module GrapeOAS
119
132
  result
120
133
  end
121
134
  else
122
- Schema.new(schema, @ref_tracker, nullable_strategy: @nullable_strategy).build
135
+ built = Schema.new(schema, @ref_tracker, nullable_strategy: @nullable_strategy).build
136
+ built.delete("description") unless include_metadata
137
+ built
123
138
  end
124
139
  end
125
140
 
@@ -32,7 +32,23 @@ 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
38
54
  schema_hash
@@ -72,6 +88,7 @@ module GrapeOAS
72
88
 
73
89
  result = { "allOf" => all_of_items }
74
90
  result["description"] = @schema.description.to_s if @schema.description
91
+ apply_nullable(result)
75
92
  result
76
93
  end
77
94
 
@@ -84,6 +101,7 @@ module GrapeOAS
84
101
  result = { "oneOf" => one_of_items }
85
102
  result["description"] = @schema.description.to_s if @schema.description
86
103
  result["discriminator"] = build_discriminator if @schema.discriminator
104
+ apply_nullable(result)
87
105
  result
88
106
  end
89
107
 
@@ -96,6 +114,7 @@ module GrapeOAS
96
114
  result = { "anyOf" => any_of_items }
97
115
  result["description"] = @schema.description.to_s if @schema.description
98
116
  result["discriminator"] = build_discriminator if @schema.discriminator
117
+ apply_nullable(result)
99
118
  result
100
119
  end
101
120
 
@@ -146,11 +165,13 @@ module GrapeOAS
146
165
  end
147
166
  end
148
167
 
149
- def build_schema_or_ref(schema)
168
+ def build_schema_or_ref(schema, include_metadata: true)
150
169
  if schema.respond_to?(:canonical_name) && schema.canonical_name
151
170
  @ref_tracker << schema.canonical_name if @ref_tracker
152
171
  ref_name = schema.canonical_name.gsub("::", "_")
153
172
  ref_hash = { "$ref" => "#/components/schemas/#{ref_name}" }
173
+ return ref_hash unless include_metadata
174
+
154
175
  result = {}
155
176
  result["description"] = schema.description.to_s if schema.description
156
177
  apply_nullable_to_ref(result, schema)
@@ -161,10 +182,16 @@ module GrapeOAS
161
182
  result
162
183
  end
163
184
  else
164
- Schema.new(schema, @ref_tracker, nullable_strategy: @nullable_strategy).build
185
+ built = Schema.new(schema, @ref_tracker, nullable_strategy: @nullable_strategy).build
186
+ strip_items_metadata(built) unless include_metadata
187
+ built
165
188
  end
166
189
  end
167
190
 
191
+ def strip_items_metadata(hash)
192
+ hash.delete("description")
193
+ end
194
+
168
195
  def apply_nullable_to_ref(result, schema)
169
196
  return unless schema.respond_to?(:nullable) && schema.nullable
170
197
 
@@ -244,13 +271,13 @@ module GrapeOAS
244
271
  when Constants::SchemaTypes::ARRAY, Constants::SchemaTypes::OBJECT, nil
245
272
  hash.delete("enum")
246
273
  when Constants::SchemaTypes::INTEGER
247
- hash.delete("enum") unless enum_vals.all? { |v| v.is_a?(Integer) }
274
+ hash.delete("enum") unless enum_vals.all?(Integer)
248
275
  when Constants::SchemaTypes::NUMBER
249
- hash.delete("enum") unless enum_vals.all? { |v| v.is_a?(Numeric) }
276
+ hash.delete("enum") unless enum_vals.all?(Numeric)
250
277
  when Constants::SchemaTypes::BOOLEAN
251
278
  hash.delete("enum") unless enum_vals.all? { |v| [true, false].include?(v) }
252
279
  else # string and fallback
253
- hash.delete("enum") unless enum_vals.all? { |v| v.is_a?(String) }
280
+ hash.delete("enum") unless enum_vals.all?(String)
254
281
  end
255
282
  end
256
283
 
@@ -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)
@@ -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