grape-oas 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e5289691e338b77f7408ed384429c297bfcac143962085e4b02edc1b980b35ff
4
- data.tar.gz: 1d03adce8e12922b769ce6679013d14cb4e9bb4bc3ceaa2a6d4fdc2a26d34625
3
+ metadata.gz: 585f37dd85b6630ef0d9599e3c1a02172cda35ff81ba6b9782e500d6bdaf8115
4
+ data.tar.gz: b7e2cec6f5c24f7927e7290c87dffd2a488fe77768c039d9bc711cc79e868373
5
5
  SHA512:
6
- metadata.gz: 850eee859637b8e5cab7606b8819931c5b59479aeab6247907fde7578c43b613694a0ab7b2ffb24c1530ac1e878982f555810a7afb83b34cf378d2f6b35cc020
7
- data.tar.gz: 1298c51a115ec42502e011e50b41ae3b9bf9a553c834c49b7c3b628d285b7e2853ee8251cf55c742766d951f2ecfc525252aee1950a61ffe8fbd064a84550110
6
+ metadata.gz: 74106be01a4e9d0a9625d4b328df7b319de5dfe1da3d74d37f605d9328eb7fe200dccc761a865a66963797ad209871f71402f0c601e7f9d27ef9fbfe23408c69
7
+ data.tar.gz: fbc867bb5e8167fe096a6024285986ac410385b3bc07cdca2b1c62c73bb0cbd7a4e9e1f0dff271212637dac1ac43842e8417d36b93bc39984d396cc8cd9329f3
data/CHANGELOG.md CHANGED
@@ -5,6 +5,22 @@ 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
+ ## [1.4.0] - 2026-04-23
9
+
10
+ ### Fixed
11
+
12
+ - [#60](https://github.com/numbata/grape-oas/pull/60): Fix `Dangerfile` to properly look for tests - [@olivier-thatch](https://github.com/olivier-thatch).
13
+ - [#58](https://github.com/numbata/grape-oas/pull/58): Fix contract extraction compatibility with Grape 3.2 - [@numbata](https://github.com/numbata).
14
+ - [#57](https://github.com/numbata/grape-oas/pull/57): Fix `Array<Array<...>>` double-wrap when `is_array: true` is used with typed array notation like `type: [String]` — the redundant `is_array` flag no longer produces a nested array schema - [@numbata](https://github.com/numbata).
15
+ - [#59](https://github.com/numbata/grape-oas/pull/59): Export `default` param values to OAS2 and OAS3 output - [@olivier-thatch](https://github.com/olivier-thatch).
16
+ - [#61](https://github.com/numbata/grape-oas/pull/61): Respect `entity_name` on `grape::entity` subclasses - [@olivier-thatch](https://github.com/olivier-thatch).
17
+ - [#68](https://github.com/numbata/grape-oas/pull/68): De-duplicate parameter `description` between the Parameter Object and its nested `schema` in OAS 3 output - [@olivier-thatch](https://github.com/olivier-thatch).
18
+ - [#70](https://github.com/numbata/grape-oas/pull/70): Propagate schema attributes (`default`, `enum`, constraints, extensions) through `$ref` and composition paths - [@numbata](https://github.com/numbata).
19
+
20
+ ### Changed
21
+
22
+ - [#64](https://github.com/numbata/grape-oas/pull/64): Memoize content-type and default-format resolution per generation — eliminates redundant calls that scaled with route × response count - [@JuniorJoanis](https://github.com/JuniorJoanis).
23
+ - [#62](https://github.com/numbata/grape-oas/pull/62): Default to body params for post/put/patch routes - [@olivier-thatch](https://github.com/olivier-thatch).
8
24
 
9
25
  ## [1.3.0] - 2026-03-27
10
26
 
@@ -38,6 +38,10 @@ module GrapeOAS
38
38
  def add_tags(*tags)
39
39
  @tag_defs.merge(tags)
40
40
  end
41
+
42
+ def builder_cache
43
+ @builder_cache ||= {}
44
+ end
41
45
  end
42
46
  end
43
47
  end
@@ -11,7 +11,7 @@ module GrapeOAS
11
11
  VALID_ATTRIBUTES = %i[
12
12
  canonical_name type format properties items description
13
13
  required nullable enum additional_properties unevaluated_properties defs
14
- examples extensions
14
+ examples default extensions
15
15
  min_length max_length pattern
16
16
  minimum maximum exclusive_minimum exclusive_maximum
17
17
  min_items max_items
@@ -57,6 +57,16 @@ module GrapeOAS
57
57
  end
58
58
 
59
59
  def default_format_from_app_or_api
60
+ return uncached_default_format_from_app_or_api unless api.respond_to?(:builder_cache)
61
+
62
+ cache = api.builder_cache
63
+ key = [:default_format, app.object_id]
64
+ return cache[key] if cache.key?(key)
65
+
66
+ cache[key] = uncached_default_format_from_app_or_api
67
+ end
68
+
69
+ def uncached_default_format_from_app_or_api
60
70
  return api.default_format if api.respond_to?(:default_format)
61
71
  return app.default_format if app_responds_to?(:default_format)
62
72
 
@@ -66,6 +76,17 @@ module GrapeOAS
66
76
  end
67
77
 
68
78
  def content_types_from_app_or_api(default_format)
79
+ return uncached_content_types_from_app_or_api(default_format) unless api.respond_to?(:builder_cache)
80
+
81
+ cache = api.builder_cache
82
+ key = [:content_types, app.object_id, default_format]
83
+ return cache[key] if cache.key?(key)
84
+
85
+ value = uncached_content_types_from_app_or_api(default_format)
86
+ cache[key] = value.is_a?(Hash) ? value.dup.freeze : value
87
+ end
88
+
89
+ def uncached_content_types_from_app_or_api(default_format)
69
90
  source = if api.respond_to?(:content_types)
70
91
  api.content_types
71
92
  elsif app_responds_to?(:content_types)
@@ -12,11 +12,8 @@ module GrapeOAS
12
12
  # Centralizes Ruby type to OpenAPI schema type resolution.
13
13
  # Used by request builders and introspectors to avoid duplicated type switching logic.
14
14
  module TypeResolver
15
- # Regex to match Grape's typed array notation like "[String]", "[Integer]", "[MyModule::MyType]"
16
- TYPED_ARRAY_PATTERN = /\A\[(\w+(?:::\w+)*)\]\z/
17
-
18
- # Regex to match Grape's multi-type notation like "[String, Integer]", "[String, Float]"
19
- MULTI_TYPE_PATTERN = /\A\[(\w+(?:::\w+)*(?:,\s*\w+(?:::\w+)*)+)\]\z/
15
+ TYPED_ARRAY_PATTERN = Constants::TypePatterns::TYPED_ARRAY
16
+ MULTI_TYPE_PATTERN = Constants::TypePatterns::MULTI_TYPE
20
17
 
21
18
  # Resolves a Ruby class or type name to its OpenAPI schema type string.
22
19
  # Handles both Ruby classes (Integer, Float) and string type names ("integer", "float").
@@ -65,7 +62,7 @@ module GrapeOAS
65
62
  return nil unless type.is_a?(String)
66
63
 
67
64
  match = type.match(TYPED_ARRAY_PATTERN)
68
- match ? match[1] : nil
65
+ match ? match[:inner] : nil
69
66
  end
70
67
 
71
68
  # Checks if type is a multi-type notation like "[String, Integer]"
@@ -134,20 +134,29 @@ module GrapeOAS
134
134
  validations = setting.route[:saved_validations]
135
135
  return unless validations.is_a?(Array)
136
136
 
137
- # Find ContractScopeValidator which holds the Dry contract/schema
138
- contract_validation = validations.find do |v|
139
- next unless v.is_a?(Hash)
140
-
141
- validator_class = v[:validator_class]
142
- validator_class.is_a?(Class) &&
143
- defined?(Grape::Validations::Validators::ContractScopeValidator) &&
144
- validator_class <= Grape::Validations::Validators::ContractScopeValidator
145
- end
137
+ # Find ContractScopeValidator which holds the Dry contract/schema.
138
+ # Grape < 3.2 stores hashes: {validator_class: ..., opts: {schema: ...}}
139
+ # Grape >= 3.2 stores validator instances directly (instantiated at definition time)
140
+ return unless defined?(Grape::Validations::Validators::ContractScopeValidator)
146
141
 
147
- return unless contract_validation
142
+ validations.each do |v|
143
+ case v
144
+ when Hash
145
+ next unless v[:validator_class].is_a?(Class) &&
146
+ v[:validator_class] <= Grape::Validations::Validators::ContractScopeValidator
147
+
148
+ return v.dig(:opts, :schema)
149
+ when Grape::Validations::Validators::ContractScopeValidator
150
+ # Grape 3.2 removed attr_reader :schema and freezes the validator,
151
+ # so instance_variable_get is the only way to access the schema.
152
+ # TODO: use v.schema once ruby-grape/grape#2657 restores the accessor.
153
+ schema = v.instance_variable_get(:@schema)
154
+ GrapeOAS.logger&.warn("ContractScopeValidator found but @schema is nil") if schema.nil?
155
+ return schema
156
+ end
157
+ end
148
158
 
149
- # The contract instance is stored in opts[:schema]
150
- contract_validation.dig(:opts, :schema)
159
+ nil
151
160
  end
152
161
 
153
162
  def build_contract_schema
@@ -65,7 +65,7 @@ module GrapeOAS
65
65
  # Precedence (highest to lowest):
66
66
  # 1. `param_type` option (e.g., `documentation: { param_type: 'query' }`)
67
67
  # 2. `in` option (e.g., `documentation: { in: 'query' }`)
68
- # 3. Falls back to "query" if neither is specified
68
+ # 3. Defaults to "body" for write methods (POST/PUT/PATCH), "query" for read methods
69
69
  #
70
70
  # Note: If both `param_type` and `in` are specified, `param_type` takes precedence.
71
71
  # For example, `{ param_type: 'query', in: 'body' }` will be treated as query.
@@ -81,7 +81,12 @@ module GrapeOAS
81
81
 
82
82
  # Support both param_type and in for grape-swagger compatibility
83
83
  # param_type takes precedence over in when both are specified
84
- (param_type || in_location)&.to_s&.downcase || "query"
84
+ explicit_location = (param_type || in_location)&.to_s&.downcase
85
+ return explicit_location if explicit_location
86
+
87
+ # Default: body for write methods (POST/PUT/PATCH), query for read methods (GET/DELETE/HEAD)
88
+ http_method = route.request_method.to_s.downcase
89
+ Constants::HttpMethods::BODYLESS_HTTP_METHODS.include?(http_method) ? "query" : "body"
85
90
  end
86
91
  end
87
92
  end
@@ -33,6 +33,10 @@ 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
+
37
+ # is_array: true on a typed array like "[String]" is redundant and would
38
+ # double-wrap it as Array<Array<...>> via build_primitive_array_schema.
39
+ return GrapeOAS.type_resolvers.build_schema(raw_type) if doc[:is_array] && extract_typed_array_member(raw_type)
36
40
  return build_primitive_array_schema(doc_type, raw_type) if doc[:is_array]
37
41
  return build_entity_schema(doc_type) if grape_entity?(doc_type)
38
42
  return build_entity_schema(raw_type) if grape_entity?(raw_type)
@@ -21,6 +21,7 @@ module GrapeOAS
21
21
  apply_format_and_example(schema, doc)
22
22
  SchemaConstraints.apply(schema, doc)
23
23
  apply_values(schema, spec)
24
+ apply_default(schema, spec, doc)
24
25
  end
25
26
 
26
27
  # Extracts nullable flag from a documentation hash.
@@ -45,6 +46,16 @@ module GrapeOAS
45
46
  schema.defs = defs if defs.is_a?(Hash) && schema.respond_to?(:defs=)
46
47
  end
47
48
 
49
+ def apply_default(schema, spec, doc)
50
+ return unless schema.respond_to?(:default=)
51
+
52
+ if spec.key?(:default)
53
+ schema.default = spec[:default]
54
+ elsif doc.key?(:default)
55
+ schema.default = doc[:default]
56
+ end
57
+ end
58
+
48
59
  def apply_format_and_example(schema, doc)
49
60
  schema.format = doc[:format] if doc[:format] && schema.respond_to?(:format=)
50
61
  schema.examples = doc[:example] if doc[:example] && schema.respond_to?(:examples=)
@@ -51,6 +51,15 @@ module GrapeOAS
51
51
  # Prevents OOM on wide string ranges (e.g. "a".."zzzzzz").
52
52
  MAX_ENUM_RANGE_SIZE = 100
53
53
 
54
+ # Regex patterns for Grape's stringified type notations.
55
+ # Grape converts `type: [SomeClass]` to "[SomeClass]" and
56
+ # `type: [String, Integer]` to "[String, Integer]" for documentation.
57
+ module TypePatterns
58
+ CONST_NAME = /(?:::)?[A-Z]\w*(?:::[A-Z]\w*)*/
59
+ TYPED_ARRAY = /\A\[(?<inner>#{CONST_NAME})\]\z/
60
+ MULTI_TYPE = /\A\[(#{CONST_NAME}(?:,\s*#{CONST_NAME})+)\]\z/
61
+ end
62
+
54
63
  # Default values for OpenAPI spec when not provided by user
55
64
  module Defaults
56
65
  LICENSE_NAME = "Proprietary"
@@ -78,6 +78,7 @@ module GrapeOAS
78
78
  result["maxItems"] = schema.max_items if schema.respond_to?(:max_items) && !schema.max_items.nil?
79
79
  result["pattern"] = schema.pattern if schema.respond_to?(:pattern) && schema.pattern
80
80
  result["enum"] = normalize_enum(schema.enum, result["type"]) if schema.respond_to?(:enum) && schema.enum
81
+ result["default"] = schema.default if schema.respond_to?(:default) && !schema.default.nil?
81
82
  end
82
83
 
83
84
  def normalize_enum(enum_vals, type)
@@ -52,19 +52,20 @@ module GrapeOAS
52
52
  schema_hash["example"] = @schema.examples if @schema.examples
53
53
  schema_hash["required"] = @schema.required if @schema.required && !@schema.required.empty?
54
54
  schema_hash["discriminator"] = @schema.discriminator if @schema.discriminator
55
+ schema_hash["default"] = @schema.default unless @schema.default.nil?
55
56
  schema_hash
56
57
  end
57
58
 
58
- def apply_constraints(schema_hash)
59
- schema_hash["minLength"] = @schema.min_length if @schema.min_length
60
- schema_hash["maxLength"] = @schema.max_length if @schema.max_length
61
- schema_hash["pattern"] = @schema.pattern if @schema.pattern
62
- schema_hash["minimum"] = @schema.minimum if @schema.minimum
63
- schema_hash["maximum"] = @schema.maximum if @schema.maximum
64
- schema_hash["exclusiveMinimum"] = @schema.exclusive_minimum if @schema.exclusive_minimum
65
- schema_hash["exclusiveMaximum"] = @schema.exclusive_maximum if @schema.exclusive_maximum
66
- schema_hash["minItems"] = @schema.min_items if @schema.min_items
67
- schema_hash["maxItems"] = @schema.max_items if @schema.max_items
59
+ def apply_constraints(schema_hash, schema = @schema)
60
+ schema_hash["minimum"] = schema.minimum unless schema.minimum.nil?
61
+ schema_hash["maximum"] = schema.maximum unless schema.maximum.nil?
62
+ schema_hash["exclusiveMinimum"] = schema.exclusive_minimum if schema.exclusive_minimum
63
+ schema_hash["exclusiveMaximum"] = schema.exclusive_maximum if schema.exclusive_maximum
64
+ schema_hash["minLength"] = schema.min_length unless schema.min_length.nil?
65
+ schema_hash["maxLength"] = schema.max_length unless schema.max_length.nil?
66
+ schema_hash["pattern"] = schema.pattern if schema.pattern
67
+ schema_hash["minItems"] = schema.min_items unless schema.min_items.nil?
68
+ schema_hash["maxItems"] = schema.max_items unless schema.max_items.nil?
68
69
  end
69
70
 
70
71
  def apply_extensions(schema_hash)
@@ -80,28 +81,42 @@ module GrapeOAS
80
81
 
81
82
  # Build schema from oneOf/anyOf by using first type (OAS2 doesn't support these)
82
83
  # Extensions are merged to allow x-anyOf/x-oneOf for consumers that support them
84
+ #
85
+ # Only description and extensions are applied from the composition node.
86
+ # Type-specific attributes (default, enum, format, constraints) are omitted
87
+ # because they describe the multi-type composition, not the single fallback
88
+ # branch selected here.
83
89
  def build_first_of_schema(composition_type)
84
90
  schemas = @schema.send(composition_type)
85
91
  first_schema = schemas.first
86
92
  return {} unless first_schema
87
93
 
88
- # Build the first schema as the fallback
89
94
  result = build_schema_or_ref(first_schema)
90
95
  result["description"] = @schema.description.to_s if @schema.description
91
96
  apply_extensions(result)
97
+ if result.key?("$ref") && result.size > 1
98
+ ref = { "$ref" => result.delete("$ref") }
99
+ result["allOf"] = [ref]
100
+ end
92
101
  result
93
102
  end
94
103
 
95
104
  # Build allOf schema for inheritance
96
105
  def build_all_of_schema
97
- all_of_items = @schema.all_of.map do |item|
98
- build_schema_or_ref(item)
99
- end
106
+ items = @schema.all_of.map { |item| build_schema_or_ref(item) }
107
+ result = { "allOf" => items }
108
+ apply_composition_attributes(result)
109
+ result
110
+ end
100
111
 
101
- result = { "allOf" => all_of_items }
112
+ def apply_composition_attributes(result)
113
+ result["type"] = @schema.type if @schema.type
114
+ result["format"] = @schema.format if @schema.format
102
115
  result["description"] = @schema.description.to_s if @schema.description
103
- result["x-nullable"] = true if @nullable_strategy == Constants::NullableStrategy::EXTENSION && nullable?
104
- result
116
+ result["default"] = @schema.default unless @schema.default.nil?
117
+ result["enum"] = normalize_enum(@schema.enum, @schema.type) if @schema.enum
118
+ apply_constraints(result)
119
+ apply_extensions(result)
105
120
  end
106
121
 
107
122
  def build_properties(properties)
@@ -125,6 +140,10 @@ module GrapeOAS
125
140
  result["x-nullable"] = true
126
141
  end
127
142
  result["description"] = schema.description.to_s if schema.description
143
+ result["default"] = schema.default unless schema.default.nil?
144
+ result["enum"] = normalize_enum(schema.enum, schema.type) if schema.enum
145
+ apply_constraints(result, schema)
146
+ result.merge!(schema.extensions) if schema.extensions
128
147
  if result.empty?
129
148
  ref_hash
130
149
  else
@@ -12,14 +12,17 @@ module GrapeOAS
12
12
 
13
13
  def build
14
14
  Array(@op.parameters).map do |param|
15
+ schema_hash = Schema.new(param.schema, @ref_tracker, nullable_strategy: @nullable_strategy).build
16
+ schema_description = schema_hash.delete("description")
17
+ description = param.description || schema_description
15
18
  {
16
19
  "name" => param.name,
17
20
  "in" => param.location,
18
21
  "required" => param.required,
19
- "description" => param.description,
22
+ "description" => description,
20
23
  "style" => param.style,
21
24
  "explode" => param.explode,
22
- "schema" => Schema.new(param.schema, @ref_tracker, nullable_strategy: @nullable_strategy).build
25
+ "schema" => schema_hash
23
26
  }.compact
24
27
  end.presence
25
28
  end
@@ -51,6 +51,7 @@ module GrapeOAS
51
51
  end
52
52
  schema_hash["required"] = @schema.required if @schema.required && !@schema.required.empty?
53
53
  schema_hash["enum"] = normalize_enum(@schema.enum, schema_hash["type"]) if @schema.enum
54
+ schema_hash["default"] = @schema.default unless @schema.default.nil?
54
55
  schema_hash
55
56
  end
56
57
 
@@ -72,34 +73,28 @@ module GrapeOAS
72
73
  schema_hash["discriminator"] = build_discriminator if @schema.discriminator
73
74
  end
74
75
 
75
- def apply_all_constraints(schema_hash)
76
- apply_numeric_constraints(schema_hash)
77
- apply_string_constraints(schema_hash)
78
- apply_array_constraints(schema_hash)
76
+ def apply_all_constraints(schema_hash, schema = @schema)
77
+ apply_numeric_constraints(schema_hash, schema)
78
+ apply_string_constraints(schema_hash, schema)
79
+ apply_array_constraints(schema_hash, schema)
79
80
  end
80
81
 
81
82
  private
82
83
 
83
84
  # Build allOf schema for inheritance
84
85
  def build_all_of_schema
85
- all_of_items = @schema.all_of.map do |item|
86
- build_schema_or_ref(item)
87
- end
88
-
89
- result = { "allOf" => all_of_items }
90
- result["description"] = @schema.description.to_s if @schema.description
86
+ items = @schema.all_of.map { |item| build_schema_or_ref(item) }
87
+ result = { "allOf" => items }
88
+ apply_composition_attributes(result)
91
89
  apply_nullable(result)
92
90
  result
93
91
  end
94
92
 
95
93
  # Build oneOf schema for polymorphism
96
94
  def build_one_of_schema
97
- one_of_items = @schema.one_of.map do |item|
98
- build_schema_or_ref(item)
99
- end
100
-
101
- result = { "oneOf" => one_of_items }
102
- result["description"] = @schema.description.to_s if @schema.description
95
+ items = @schema.one_of.map { |item| build_schema_or_ref(item) }
96
+ result = { "oneOf" => items }
97
+ apply_composition_attributes(result)
103
98
  result["discriminator"] = build_discriminator if @schema.discriminator
104
99
  apply_nullable(result)
105
100
  result
@@ -107,17 +102,25 @@ module GrapeOAS
107
102
 
108
103
  # Build anyOf schema for polymorphism
109
104
  def build_any_of_schema
110
- any_of_items = @schema.any_of.map do |item|
111
- build_schema_or_ref(item)
112
- end
113
-
114
- result = { "anyOf" => any_of_items }
115
- result["description"] = @schema.description.to_s if @schema.description
105
+ items = @schema.any_of.map { |item| build_schema_or_ref(item) }
106
+ result = { "anyOf" => items }
107
+ apply_composition_attributes(result)
116
108
  result["discriminator"] = build_discriminator if @schema.discriminator
117
109
  apply_nullable(result)
118
110
  result
119
111
  end
120
112
 
113
+ def apply_composition_attributes(result)
114
+ result["type"] = nullable_type if @schema.type
115
+ result["format"] = @schema.format if @schema.format
116
+ result["description"] = @schema.description.to_s if @schema.description
117
+ result["default"] = @schema.default unless @schema.default.nil?
118
+ result["enum"] = normalize_enum(@schema.enum, result["type"]) if @schema.enum
119
+ sanitize_enum_against_type(result)
120
+ apply_all_constraints(result)
121
+ result.merge!(@schema.extensions) if @schema.extensions
122
+ end
123
+
121
124
  # Build OAS3 discriminator object
122
125
  def build_discriminator
123
126
  return nil unless @schema.discriminator
@@ -174,6 +177,11 @@ module GrapeOAS
174
177
 
175
178
  result = {}
176
179
  result["description"] = schema.description.to_s if schema.description
180
+ result["default"] = schema.default unless schema.default.nil?
181
+ result["enum"] = normalize_enum(schema.enum, schema.type) if schema.enum
182
+ sanitize_enum_against_type(result, type: schema.type)
183
+ apply_all_constraints(result, schema)
184
+ result.merge!(schema.extensions) if schema.extensions
177
185
  apply_nullable_to_ref(result, schema)
178
186
  if result.empty?
179
187
  ref_hash
@@ -224,40 +232,40 @@ module GrapeOAS
224
232
  result
225
233
  end
226
234
 
227
- def apply_numeric_constraints(hash)
228
- hash["minimum"] = @schema.minimum unless @schema.minimum.nil?
229
- hash["maximum"] = @schema.maximum unless @schema.maximum.nil?
235
+ def apply_numeric_constraints(hash, schema = @schema)
236
+ hash["minimum"] = schema.minimum unless schema.minimum.nil?
237
+ hash["maximum"] = schema.maximum unless schema.maximum.nil?
230
238
 
231
239
  if @nullable_strategy == Constants::NullableStrategy::TYPE_ARRAY
232
- if @schema.exclusive_minimum && !@schema.minimum.nil?
233
- hash["exclusiveMinimum"] = @schema.minimum
240
+ if schema.exclusive_minimum && !schema.minimum.nil?
241
+ hash["exclusiveMinimum"] = schema.minimum
234
242
  hash.delete("minimum")
235
243
  end
236
- if @schema.exclusive_maximum && !@schema.maximum.nil?
237
- hash["exclusiveMaximum"] = @schema.maximum
244
+ if schema.exclusive_maximum && !schema.maximum.nil?
245
+ hash["exclusiveMaximum"] = schema.maximum
238
246
  hash.delete("maximum")
239
247
  end
240
248
  else
241
- hash["exclusiveMinimum"] = @schema.exclusive_minimum if @schema.exclusive_minimum
242
- hash["exclusiveMaximum"] = @schema.exclusive_maximum if @schema.exclusive_maximum
249
+ hash["exclusiveMinimum"] = schema.exclusive_minimum if schema.exclusive_minimum
250
+ hash["exclusiveMaximum"] = schema.exclusive_maximum if schema.exclusive_maximum
243
251
  end
244
252
  end
245
253
 
246
- def apply_string_constraints(hash)
247
- hash["minLength"] = @schema.min_length unless @schema.min_length.nil?
248
- hash["maxLength"] = @schema.max_length unless @schema.max_length.nil?
249
- hash["pattern"] = @schema.pattern if @schema.pattern
254
+ def apply_string_constraints(hash, schema = @schema)
255
+ hash["minLength"] = schema.min_length unless schema.min_length.nil?
256
+ hash["maxLength"] = schema.max_length unless schema.max_length.nil?
257
+ hash["pattern"] = schema.pattern if schema.pattern
250
258
  end
251
259
 
252
- def apply_array_constraints(hash)
253
- hash["minItems"] = @schema.min_items unless @schema.min_items.nil?
254
- hash["maxItems"] = @schema.max_items unless @schema.max_items.nil?
260
+ def apply_array_constraints(hash, schema = @schema)
261
+ hash["minItems"] = schema.min_items unless schema.min_items.nil?
262
+ hash["maxItems"] = schema.max_items unless schema.max_items.nil?
255
263
  end
256
264
 
257
265
  # Ensure enum values match the declared type; drop enum if incompatible to avoid invalid specs
258
- def sanitize_enum_against_type(hash)
266
+ def sanitize_enum_against_type(hash, type: nil)
259
267
  enum_vals = hash["enum"]
260
- type_val = hash["type"]
268
+ type_val = type || hash["type"]
261
269
  return unless enum_vals && type_val
262
270
 
263
271
  base_type = if type_val.is_a?(Array)
@@ -88,12 +88,16 @@ module GrapeOAS
88
88
  def initialize_or_reuse_schema
89
89
  @registry[@entity_class] ||= ApiModel::Schema.new(
90
90
  type: Constants::SchemaTypes::OBJECT,
91
- canonical_name: @entity_class.name,
91
+ canonical_name: resolve_canonical_name,
92
92
  description: nil,
93
93
  nullable: nil,
94
94
  )
95
95
  end
96
96
 
97
+ def resolve_canonical_name
98
+ EntityIntrospectorSupport.resolve_canonical_name(@entity_class)
99
+ end
100
+
97
101
  def populate_schema(schema)
98
102
  doc = entity_doc
99
103
  apply_schema_metadata(schema, doc)
@@ -41,7 +41,7 @@ module GrapeOAS
41
41
 
42
42
  # Create allOf schema with ref to parent + child properties
43
43
  schema = ApiModel::Schema.new(
44
- canonical_name: @entity_class.name,
44
+ canonical_name: EntityIntrospectorSupport.resolve_canonical_name(@entity_class),
45
45
  all_of: [parent_schema, child_schema],
46
46
  )
47
47
 
@@ -19,6 +19,30 @@ module GrapeOAS
19
19
  []
20
20
  end
21
21
 
22
+ # Resolves the canonical name for an entity class, preferring entity_name
23
+ # when defined on the class itself (via def self. or extend) and non-blank,
24
+ # falling back to the Ruby class name. Inherited entity_name is ignored to
25
+ # avoid collisions between parent and child schemas.
26
+ def self.resolve_canonical_name(entity_class)
27
+ if defines_own_entity_name?(entity_class)
28
+ name = entity_class.entity_name
29
+ name.is_a?(String) && !name.strip.empty? ? name : entity_class.name
30
+ else
31
+ entity_class.name
32
+ end
33
+ end
34
+
35
+ def self.defines_own_entity_name?(entity_class)
36
+ return false unless entity_class.respond_to?(:entity_name)
37
+
38
+ parent = find_parent_entity(entity_class)
39
+ return true if parent.nil? || !parent.respond_to?(:entity_name)
40
+
41
+ entity_class.method(:entity_name).owner !=
42
+ parent.method(:entity_name).owner
43
+ end
44
+ private_class_method :defines_own_entity_name?
45
+
22
46
  # Finds the parent entity class if one exists in the Grape::Entity hierarchy.
23
47
  def self.find_parent_entity(entity_class)
24
48
  return nil unless defined?(Grape::Entity)
@@ -20,15 +20,13 @@ module GrapeOAS
20
20
  class ArrayResolver
21
21
  extend Base
22
22
 
23
- # Pattern to match Grape's array notation: "[Type]" or "[Module::Type]"
24
- # Intentionally narrow: avoid treating multi-type strings like "[String, Integer]" as arrays.
25
- ARRAY_PATTERN = /\A\[(?<inner>(?:::)?[A-Z]\w*(?:::[A-Z]\w*)*)\]\z/
23
+ TYPED_ARRAY_PATTERN = Constants::TypePatterns::TYPED_ARRAY
26
24
 
27
25
  class << self
28
26
  def handles?(type)
29
27
  return false unless type.is_a?(String)
30
28
 
31
- type.match?(ARRAY_PATTERN)
29
+ type.match?(TYPED_ARRAY_PATTERN)
32
30
  end
33
31
 
34
32
  def build_schema(type)
@@ -53,7 +51,7 @@ module GrapeOAS
53
51
  private
54
52
 
55
53
  def extract_inner_type(type)
56
- match = type.match(ARRAY_PATTERN)
54
+ match = type.match(TYPED_ARRAY_PATTERN)
57
55
  match[:inner] if match
58
56
  end
59
57
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module GrapeOAS
4
- VERSION = "1.3.0"
4
+ VERSION = "1.4.0"
5
5
  end
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.3.0
4
+ version: 1.4.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: 2026-03-27 00:00:00.000000000 Z
11
+ date: 2026-04-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: grape