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
@@ -59,7 +59,6 @@ module GrapeOAS
59
59
  when :email? then constraints.format = "email"
60
60
  when :date? then constraints.format = "date"
61
61
  when :time?, :date_time? then constraints.format = "date-time"
62
- when :bool?, :boolean? then constraints.type_predicate ||= :boolean
63
62
  when :type? then constraints.type_predicate = ArgumentExtractor.extract_literal(args.first)
64
63
  when :odd? then constraints.parity = :odd
65
64
  when :even? then constraints.parity = :even
@@ -68,7 +67,31 @@ module GrapeOAS
68
67
 
69
68
  def apply_enum_from_list(args)
70
69
  vals = ArgumentExtractor.extract_list(args.first)
71
- constraints.enum = vals if vals
70
+ if vals
71
+ constraints.enum = vals
72
+ else
73
+ # For numeric ranges, extract min/max instead of enum
74
+ apply_min_max_from_range(args)
75
+ end
76
+ end
77
+
78
+ def apply_min_max_from_range(args)
79
+ rng = ArgumentExtractor.extract_range(args.first)
80
+ return unless rng
81
+ # Only apply min/max for numeric ranges; non-numeric ranges that can't
82
+ # be enumerated should be silently ignored rather than producing invalid schema
83
+ return if rng.begin && !rng.begin.is_a?(Numeric)
84
+ return if rng.end && !rng.end.is_a?(Numeric)
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
72
95
  end
73
96
 
74
97
  def apply_excluded_from_list(args)
@@ -97,19 +120,22 @@ module GrapeOAS
97
120
  end
98
121
 
99
122
  def handle_size(name, args)
100
- min_val = ArgumentExtractor.extract_numeric(args[0])
101
- max_val = ArgumentExtractor.extract_numeric(args[1]) if name == :size?
102
- constraints.min_size = min_val if min_val
103
- constraints.max_size = max_val if max_val
123
+ rng = ArgumentExtractor.extract_range(args.first)
124
+
125
+ if rng
126
+ constraints.min_size = rng.begin if rng.begin
127
+ constraints.max_size = rng.max if rng.end
128
+ else
129
+ min_val = ArgumentExtractor.extract_numeric(args[0])
130
+ max_val = ArgumentExtractor.extract_numeric(args[1]) if name == :size?
131
+ constraints.min_size = min_val if min_val
132
+ constraints.max_size = max_val if max_val
133
+ end
104
134
  end
105
135
 
106
136
  def handle_range(args)
107
- rng = args.first.is_a?(Range) ? args.first : ArgumentExtractor.extract_range(args.first)
108
- return unless rng
109
-
110
- constraints.minimum = rng.begin if rng.begin
111
- constraints.maximum = rng.end if rng.end
112
- constraints.exclusive_maximum = rng.exclude_end?
137
+ rng = ArgumentExtractor.extract_range(args.first)
138
+ apply_range_constraints(rng)
113
139
  end
114
140
 
