grape-oas 1.0.2 → 1.1.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 (27) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +92 -75
  3. data/README.md +25 -1
  4. data/lib/grape_oas/api_model/parameter.rb +5 -2
  5. data/lib/grape_oas/api_model_builders/concerns/type_resolver.rb +5 -2
  6. data/lib/grape_oas/api_model_builders/request.rb +125 -9
  7. data/lib/grape_oas/api_model_builders/request_params.rb +6 -5
  8. data/lib/grape_oas/api_model_builders/request_params_support/param_schema_builder.rb +59 -8
  9. data/lib/grape_oas/api_model_builders/request_params_support/schema_enhancer.rb +77 -2
  10. data/lib/grape_oas/api_model_builders/response.rb +63 -6
  11. data/lib/grape_oas/api_model_builders/response_parsers/http_codes_parser.rb +114 -10
  12. data/lib/grape_oas/constants.rb +32 -19
  13. data/lib/grape_oas/exporter/oas2_schema.rb +0 -3
  14. data/lib/grape_oas/exporter/oas3/parameter.rb +2 -0
  15. data/lib/grape_oas/exporter/oas3_schema.rb +0 -3
  16. data/lib/grape_oas/introspectors/dry_introspector.rb +19 -10
  17. data/lib/grape_oas/introspectors/dry_introspector_support/argument_extractor.rb +57 -6
  18. data/lib/grape_oas/introspectors/dry_introspector_support/inheritance_handler.rb +17 -5
  19. data/lib/grape_oas/introspectors/dry_introspector_support/predicate_handler.rb +38 -12
  20. data/lib/grape_oas/introspectors/dry_introspector_support/rule_index.rb +196 -0
  21. data/lib/grape_oas/introspectors/dry_introspector_support/type_schema_builder.rb +89 -17
  22. data/lib/grape_oas/introspectors/dry_introspector_support/type_unwrapper.rb +19 -0
  23. data/lib/grape_oas/introspectors/entity_introspector.rb +0 -8
  24. data/lib/grape_oas/introspectors/entity_introspector_support/exposure_processor.rb +6 -3
  25. data/lib/grape_oas/version.rb +1 -1
  26. data/lib/grape_oas.rb +1 -1
  27. metadata +3 -2
@@ -14,7 +14,8 @@ module GrapeOAS
14
14
  nullable = extract_nullable(spec, doc)
15
15
 
16
16
  schema.description ||= doc[:desc]
17
- schema.nullable = nullable if schema.respond_to?(:nullable=)
17
+ # Preserve existing nullable: true (e.g., from [Type, Nil] optimization)
18
+ schema.nullable = (schema.nullable || nullable) if schema.respond_to?(:nullable=)
18
19
 
19
20
  apply_additional_properties(schema, doc)
20
21
  apply_format_and_example(schema, doc)
@@ -61,6 +62,8 @@ module GrapeOAS
61
62
  # Applies values from spec[:values] - converts Range to min/max,
62
63
  # evaluates Proc (arity 0), and sets enum for arrays.
63
64
  # Skips Proc/Lambda validators (arity > 0) used for custom validation.
65
+ # For array schemas, applies enum to items (since values constrain array elements).
66
+ # For oneOf schemas, applies enum to each non-null variant.
64
67
  def apply_values(schema, spec)
65
68
  values = spec[:values]
66
69
  return unless values
@@ -80,7 +83,79 @@ module GrapeOAS
80
83
  if values.is_a?(Range)
81
84
  apply_range_values(schema, values)
82
85
  elsif values.is_a?(Array) && values.any?
