grape-oas 1.0.3 → 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.
@@ -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
@@ -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
@@ -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
@@ -18,20 +27,55 @@ module GrapeOAS
18
27
  return arg if arg.is_a?(Range)
19
28
  return arg[1] if arg.is_a?(Array) && arg.first == :range
20
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)
21
32
 
22
33
  nil
23
34
  end
24
35
 
25
36
  def extract_list(arg)
26
- 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
27
45
  return arg if arg.is_a?(Array)
46
+ return range_to_enum_array(arg) if arg.is_a?(Range)
28
47
 
29
48
  nil
30
49
  end
31
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
+
32
76
  def extract_literal(arg)
33
77
  return arg unless arg.is_a?(Array)
34
- 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)
35
79
  return extract_literal(arg.first) if arg.first.is_a?(Array)
36
80
 
37
81
  arg
@@ -39,13 +83,19 @@ module GrapeOAS
39
83
 
40
84
  def extract_pattern(arg)
41
85
  return arg.source if arg.is_a?(Regexp)
42
- return arg[1].source if arg.is_a?(Array) && arg.first == :regexp && arg[1].is_a?(Regexp)
43
- return arg[1] if arg.is_a?(Array) && arg.first == :regexp && arg[1].is_a?(String)
44
- return arg[1].source if arg.is_a?(Array) && arg.first == :regex && arg[1].is_a?(Regexp)
45
- 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
46
91
 
47
92
  nil
48
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
49
99
  end
50
100
  end
51
101
  end
@@ -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)
@@ -111,12 +134,8 @@ module GrapeOAS
111
134
  end
112
135
 
113
136
  def handle_range(args)
114
- rng = args.first.is_a?(Range) ? args.first : ArgumentExtractor.extract_range(args.first)
115
- return unless rng
116
-
117
- constraints.minimum = rng.begin if rng.begin
118
- constraints.maximum = rng.end if rng.end
119
- constraints.exclusive_maximum = rng.exclude_end?
137
+ rng = ArgumentExtractor.extract_range(args.first)
138
+ apply_range_constraints(rng)
120
139
  end
121
140
 
122
141
  def handle_multiple_of(args)
@@ -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
@@ -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.3"
4
+ VERSION = "1.1.0"
5
5
  end