115
141
  def handle_multiple_of(args)
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ast_walker"
4
+ require_relative "constraint_extractor"
5
+ require_relative "constraint_merger"
6
+
7
+ module GrapeOAS
8
+ module Introspectors
9
+ module DryIntrospectorSupport
10
+ # Builds path-aware constraint and required field indexes from dry-schema AST
11
+ class RuleIndex
12
+ def initialize(contract_schema)
13
+ @walker = AstWalker.new(ConstraintExtractor::ConstraintSet)
14
+ @merger = ConstraintMerger
15
+ @constraints_by_path = {}
16
+ @required_by_object_path = Hash.new { |h, k| h[k] = {} }
17
+
18
+ build_indexes(contract_schema)
19
+ end
20
+
21
+ def self.build(contract_schema)
22
+ new(contract_schema).to_a
23
+ end
24
+
25
+ def to_a
26
+ [@constraints_by_path, @required_by_object_path.transform_values(&:keys)]
27
+ end
28
+
29
+ private
30
+
31
+ def build_indexes(contract_schema)
32
+ rules = contract_schema.respond_to?(:rules) ? contract_schema.rules : {}
33
+ rules.each_value do |rule|
34
+ ast = rule.respond_to?(:to_ast) ? rule.to_ast : rule
35
+ collect_constraints(ast, [])
36
+ collect_required(ast, [], in_implication_condition: false)
37
+ end
38
+ end
39
+
40
+ def collect_constraints(ast, path)
41
+ return unless ast.is_a?(Array)
42
+
43
+ case ast[0]
44
+ when :key
45
+ key_name, value_ast = parse_key_node(ast)
46
+ return unless key_name && value_ast.is_a?(Array)
47
+
48
+ new_path = path + [key_name]
49
+ apply_node_constraints(value_ast, new_path)
50
+ collect_constraints(value_ast, new_path)
51
+
52
+ when :each
53
+ child = ast[1]
54
+ return unless child.is_a?(Array)
55
+
56
+ item_path = path + ["[]"]
57
+
58
+ # Index constraints that apply to the item schema itself
59
+ apply_node_constraints(child, item_path)
60
+
61
+ # Recurse so nested keys inside the item get their own paths
62
+ collect_constraints(child, item_path)
63
+ else
64
+ ast.each { |child| collect_constraints(child, path) if child.is_a?(Array) }
65
+ end
66
+ end
67
+
68
+ def collect_required(ast, object_path, in_implication_condition:)
69
+ return unless ast.is_a?(Array)
70
+
71
+ case ast[0]
72
+ when :implication
73
+ left, right = ast[1].is_a?(Array) ? ast[1] : [nil, nil]
74
+ collect_required(left, object_path, in_implication_condition: true) if left
75
+ collect_required(right, object_path, in_implication_condition: false) if right
76
+
77
+ when :predicate
78
+ mark_required_if_key_predicate(ast[1], object_path) unless in_implication_condition
79
+
80
+ when :key, :each
81
+ if ast[0] == :key
82
+ key_name, value_ast = parse_key_node(ast)
83
+ if key_name && value_ast.is_a?(Array)
84
+ collect_required(value_ast, object_path + [key_name],
85
+ in_implication_condition: in_implication_condition,)
86
+ end
87
+ elsif ast[1] # :each
88
+ collect_required(ast[1], object_path + ["[]"],
89
+ in_implication_condition: in_implication_condition,)
90
+ end
91
+
92
+ else
93
+ ast.each do |child|
94
+ collect_required(child, object_path, in_implication_condition: in_implication_condition) if child.is_a?(Array)
95
+ end
96
+ end
97
+ end
98
+
99
+ def parse_key_node(ast)
100
+ info = ast[1]
101
+ return [nil, nil] unless info.is_a?(Array) && info.any?
102
+
103
+ key_name = info[0]
104
+ value_ast = info[1] || info[-1]
105
+ [key_name&.to_s, value_ast]
106
+ end
107
+
108
+ def apply_node_constraints(value_ast, path)
109
+ pruned = prune_nested_validations(value_ast)
110
+ return unless pruned
111
+
112
+ constraints = @walker.walk(pruned)
113
+ constraints.required = nil if constraints.respond_to?(:required=)
114
+
115
+ path_key = path.join("/")
116
+ if @constraints_by_path.key?(path_key)
117
+ @merger.merge(@constraints_by_path[path_key], constraints)
118
+ else
119
+ @constraints_by_path[path_key] = constraints
120
+ end
121
+ end
122
+
123
+ def prune_nested_validations(ast)
124
+ return ast unless ast.is_a?(Array)
125
+
126
+ tag = ast[0]
127
+ return ast unless tag.is_a?(Symbol)
128
+
129
+ case tag
130
+ when :each, :key
131
+ nil
132
+
133
+ when :set
134
+ children, wrapped = extract_children(ast)
135
+ pruned = children.filter_map { |c| c.is_a?(Array) ? prune_nested_validations(c) : c }
136
+ return nil if pruned.empty?
137
+
138
+ # rewrite set -> and, preserve wrapper style
139
+ wrapped ? [:and, pruned] : [:and, *pruned]
140
+
141
+ when :and, :or, :rule
142
+ children, wrapped = extract_children(ast)
143
+ pruned = children.filter_map { |c| c.is_a?(Array) ? prune_nested_validations(c) : c }
144
+ return nil if pruned.empty?
145
+
146
+ wrapped ? [tag, pruned] : [tag, *pruned]
147
+
148
+ when :implication
149
+ pair = ast[1]
150
+ return ast unless pair.is_a?(Array) && pair.size >= 2
151
+
152
+ left = pair[0].is_a?(Array) ? prune_nested_validations(pair[0]) : pair[0]
153
+ right = pair[1].is_a?(Array) ? prune_nested_validations(pair[1]) : pair[1]
154
+ [:implication, [left, right]]
155
+
156
+ when :not
157
+ child = ast[1]
158
+ child = prune_nested_validations(child) if child.is_a?(Array)
159
+ [:not, child]
160
+
161
+ else
162
+ ast
163
+ end
164
+ end
165
+
166
+ def extract_children(ast)
167
+ # handles both shapes:
168
+ # [:and, [node1, node2]]
169
+ # [:and, node1, node2]
170
+ payload = ast[1]
171
+
172
+ if payload.is_a?(Array) && !payload.empty? && payload.all? { |x| x.is_a?(Array) && x[0].is_a?(Symbol) }
173
+ [payload, true] # wrapped list
174
+ else
175
+ [ast[1..], false] # splatted
176
+ end
177
+ end
178
+
179
+ def mark_required_if_key_predicate(pred, object_path)
180
+ return unless pred.is_a?(Array) && pred[0] == :key?
181
+
182
+ name = extract_key_name(pred)
183
+ @required_by_object_path[object_path.join("/")][name] = true if name
184
+ end
185
+
186
+ def extract_key_name(pred_node)
187
+ args = pred_node[1]
188
+ return nil unless args.is_a?(Array)
189
+
190
+ name_pair = args.find { |x| x.is_a?(Array) && x[0] == :name }
191
+ name_pair&.dig(1)&.to_s
192
+ end
193
+ end
194
+ end
195
+ end
196
+ end
@@ -11,7 +11,39 @@ module GrapeOAS
11
11
  ConstraintSet = ConstraintExtractor::ConstraintSet