83
- schema.enum = values if schema.respond_to?(:enum=)
86
+ apply_enum_values(schema, values)
87
+ end
88
+ end
89
+
90
+ def apply_enum_values(schema, values)
91
+ # For oneOf schemas, apply enum to each variant that supports enum
92
+ if one_of_schema?(schema)
93
+ schema.one_of.each do |variant|
94
+ # Skip null types - they don't have enums
95
+ next if null_type_schema?(variant)
96
+
97
+ # Filter values to those compatible with this variant's type
98
+ compatible_values = filter_compatible_values(variant, values)
99
+
100
+ # Only apply enum if there are compatible values
101
+ variant.enum = compatible_values if compatible_values.any? && variant.respond_to?(:enum=)
102
+ end
103
+ elsif array_schema_with_items?(schema)
104
+ # For array schemas, apply enum to items (values constrain array elements)
105
+ schema.items.enum = values if schema.items.respond_to?(:enum=)
106
+ elsif schema.respond_to?(:enum=)
107
+ # For regular schemas, apply enum directly
108
+ schema.enum = values
109
+ end
110
+ end
111
+
112
+ def one_of_schema?(schema)
113
+ schema.respond_to?(:one_of) && schema.one_of.is_a?(Array) && schema.one_of.any?
114
+ end
115
+
116
+ def null_type_schema?(schema)
117
+ return false unless schema.respond_to?(:type)
118
+
119
+ schema.type.nil? || schema.type == "null"
120
+ end
121
+
122
+ def array_schema_with_items?(schema)
123
+ schema.respond_to?(:type) &&
124
+ schema.type == Constants::SchemaTypes::ARRAY &&
125
+ schema.respond_to?(:items) &&
126
+ schema.items
127
+ end
128
+
129
+ # Filters enum values to those compatible with the schema variant's type.
130
+ # For mixed-type enums like ["a", 1], returns only values matching the variant type.
131
+ def filter_compatible_values(schema, values)
132
+ return values unless schema.respond_to?(:type) && schema.type
133
+ return [] if values.nil? || values.empty?
134
+
135
+ case schema.type
136
+ when Constants::SchemaTypes::STRING,
137
+ Constants::SchemaTypes::INTEGER,
138
+ Constants::SchemaTypes::NUMBER,
139
+ Constants::SchemaTypes::BOOLEAN
140
+ values.select { |value| enum_value_compatible_with_type?(schema.type, value) }
141
+ else
142
+ values # Return all values for unknown types
143
+ end
144
+ end
145
+
146
+ # Checks if a single enum value is compatible with the given schema type.
147
+ def enum_value_compatible_with_type?(schema_type, value)
148
+ case schema_type
149
+ when Constants::SchemaTypes::STRING
150
+ value.is_a?(String) || value.is_a?(Symbol)
151
+ when Constants::SchemaTypes::INTEGER
152
+ value.is_a?(Integer)
153
+ when Constants::SchemaTypes::NUMBER
154
+ value.is_a?(Numeric)
155
+ when Constants::SchemaTypes::BOOLEAN
156
+ [true, false].include?(value)
157
+ else
158
+ true
84
159
  end
85
160
  end
86
161
 
@@ -8,7 +8,7 @@ module GrapeOAS
8
8
 
9
9
  # Default response parsers in priority order
10
10
  # DocumentationResponsesParser has highest priority (most comprehensive)
11
- # HttpCodesParser handles legacy grape-swagger formats
11
+ # HttpCodesParser handles legacy grape-swagger formats and desc blocks
12
12
  # DefaultResponseParser is the fallback
