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