12
12
 
13
13
  def initialize
14
- # Stateless builder - no initialization needed
14
+ @path_stack = []
15
+ @constraints_by_path = nil
16
+ @required_by_object_path = nil
17
+ end
18
+
19
+ def configure_path_aware_mode(constraints_by_path, required_by_object_path)
20
+ @path_stack = []
21
+ @constraints_by_path = constraints_by_path
22
+ @required_by_object_path = required_by_object_path
23
+ end
24
+
25
+ def with_path(part)
26
+ @path_stack << part
27
+ yield
28
+ ensure
29
+ @path_stack.pop
30
+ end
31
+
32
+ def current_object_path
33
+ @path_stack.join("/")
34
+ end
35
+
36
+ def constraints_for_current_path
37
+ return nil unless @constraints_by_path
38
+
39
+ @constraints_by_path[current_object_path]
40
+ end
41
+
42
+ def required_keys_for_current_object
43
+ return nil unless @required_by_object_path
44
+
45
+ # In path-aware mode we rely entirely on rule-index requiredness
46
+ @required_by_object_path[current_object_path] || []
15
47
  end
16
48
 
17
49
  # Builds a schema for a Dry type.
@@ -20,7 +52,7 @@ module GrapeOAS
20
52
  # @param constraints [ConstraintSet, nil] extracted constraints
21
53
  # @return [ApiModel::Schema] the built schema
22
54
  def build_schema_for_type(dry_type, constraints = nil)
23
- constraints ||= ConstraintSet.new(unhandled_predicates: [])
55
+ constraints ||= constraints_for_current_path || ConstraintSet.new(unhandled_predicates: [])
24
56
  meta = dry_type.respond_to?(:meta) ? dry_type.meta : {}
25
57
 
26
58
  # Check for Sum type first (TypeA | TypeB) -> anyOf
@@ -29,6 +61,10 @@ module GrapeOAS
29
61
  # Check for Hash schema type (nested schemas like .hash(SomeSchema))
30
62
  return build_hash_schema(dry_type) if hash_schema_type?(dry_type)
31
63
 
64
+ # Check for object schema (unwrapped hash with keys)
65
+ unwrapped = TypeUnwrapper.unwrap(dry_type)
66
+ return build_object_schema(unwrapped) if unwrapped.respond_to?(:keys)
67
+
32
68
  primitive, member = TypeUnwrapper.derive_primitive_and_member(dry_type)