13
13
  DEFAULT_PARSERS = [
14
14
  ResponseParsers::DocumentationResponsesParser,
@@ -61,15 +61,63 @@ module GrapeOAS
61
61
  end
62
62
 
63
63
  # Builds a response from a group of specs with the same status code
64
- # If multiple specs have `as:` keys, they are merged into a single object schema
64
+ # If any spec has `as:`, build a merged object response using only `as:` entries
65
+ # Else if any spec has `one_of:`, build a oneOf response from one_of entries and
66
+ # any regular specs in the group (this branch only runs when no `as:` entries exist)
65
67
  def build_response_from_group(group_specs)
66
- if group_specs.any? { |s| s[:as] }
67
- build_merged_response(group_specs)
68
+ has_one_of = group_specs.any? { |s| s[:one_of] && !s[:one_of].empty? }
69
+ has_as = group_specs.any? { |s| !s[:as].nil? }
70
+
71
+ if has_as
72
+ build_merged_response(group_specs.select { |s| s[:as] })
73
+ elsif has_one_of
74
+ build_one_of_response(group_specs)
68
75
  else
69
76
  build_response_from_spec(group_specs.first)
70
77
  end
71
78
  end
72
79
 
80
+ # Builds a oneOf response for multiple possible response schemas
81
+ def build_one_of_response(specs)
82
+ first_spec = specs.first
83
+
84
+ all_schemas = []
85
+ specs.each do |spec|
86
+ if spec[:one_of]
87
+ spec[:one_of].each do |one_of_spec|
88
+ one_of_entity = one_of_spec.is_a?(Hash) ? (one_of_spec[:model] || one_of_spec[:entity]) : nil
89
+ raise ArgumentError, "one_of items must include :model or :entity" unless one_of_entity
90
+
91
+ is_array = one_of_spec.key?(:is_array) ? one_of_spec[:is_array] : spec[:is_array]
92
+ schema = build_schema(one_of_entity)
93
+ schema = array_schema(schema) if is_array
94
+ all_schemas << schema if schema
95
+ end
96
+ else
97
+ schema = build_schema(spec[:entity])
98
+ schema = array_schema(schema) if spec[:is_array]
99
+ all_schemas << schema if schema
100
+ end
101
+ end
102
+
103
+ schema = GrapeOAS::ApiModel::Schema.new(one_of: all_schemas)
104
+ media_types = Array(response_content_types).map do |mime|
105
+ build_media_type(mime_type: mime, schema: schema)
106
+ end
107
+
108
+ message = first_spec[:message]
109
+ description = message.is_a?(String) ? message : message&.to_s
110
+
111
+ GrapeOAS::ApiModel::Response.new(
112
+ http_status: first_spec[:code].to_s,
113
+ description: description || "Success",
114
+ media_types: media_types,
115
+ headers: normalize_headers(first_spec[:headers]) || headers_from_route,
116
+ extensions: first_spec[:extensions] || extensions_from_route,
117
+ examples: merge_examples(specs),
118
+ )
119
+ end
120
+
73
121
  # Builds a merged response for multiple present with `as:` keys
74
122
  def build_merged_response(specs)
75
123
  first_spec = specs.first
@@ -78,7 +126,8 @@ module GrapeOAS
78
126
  build_media_type(mime_type: mime, schema: schema)
79
127
  end
80
128
 
81
- description = first_spec[:message].is_a?(String) ? first_spec[:message] : first_spec[:message].to_s
129
+ message = first_spec[:message]
130
+ description = message.is_a?(String) ? message : message&.to_s
82
131
 
83
132
  GrapeOAS::ApiModel::Response.new(
84
133
  http_status: first_spec[:code].to_s,
@@ -136,7 +185,8 @@ module GrapeOAS
136
185
  )
137
186
  end
138
187
 
139
- description = spec[:message].is_a?(String) ? spec[:message] : spec[:message].to_s
188
+ message = spec[:message]
189
+ description = message.is_a?(String) ? message : message&.to_s
140
190
 
141
191
  GrapeOAS::ApiModel::Response.new(
142
192
  http_status: spec[:code].to_s,
@@ -148,6 +198,13 @@ module GrapeOAS
148
198
  )
149
199
  end
150
200
 
201
+ def array_schema(schema)
202
+ GrapeOAS::ApiModel::Schema.new(
203
+ type: Constants::SchemaTypes::ARRAY,
204
+ items: schema,
205
+ )
206
+ end
207
+
151
208
  def extensions_from_route
152
209
  extract_extensions(route.options[:documentation])
153
210
  end
@@ -9,27 +9,116 @@ module GrapeOAS
9
9
  include Base
10
10
 
11
11
  def applicable?(route)
12
- route.options[:http_codes] || route.options[:failure] || route.options[:success]
12
+ options_applicable?(route) || desc_block?(route)
13
13
  end
14
14
 
15
15
  def parse(route)
16
- specs = []
16
+ specs = parse_from_options(route)
17
+ return specs unless specs.empty?
17
18
 
18
- specs.concat(parse_option(route, :http_codes)) if route.options[:http_codes]
19
- specs.concat(parse_option(route, :failure)) if route.options[:failure]
20
- specs.concat(parse_option(route, :success)) if route.options[:success]
19
+ parse_from_desc(route)
20
+ end
21
+
22
+ private
23
+
24
+ def parse_from_options(route)
25
+ specs = parse_values(route.options, route)
26
+ entity_value = route.options[:entity]
27
+ return specs unless entity_value
28
+
29
+ # Append entity from options unless desc block has explicit :success definition
30
+ # that should take precedence (stored via `success({ code: X, model: Y })` syntax)
31
+ should_append = (specs.empty? || desc_block?(route)) && !desc_block_has_explicit_success?(route)
32
+ return append_entity_spec(specs, entity_value, route) if should_append
21
33
 
22
34
  specs
23
35
  end
24
36
 
25
- private
37
+ def parse_from_desc(route)
38
+ data = desc_data(route)
39
+ return [] unless data
40
+
41
+ specs = parse_values(data, route)
42
+ specs = append_entity_spec(specs, data[:entity], route) if data[:entity]
43
+ specs
44
+ end
45
+
46
+ def parse_values(data, route)
47
+ return [] unless data.is_a?(Hash)
26
48
 
27
- def parse_option(route, option_key)
28
- value = route.options[option_key]
49
+ %i[http_codes failure success].flat_map do |key|
50
+ parse_value(data[key], route)
51
+ end
52
+ end
53
+
54
+ def parse_value(value, route)
29
55
  return [] unless value
30
56
 
31
- items = value.is_a?(Hash) ? [value] : Array(value)
32
- items.map { |entry| normalize_entry(entry, route) }
57
+ entries_for(value).map { |entry| normalize_entry(entry, route) }
58
+ end
59
+
60
+ def entries_for(value)
61
+ return [value] if value.is_a?(Hash)
62
+ return [] if value.is_a?(Array) && value.empty?
63
+ return value if value.is_a?(Array) && (value.first.is_a?(Hash) || value.first.is_a?(Array))
64
+
65
+ [value]
66
+ end
67
+
68
+ def desc_data(route)
69
+ data = route.settings&.dig(:description)
70
+ data if data.is_a?(Hash)
71
+ end
72
+
73
+ def options_applicable?(route)
74
+ entity_hash = route.options[:entity].is_a?(Hash) ? route.options[:entity] : nil
75
+ route.options[:http_codes] || route.options[:failure] || route.options[:success] ||
76
+ (entity_hash && (entity_hash[:code] || entity_hash[:model] || entity_hash[:entity] || entity_hash[:one_of]))
77
+ end
78
+
79
+ def desc_block?(route)
80
+ data = desc_data(route)
81
+ data && (data[:success] || data[:failure] || data[:http_codes] || data[:entity])
82
+ end
83
+
84
+ def desc_block_has_explicit_success?(route)
85
+ desc_data(route)&.key?(:success)
86
+ end
87
+
88
+ def append_entity_spec(specs, entity_value, route)
89
+ entity_spec = build_entity_spec(entity_value, route)
90
+ return specs if specs.any? { |spec| spec[:code].to_i == entity_spec[:code].to_i }
91
+
92
+ specs + [entity_spec]
93
+ end
94
+
95
+ def build_entity_spec(entity_value, route)
96
+ if entity_value.is_a?(Hash)
97
+ # Hash format: { code: 201, model: Entity, message: "Created" }
98
+ {
99
+ code: entity_value[:code] || 200,
100
+ message: entity_value[:message],
101
+ entity: extract_entity(entity_value, nil),
102
+ headers: entity_value[:headers],
103
+ examples: entity_value[:examples],
104
+ as: entity_value[:as],
105
+ one_of: entity_value[:one_of],
106
+ is_array: entity_value[:is_array] || route.options[:is_array],
107
+ required: entity_value[:required]
108
+ }
109
+ else
110
+ # Plain entity class
111
+ {
112
+ code: 200,
113
+ message: nil,
114
+ entity: entity_value,
115
+ headers: nil,
116
+ examples: nil,
117
+ as: nil,
118
+ is_array: route.options[:is_array],
119
+ required: nil
120
+ }
121
+ end
33
122
  end
34
123
 
35
124
  def normalize_entry(entry, route)
@@ -38,6 +127,9 @@ module GrapeOAS
38
127
  normalize_hash_entry(entry, route)
39
128
  when Array
40
129
  normalize_array_entry(entry, route)
130
+ when Class, Module
131
+ # Plain entity class (e.g., success TestEntity)
132
+ normalize_entity_entry(entry, route)
41
133
  else
42
134
  normalize_plain_entry(entry, route)
43
135
  end
@@ -52,6 +144,7 @@ module GrapeOAS
52
144
  headers: entry[:headers],
53
145
  examples: entry[:examples],
54
146
  as: entry[:as],
147
+ one_of: entry[:one_of],
55
148
  is_array: entry[:is_array] || route.options[:is_array],
56
149
  required: entry[:required]
57
150
  }
@@ -70,6 +163,17 @@ module GrapeOAS
70
163
  }
71
164
  end
72
165
 
166
+ def normalize_entity_entry(entity_class, route)
167
+ # Plain entity class (e.g., success TestEntity)
168
+ {
169
+ code: route.options[:default_status] || 200,
170
+ message: nil,
171
+ entity: entity_class,
172
+ headers: nil,
173
+ is_array: route.options[:is_array]
174
+ }
175
+ end
176
+
73
177
  def normalize_plain_entry(entry, route)
74
178
  # Plain status code (e.g., 404)
75
179
  {
@@ -57,34 +57,47 @@ module GrapeOAS
57
57
  File => SchemaTypes::FILE
58
58
  }.freeze
59
59
 
60
- # String type name to schema type mapping (lowercase).
60
+ # String type name to schema type and format mapping (lowercase).
61
61
  # Supports lookup with any case via primitive_type helper.
62
- # Note: float and bigdecimal both map to NUMBER as they represent
63
- # the same OpenAPI numeric type.
62
+ # Each entry contains :type and optional :format for OpenAPI schema generation.
63
+ #
64
+ # @see https://swagger.io/specification/#data-types
65
+ # @see https://spec.openapis.org/registry/format/
64
66
  PRIMITIVE_TYPE_MAPPING = {
65
- "float" => SchemaTypes::NUMBER,
66
- "bigdecimal" => SchemaTypes::NUMBER,
67
- "string" => SchemaTypes::STRING,
68
- "integer" => SchemaTypes::INTEGER,
69
- "number" => SchemaTypes::NUMBER,
70
- "boolean" => SchemaTypes::BOOLEAN,
71
- "grape::api::boolean" => SchemaTypes::BOOLEAN,
72
- "trueclass" => SchemaTypes::BOOLEAN,
73
- "falseclass" => SchemaTypes::BOOLEAN,
74
- "array" => SchemaTypes::ARRAY,
75
- "hash" => SchemaTypes::OBJECT,
76
- "object" => SchemaTypes::OBJECT,
77
- "file" => SchemaTypes::FILE,
78
- "rack::multipart::uploadedfile" => SchemaTypes::FILE
67
+ "float" => { type: SchemaTypes::NUMBER, format: "float" },
68
+ "bigdecimal" => { type: SchemaTypes::NUMBER, format: "double" },
69
+ "string" => { type: SchemaTypes::STRING },
70
+ "integer" => { type: SchemaTypes::INTEGER, format: "int32" },
71
+ "number" => { type: SchemaTypes::NUMBER, format: "double" },
72
+ "boolean" => { type: SchemaTypes::BOOLEAN },
73
+ "grape::api::boolean" => { type: SchemaTypes::BOOLEAN },
74
+ "trueclass" => { type: SchemaTypes::BOOLEAN },
75
+ "falseclass" => { type: SchemaTypes::BOOLEAN },
76
+ "array" => { type: SchemaTypes::ARRAY },
77
+ "hash" => { type: SchemaTypes::OBJECT },
78
+ "object" => { type: SchemaTypes::OBJECT },
79
+ "file" => { type: SchemaTypes::FILE },
80
+ "rack::multipart::uploadedfile" => { type: SchemaTypes::FILE }
79
81
  }.freeze