33
69
  enum_vals = extract_enum_from_type(dry_type)
34
70
 
@@ -59,6 +95,36 @@ module GrapeOAS
59
95
 
60
96
  private
61
97
 
98
+ def build_object_schema(unwrapped_schema_type)
99
+ schema = ApiModel::Schema.new(type: Constants::SchemaTypes::OBJECT)
100
+ required_keys = required_keys_for_current_object
101
+
102
+ # Dry::Types::Schema does not have each_key, so we disable the cop here
103
+ unwrapped_schema_type.keys.each do |key| # rubocop:disable Style/HashEachMethods
104
+ key_name = key.respond_to?(:name) ? key.name.to_s : key.to_s
105
+ key_type = key.respond_to?(:type) ? key.type : nil
106
+
107
+ prop_schema = nil
108
+ with_path(key_name) do
109
+ prop_schema = if key_type
110
+ build_schema_for_type(key_type, constraints_for_current_path)
111
+ else
112
+ default_string_schema
113
+ end
114
+ end
115
+
116
+ is_required = if required_keys
117
+ required_keys.include?(key_name)
118
+ else
119
+ key.respond_to?(:required?) ? key.required? : false
120
+ end
121
+
122
+ schema.add_property(key_name, prop_schema, required: is_required)
123
+ end
124
+
125
+ schema
126
+ end
127
+
62
128
  def build_any_of_schema(sum_type)
63
129
  types = TypeUnwrapper.extract_sum_types(sum_type)
64
130
 
@@ -86,29 +152,35 @@ module GrapeOAS
86
152
  end
87
153
 
88
154
  def build_hash_schema(dry_type)
89
- schema = ApiModel::Schema.new(type: Constants::SchemaTypes::OBJECT)
90
155
  unwrapped = TypeUnwrapper.unwrap(dry_type)
91
156
 
92
- return schema unless unwrapped.respond_to?(:keys)
157
+ # Delegate to the same path-aware logic as regular object schemas.
158
+ # This ensures nested rule constraints (e.g. max_size?, gteq?, format?) are applied
159
+ # to properties inside `.hash do ... end` blocks.
160
+ return ApiModel::Schema.new(type: Constants::SchemaTypes::OBJECT) unless unwrapped.respond_to?(:keys)
93
161
 
94
- # Dry::Schema keys method returns an array of Key objects, not a Hash
95
- schema_keys = unwrapped.keys
96
- schema_keys.each do |key|
97
- key_name = key.respond_to?(:name) ? key.name.to_s : key.to_s
98
- key_type = key.respond_to?(:type) ? key.type : nil
99
-
100
- prop_schema = key_type ? build_schema_for_type(key_type) : default_string_schema
101
- req = key.respond_to?(:required?) ? key.required? : true
102
- schema.add_property(key_name, prop_schema, required: req)
103
- end
104
-
105
- schema
162
+ build_object_schema(unwrapped)
106
163
  end
107
164
 
108
165
  def build_base_schema(primitive, member)
109
166
  if primitive == Array
110
- items_schema = member ? build_schema_for_type(member) : default_string_schema
167
+ items_schema = nil
168
+
169
+ with_path("[]") do
170
+ if member
171
+ unwrapped = TypeUnwrapper.unwrap(member)
172
+ items_schema = if unwrapped.respond_to?(:keys)
173
+ build_object_schema(unwrapped)
174
+ else
175
+ build_schema_for_type(member, constraints_for_current_path)
176
+ end
177
+ else
178
+ items_schema = default_string_schema
179
+ end
180
+ end
181
+
111
182
  ApiModel::Schema.new(type: Constants::SchemaTypes::ARRAY, items: items_schema)
183
+
112
184
  else
113
185
  build_schema_for_primitive(primitive)
114
186
  end
@@ -24,6 +24,9 @@ module GrapeOAS
24
24
  # @param dry_type [Dry::Types::Type] the type to analyze
25
25
  # @return [Array(Class, Object)] tuple of [primitive_class, member_type_or_nil]
26
26
  def derive_primitive_and_member(dry_type)
27
+ # Handle boolean Sum types (TrueClass | FalseClass)
28
+ return [TrueClass, nil] if boolean_sum_type?(dry_type)
29
+
27
30
  core = unwrap(dry_type)