80
82
 
81
83
  # Resolves a primitive type name to its OpenAPI schema type.
82
84
  # Normalizes the key to lowercase for consistent lookup.
83
85
  #
84
- # @param key [String, Symbol] The type name to resolve
86
+ # @param key [String, Symbol, Class] The type name to resolve
85
87
  # @return [String, nil] The OpenAPI schema type or nil if not found
86
88
  def self.primitive_type(key)
87
- PRIMITIVE_TYPE_MAPPING[key.to_s.downcase]
89
+ entry = PRIMITIVE_TYPE_MAPPING[key.to_s.downcase]
90
+ entry&.fetch(:type, nil)
91
+ end
92
+
93
+ # Resolves the default format for a given type.
94
+ # Returns nil if no specific format applies (e.g., for strings, booleans).
95
+ #
96
+ # @param key [String, Symbol, Class] The type name to resolve format for
97
+ # @return [String, nil] The OpenAPI format or nil if not applicable
98
+ def self.format_for_type(key)
99
+ entry = PRIMITIVE_TYPE_MAPPING[key.to_s.downcase]
100
+ entry&.fetch(:format, nil)
88
101
  end
89
102
  end
90
103
  end
@@ -1,8 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "concerns/tag_builder"
4
- require_relative "concerns/schema_indexer"
5
-
6
3
  module GrapeOAS
7
4
  module Exporter
8
5
  class OAS2Schema
@@ -17,6 +17,8 @@ module GrapeOAS
17
17
  "in" => param.location,
18
18
  "required" => param.required,
19
19
  "description" => param.description,
20
+ "style" => param.style,
21
+ "explode" => param.explode,
20
22
  "schema" => Schema.new(param.schema, @ref_tracker, nullable_keyword: @nullable_keyword).build
21
23
  }.compact
22
24
  end.presence
@@ -1,8 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "concerns/tag_builder"
4
- require_relative "concerns/schema_indexer"
5
-
6
3
  module GrapeOAS
7
4
  module Exporter
8
5
  class OAS3Schema
@@ -1,10 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "base"
4
- require_relative "dry_introspector_support/contract_resolver"
5
- require_relative "dry_introspector_support/inheritance_handler"
6
- require_relative "dry_introspector_support/type_schema_builder"
7
-
8
3
  module GrapeOAS
9
4
  module Introspectors
10
5
  # Introspector for Dry::Validation contracts and Dry::Schema.
@@ -92,16 +87,30 @@ module GrapeOAS
92
87
  end
93
88
 
94
89
  def build_flat_schema
95
- rule_constraints = DryIntrospectorSupport::ConstraintExtractor.extract(contract_resolver.contract_schema)
90
+ contract_schema = contract_resolver.contract_schema
91
+
92
+ constraints_by_path, required_by_object_path =
93
+ DryIntrospectorSupport::RuleIndex.build(contract_schema)
94
+
95
+ type_schema_builder.configure_path_aware_mode(constraints_by_path, required_by_object_path)
96
+
96
97
  schema = ApiModel::Schema.new(
97
98
  type: Constants::SchemaTypes::OBJECT,
98
99
  canonical_name: contract_resolver.canonical_name,
99
100
  )
100
101
 
101
- contract_resolver.contract_schema.types.each do |name, dry_type|
102
- constraints = rule_constraints[name]
103
- prop_schema = type_schema_builder.build_schema_for_type(dry_type, constraints)
104
- schema.add_property(name, prop_schema, required: type_schema_builder.required?(dry_type, constraints))
102
+ root_required = required_by_object_path.fetch("", [])
103
+
104
+ contract_schema.types.each do |name, dry_type|
105
+ name_s = name.to_s
106
+ prop_schema = nil
107
+
108
+ type_schema_builder.with_path(name_s) do
109
+ prop_schema = type_schema_builder.build_schema_for_type(dry_type,
110
+ type_schema_builder.constraints_for_current_path,)
111
+ end
112
+
113
+ schema.add_property(name, prop_schema, required: root_required.include?(name_s))
105
114
  end
106
115
 
107
116
  # Use canonical_name as registry key for schema objects (they don't have unique classes),
@@ -7,6 +7,15 @@ module GrapeOAS
7
7
  module ArgumentExtractor
8
8
  module_function
9
9
 
10
+ # AST node tags for collection predicates (included_in?, excluded_from?)
11
+ LIST_TAGS = %i[list set].freeze
12
+ # AST node tags for literal value wrappers
13
+ LITERAL_TAGS = %i[value val literal class left right].freeze
14
+ # AST node tags for regex patterns
15
+ PATTERN_TAGS = %i[regexp regex].freeze
16
+ # Maximum size for converting ranges to enum arrays
17
+ MAX_ENUM_RANGE_SIZE = 100
18
+
10
19
  def extract_numeric(arg)
11
20
  return arg if arg.is_a?(Numeric)
12
21
  return arg[1] if arg.is_a?(Array) && arg.size == 2 && arg.first == :num