28
31
 
29
32
  return [Array, core.type.member] if array_member_type?(core)
@@ -137,6 +140,22 @@ module GrapeOAS
137
140
  core.primitive == Array
138
141
  end
139
142
  private_class_method :array_with_member?
143
+
144
+ def boolean_sum_type?(dry_type)
145
+ return false unless dry_type.respond_to?(:left) && dry_type.respond_to?(:right)
146
+
147
+ boolean_type?(dry_type.left) && boolean_type?(dry_type.right)
148
+ end
149
+ private_class_method :boolean_sum_type?
150
+
151
+ def boolean_type?(dry_type)
152
+ return false unless dry_type
153
+
154
+ return [TrueClass, FalseClass].include?(dry_type.primitive) if dry_type.respond_to?(:primitive)
155
+
156
+ false
157
+ end
158
+ private_class_method :boolean_type?
140
159
  end
141
160
  end
142
161
  end
@@ -1,13 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "base"
4
- require_relative "../api_model_builders/concerns/type_resolver"
5
- require_relative "entity_introspector_support/cycle_tracker"
6
- require_relative "entity_introspector_support/discriminator_handler"
7
- require_relative "entity_introspector_support/inheritance_builder"
8
- require_relative "entity_introspector_support/property_extractor"
9
- require_relative "entity_introspector_support/exposure_processor"
10
-
11
3
  module GrapeOAS
12
4
  module Introspectors
13
5
  # Introspector for Grape::Entity classes.
@@ -59,7 +59,7 @@ module GrapeOAS
59
59
  # @return [ApiModel::Schema] the built schema
60
60
  def schema_for_exposure(exposure, doc)
61
61
  opts = exposure.instance_variable_get(:@options) || {}
62
- type = doc[:type] || doc["type"] || opts[:using]
62
+ type = opts[:using] || doc[:type] || doc["type"]
63
63
 
64
64
  schema = build_exposure_base_schema(type)
65
65
  apply_exposure_properties(schema, doc)
@@ -243,7 +243,7 @@ module GrapeOAS
243
243
 
244
244
  def resolve_entity_from_opts(exposure, doc)
245
245
  opts = exposure.instance_variable_get(:@options) || {}
246
- type = doc[:type] || doc["type"] || opts[:using]
246
+ type = opts[:using] || doc[:type] || doc["type"]
247
247
  return type if defined?(Grape::Entity) && type.is_a?(Class) && type <= Grape::Entity
248
248
 
249
249
  nil
@@ -253,7 +253,10 @@ module GrapeOAS
253
253
  schema_type = Constants.primitive_type(type)
254
254
  return nil unless schema_type
255
255
 
256
- ApiModel::Schema.new(type: schema_type)
256
+ ApiModel::Schema.new(
257
+ type: schema_type,
258
+ format: Constants.format_for_type(type),
259
+ )
257
260
  end
258
261
  end
259
262
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module GrapeOAS
4
- VERSION = "1.0.2"
4
+ VERSION = "1.1.0"
5
5
  end
data/lib/grape_oas.rb CHANGED
@@ -124,7 +124,7 @@ module GrapeOAS
124
124
  # app: MyAPI,
125
125
  # schema_type: :oas3,
126
126
  # title: "My API",
127
- # version: "1.0.0"
127
+ # version: "1.1.0"
128
128
  # )
129
129
  #
130
130
  # @example Filter by namespace
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: grape-oas
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.2
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrei Subbota
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-12-15 00:00:00.000000000 Z
11
+ date: 2026-01-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: grape
@@ -114,6 +114,7 @@ files:
114
114
  - lib/grape_oas/introspectors/dry_introspector_support/contract_resolver.rb
115
115
  - lib/grape_oas/introspectors/dry_introspector_support/inheritance_handler.rb
116
116
  - lib/grape_oas/introspectors/dry_introspector_support/predicate_handler.rb
117
+ - lib/grape_oas/introspectors/dry_introspector_support/rule_index.rb
117
118
  - lib/grape_oas/introspectors/dry_introspector_support/type_schema_builder.rb
118
119
  - lib/grape_oas/introspectors/dry_introspector_support/type_unwrapper.rb
119
120
  - lib/grape_oas/introspectors/entity_introspector.rb