@@ -17,20 +26,56 @@ module GrapeOAS
17
26
  def extract_range(arg)
18
27
  return arg if arg.is_a?(Range)
19
28
  return arg[1] if arg.is_a?(Array) && arg.first == :range
29
+ return arg[1] if arg.is_a?(Array) && arg.first == :size && arg[1].is_a?(Range)
30
+ # Handle [:list, range] from included_in? predicates
31
+ return arg[1] if list_node?(arg) && arg[1].is_a?(Range)
20
32
 
21
33
  nil
22
34
  end
23
35
 
24
36
  def extract_list(arg)
25
- return arg[1] if arg.is_a?(Array) && %i[list set].include?(arg.first)
37
+ if list_node?(arg)
38
+ 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)
42
+
43
+ return inner
44
+ end
26
45
  return arg if arg.is_a?(Array)
46
+ return range_to_enum_array(arg) if arg.is_a?(Range)
27
47
 
28
48
  nil
29
49
  end
30
50
 
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
+
31
76
  def extract_literal(arg)
32
77
  return arg unless arg.is_a?(Array)
33
- return arg[1] if arg.length == 2 && %i[value val literal class left right].include?(arg.first)
78
+ return arg[1] if arg.length == 2 && LITERAL_TAGS.include?(arg.first)
34
79
  return extract_literal(arg.first) if arg.first.is_a?(Array)
35
80
 
36
81
  arg
@@ -38,13 +83,19 @@ module GrapeOAS
38
83
 
39
84
  def extract_pattern(arg)
40
85
  return arg.source if arg.is_a?(Regexp)
41
- return arg[1].source if arg.is_a?(Array) && arg.first == :regexp && arg[1].is_a?(Regexp)
42
- return arg[1] if arg.is_a?(Array) && arg.first == :regexp && arg[1].is_a?(String)
43
- return arg[1].source if arg.is_a?(Array) && arg.first == :regex && arg[1].is_a?(Regexp)
44
- return arg[1] if arg.is_a?(Array) && arg.first == :regex && arg[1].is_a?(String)
86
+
87
+ if arg.is_a?(Array) && PATTERN_TAGS.include?(arg.first)
88
+ return arg[1].source if arg[1].is_a?(Regexp)
89
+ return arg[1] if arg[1].is_a?(String)
90
+ end
45
91
 
46
92
  nil
47
93
  end
94
+
95
+ # Helper to check if arg is a list/set AST node
96
+ def list_node?(arg)
97
+ arg.is_a?(Array) && LIST_TAGS.include?(arg.first)
98
+ end
48
99
  end
49
100
  end
50
101
  end
@@ -68,15 +68,27 @@ module GrapeOAS
68
68
  def build_child_only_schema(parent_contract, type_schema_builder)
69
69
  child_schema = ApiModel::Schema.new(type: Constants::SchemaTypes::OBJECT)
70
70
  parent_keys = parent_contract_types(parent_contract)
71
- rule_constraints = ConstraintExtractor.extract(@contract_resolver.contract_schema)
71
+ contract_schema = @contract_resolver.contract_schema
72
72
 
73
- @contract_resolver.contract_schema.types.each do |name, dry_type|
73
+ constraints_by_path, required_by_object_path =
74
+ RuleIndex.build(contract_schema)
75
+
76
+ type_schema_builder.configure_path_aware_mode(constraints_by_path, required_by_object_path)
77
+ root_required = required_by_object_path.fetch("", [])
78
+
79
+ contract_schema.types.each do |name, dry_type|
74
80
  # Skip inherited properties
75
81
  next if parent_keys.include?(name.to_s)
76
82
 
77
- constraints = rule_constraints[name]
78
- prop_schema = type_schema_builder.build_schema_for_type(dry_type, constraints)
79
- child_schema.add_property(name, prop_schema, required: type_schema_builder.required?(dry_type, constraints))
83
+ name_s = name.to_s
84
+ prop_schema = nil
85
+
86
+ type_schema_builder.with_path(name_s) do
87
+ prop_schema = type_schema_builder.build_schema_for_type(dry_type,
88
+ type_schema_builder.constraints_for_current_path,)
89
+ end
90
+
91
+ child_schema.add_property(name, prop_schema, required: root_required.include?(name_s))
80
92
  end
81
93
 
82
94
  child_schema