grape-oas 1.2.0 → 1.3.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 +4 -4
- data/CHANGELOG.md +26 -0
- data/README.md +6 -0
- data/lib/grape_oas/api_model/schema.rb +17 -2
- data/lib/grape_oas/api_model_builders/request_params_support/param_schema_builder.rb +11 -1
- data/lib/grape_oas/api_model_builders/request_params_support/schema_enhancer.rb +23 -50
- data/lib/grape_oas/constants.rb +4 -0
- data/lib/grape_oas/doc_key_normalizer.rb +14 -0
- data/lib/grape_oas/exporter/oas2/schema.rb +18 -3
- data/lib/grape_oas/exporter/oas3/schema.rb +33 -6
- data/lib/grape_oas/introspectors/dry_introspector_support/argument_extractor.rb +2 -31
- data/lib/grape_oas/introspectors/dry_introspector_support/constraint_extractor.rb +10 -19
- data/lib/grape_oas/introspectors/dry_introspector_support/predicate_handler.rb +2 -10
- data/lib/grape_oas/introspectors/entity_introspector_support/discriminator_handler.rb +2 -25
- data/lib/grape_oas/introspectors/entity_introspector_support/exposure_processor.rb +139 -136
- data/lib/grape_oas/introspectors/entity_introspector_support/inheritance_builder.rb +5 -29
- data/lib/grape_oas/introspectors/entity_introspector_support/nesting_merger.rb +104 -0
- data/lib/grape_oas/introspectors/entity_introspector_support/property_extractor.rb +2 -1
- data/lib/grape_oas/introspectors/entity_introspector_support/type_schema_resolver.rb +139 -0
- data/lib/grape_oas/introspectors/entity_introspector_support.rb +33 -0
- data/lib/grape_oas/range_utils.rb +87 -0
- data/lib/grape_oas/schema_constraints.rb +36 -0
- data/lib/grape_oas/values_normalizer.rb +47 -0
- data/lib/grape_oas/version.rb +1 -1
- data/lib/grape_oas.rb +27 -0
- metadata +9 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e5289691e338b77f7408ed384429c297bfcac143962085e4b02edc1b980b35ff
|
|
4
|
+
data.tar.gz: 1d03adce8e12922b769ce6679013d14cb4e9bb4bc3ceaa2a6d4fdc2a26d34625
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 850eee859637b8e5cab7606b8819931c5b59479aeab6247907fde7578c43b613694a0ab7b2ffb24c1530ac1e878982f555810a7afb83b34cf378d2f6b35cc020
|
|
7
|
+
data.tar.gz: 1298c51a115ec42502e011e50b41ae3b9bf9a553c834c49b7c3b628d285b7e2853ee8251cf55c742766d951f2ecfc525252aee1950a61ffe8fbd064a84550110
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,32 @@ 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
|
+
|
|
9
|
+
## [1.3.0] - 2026-03-27
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- [#48](https://github.com/numbata/grape-oas/pull/48): Add configurable `GrapeOAS.logger` for schema generation warnings - [@numbata](https://github.com/numbata).
|
|
14
|
+
- [#43](https://github.com/numbata/grape-oas/pull/43): Bump actions/upload-artifact from 6 to 7 - [@dependabot[bot]](https://github.com/dependabot[bot]).
|
|
15
|
+
- [#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).
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- [#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).
|
|
20
|
+
- [#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).
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- [#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).
|
|
25
|
+
- [#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).
|
|
26
|
+
- [#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).
|
|
27
|
+
|
|
28
|
+
- [#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).
|
|
29
|
+
- [#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).
|
|
30
|
+
- [#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).
|
|
31
|
+
- [#44](https://github.com/numbata/grape-oas/pull/44): Fix RuboCop 1.85 offenses - [@numbata](https://github.com/numbata).
|
|
32
|
+
- [#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).
|
|
33
|
+
|
|
8
34
|
## [1.2.0] - 2026-03-02
|
|
9
35
|
|
|
10
36
|
### 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
|
|
|
@@ -51,10 +51,25 @@ module GrapeOAS
|
|
|
51
51
|
end
|
|
52
52
|
|
|
53
53
|
def add_property(name, schema, required: false)
|
|
54
|
-
|
|
55
|
-
@
|
|
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
|
|
@@ -33,6 +33,7 @@ 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
|
+
return build_primitive_array_schema(doc_type, raw_type) if doc[:is_array]
|
|
36
37
|
return build_entity_schema(doc_type) if grape_entity?(doc_type)
|
|
37
38
|
return build_entity_schema(raw_type) if grape_entity?(raw_type)
|
|
38
39
|
return build_elements_array_schema(spec) if array_with_elements?(raw_type, spec)
|
|
@@ -92,6 +93,15 @@ module GrapeOAS
|
|
|
92
93
|
ApiModel::Schema.new(type: Constants::SchemaTypes::ARRAY, items: items_schema)
|
|
93
94
|
end
|
|
94
95
|
|
|
96
|
+
def build_primitive_array_schema(doc_type, raw_type)
|
|
97
|
+
type_source = doc_type || raw_type
|
|
98
|
+
item_type = resolve_schema_type(type_source)
|
|
99
|
+
ApiModel::Schema.new(
|
|
100
|
+
type: Constants::SchemaTypes::ARRAY,
|
|
101
|
+
items: ApiModel::Schema.new(type: item_type, format: Constants.format_for_type(type_source)),
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
|
|
95
105
|
def build_simple_array_schema
|
|
96
106
|
ApiModel::Schema.new(
|
|
97
107
|
type: Constants::SchemaTypes::ARRAY,
|
|
@@ -191,7 +201,7 @@ module GrapeOAS
|
|
|
191
201
|
klass = Object.const_get(const_name, false)
|
|
192
202
|
klass if klass.is_a?(Class) && klass <= Grape::Entity
|
|
193
203
|
rescue NameError => e
|
|
194
|
-
warn
|
|
204
|
+
GrapeOAS.logger.warn("Could not resolve entity constant '#{const_name}': #{e.message}")
|
|
195
205
|
nil
|
|
196
206
|
end
|
|
197
207
|
end
|
|
@@ -19,7 +19,7 @@ module GrapeOAS
|
|
|
19
19
|
|
|
20
20
|
apply_additional_properties(schema, doc)
|
|
21
21
|
apply_format_and_example(schema, doc)
|
|
22
|
-
|
|
22
|
+
SchemaConstraints.apply(schema, doc)
|
|
23
23
|
apply_values(schema, spec)
|
|
24
24
|
end
|
|
25
25
|
|
|
@@ -50,40 +50,25 @@ module GrapeOAS
|
|
|
50
50
|
schema.examples = doc[:example] if doc[:example] && schema.respond_to?(:examples=)
|
|
51
51
|
end
|
|
52
52
|
|
|
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
53
|
def apply_values(schema, spec)
|
|
67
|
-
values = spec[:values]
|
|
54
|
+
values = ValuesNormalizer.normalize(spec[:values], context: "parameter values")
|
|
68
55
|
return unless values
|
|
69
56
|
|
|
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
57
|
if values.is_a?(Range)
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
58
|
+
if one_of_schema?(schema)
|
|
59
|
+
schema.one_of.each do |variant|
|
|
60
|
+
next if null_type_schema?(variant)
|
|
61
|
+
next unless range_compatible_with_schema?(values, variant)
|
|
62
|
+
|
|
63
|
+
RangeUtils.apply_to_schema(variant, values)
|
|
64
|
+
end
|
|
65
|
+
elsif array_schema_with_items?(schema)
|
|
66
|
+
RangeUtils.apply_to_schema(schema.items, values)
|
|
67
|
+
else
|
|
68
|
+
RangeUtils.apply_to_schema(schema, values)
|
|
69
|
+
end
|
|
70
|
+
elsif values.is_a?(Array) && !values.empty?
|
|
71
|
+
apply_enum_values(schema, values)
|
|
87
72
|
end
|
|
88
73
|
end
|
|
89
74
|
|
|
@@ -98,7 +83,7 @@ module GrapeOAS
|
|
|
98
83
|
compatible_values = filter_compatible_values(variant, values)
|
|
99
84
|
|
|
100
85
|
# Only apply enum if there are compatible values
|
|
101
|
-
variant.enum = compatible_values if compatible_values.
|
|
86
|
+
variant.enum = compatible_values if !compatible_values.empty? && variant.respond_to?(:enum=)
|
|
102
87
|
end
|
|
103
88
|
elsif array_schema_with_items?(schema)
|
|
104
89
|
# For array schemas, apply enum to items (values constrain array elements)
|
|
@@ -110,7 +95,7 @@ module GrapeOAS
|
|
|
110
95
|
end
|
|
111
96
|
|
|
112
97
|
def one_of_schema?(schema)
|
|
113
|
-
schema.respond_to?(:one_of) && schema.one_of.is_a?(Array) && schema.one_of.
|
|
98
|
+
schema.respond_to?(:one_of) && schema.one_of.is_a?(Array) && !schema.one_of.empty?
|
|
114
99
|
end
|
|
115
100
|
|
|
116
101
|
def null_type_schema?(schema)
|
|
@@ -159,26 +144,14 @@ module GrapeOAS
|
|
|
159
144
|
end
|
|
160
145
|
end
|
|
161
146
|
|
|
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
147
|
def extract_defs(doc)
|
|
180
148
|
doc[:defs] || doc[:$defs]
|
|
181
149
|
end
|
|
150
|
+
|
|
151
|
+
def range_compatible_with_schema?(range, schema)
|
|
152
|
+
numeric_type = RangeUtils::NUMERIC_TYPES.include?(schema.type)
|
|
153
|
+
RangeUtils.numeric_range?(range) ? numeric_type : !numeric_type
|
|
154
|
+
end
|
|
182
155
|
end
|
|
183
156
|
end
|
|
184
157
|
end
|
data/lib/grape_oas/constants.rb
CHANGED
|
@@ -47,6 +47,10 @@ 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
|
+
|
|
50
54
|
# Default values for OpenAPI spec when not provided by user
|
|
51
55
|
module Defaults
|
|
52
56
|
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
|
|
@@ -33,9 +33,19 @@ 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
|
|
@@ -90,6 +100,7 @@ module GrapeOAS
|
|
|
90
100
|
|
|
91
101
|
result = { "allOf" => all_of_items }
|
|
92
102
|
result["description"] = @schema.description.to_s if @schema.description
|
|
103
|
+
result["x-nullable"] = true if @nullable_strategy == Constants::NullableStrategy::EXTENSION && nullable?
|
|
93
104
|
result
|
|
94
105
|
end
|
|
95
106
|
|
|
@@ -102,11 +113,13 @@ module GrapeOAS
|
|
|
102
113
|
end
|
|
103
114
|
end
|
|
104
115
|
|
|
105
|
-
def build_schema_or_ref(schema)
|
|
116
|
+
def build_schema_or_ref(schema, include_metadata: true)
|
|
106
117
|
if schema.respond_to?(:canonical_name) && schema.canonical_name
|
|
107
118
|
@ref_tracker << schema.canonical_name if @ref_tracker
|
|
108
119
|
ref_name = schema.canonical_name.gsub("::", "_")
|
|
109
120
|
ref_hash = { "$ref" => "#/definitions/#{ref_name}" }
|
|
121
|
+
return ref_hash unless include_metadata
|
|
122
|
+
|
|
110
123
|
result = {}
|
|
111
124
|
if @nullable_strategy == Constants::NullableStrategy::EXTENSION && schema.respond_to?(:nullable) && schema.nullable
|
|
112
125
|
result["x-nullable"] = true
|
|
@@ -119,7 +132,9 @@ module GrapeOAS
|
|
|
119
132
|
result
|
|
120
133
|
end
|
|
121
134
|
else
|
|
122
|
-
Schema.new(schema, @ref_tracker, nullable_strategy: @nullable_strategy).build
|
|
135
|
+
built = Schema.new(schema, @ref_tracker, nullable_strategy: @nullable_strategy).build
|
|
136
|
+
built.delete("description") unless include_metadata
|
|
137
|
+
built
|
|
123
138
|
end
|
|
124
139
|
end
|
|
125
140
|
|
|
@@ -32,7 +32,23 @@ module GrapeOAS
|
|
|
32
32
|
apply_nullable(schema_hash)
|
|
33
33
|
props = build_properties(@schema.properties)
|
|
34
34
|
schema_hash["properties"] = props if props
|
|
35
|
-
|
|
35
|
+
if @schema.items
|
|
36
|
+
schema_hash["items"] = build_schema_or_ref(@schema.items, include_metadata: false)
|
|
37
|
+
if !schema_hash["description"] && @schema.items.respond_to?(:description) && @schema.items.description
|
|
38
|
+
schema_hash["description"] = @schema.items.description.to_s
|
|
39
|
+
end
|
|
40
|
+
if @schema.items.respond_to?(:canonical_name) && @schema.items.canonical_name &&
|
|
41
|
+
@schema.items.respond_to?(:nullable) && @schema.items.nullable
|
|
42
|
+
case @nullable_strategy
|
|
43
|
+
when Constants::NullableStrategy::KEYWORD
|
|
44
|
+
schema_hash["nullable"] = true
|
|
45
|
+
when Constants::NullableStrategy::EXTENSION
|
|
46
|
+
schema_hash["x-nullable"] = true
|
|
47
|
+
when Constants::NullableStrategy::TYPE_ARRAY
|
|
48
|
+
schema_hash["type"] = (Array(schema_hash["type"]) | ["null"])
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
36
52
|
schema_hash["required"] = @schema.required if @schema.required && !@schema.required.empty?
|
|
37
53
|
schema_hash["enum"] = normalize_enum(@schema.enum, schema_hash["type"]) if @schema.enum
|
|
38
54
|
schema_hash
|
|
@@ -72,6 +88,7 @@ module GrapeOAS
|
|
|
72
88
|
|
|
73
89
|
result = { "allOf" => all_of_items }
|
|
74
90
|
result["description"] = @schema.description.to_s if @schema.description
|
|
91
|
+
apply_nullable(result)
|
|
75
92
|
result
|
|
76
93
|
end
|
|
77
94
|
|
|
@@ -84,6 +101,7 @@ module GrapeOAS
|
|
|
84
101
|
result = { "oneOf" => one_of_items }
|
|
85
102
|
result["description"] = @schema.description.to_s if @schema.description
|
|
86
103
|
result["discriminator"] = build_discriminator if @schema.discriminator
|
|
104
|
+
apply_nullable(result)
|
|
87
105
|
result
|
|
88
106
|
end
|
|
89
107
|
|
|
@@ -96,6 +114,7 @@ module GrapeOAS
|
|
|
96
114
|
result = { "anyOf" => any_of_items }
|
|
97
115
|
result["description"] = @schema.description.to_s if @schema.description
|
|
98
116
|
result["discriminator"] = build_discriminator if @schema.discriminator
|
|
117
|
+
apply_nullable(result)
|
|
99
118
|
result
|
|
100
119
|
end
|
|
101
120
|
|
|
@@ -146,11 +165,13 @@ module GrapeOAS
|
|
|
146
165
|
end
|
|
147
166
|
end
|
|
148
167
|
|
|
149
|
-
def build_schema_or_ref(schema)
|
|
168
|
+
def build_schema_or_ref(schema, include_metadata: true)
|
|
150
169
|
if schema.respond_to?(:canonical_name) && schema.canonical_name
|
|
151
170
|
@ref_tracker << schema.canonical_name if @ref_tracker
|
|
152
171
|
ref_name = schema.canonical_name.gsub("::", "_")
|
|
153
172
|
ref_hash = { "$ref" => "#/components/schemas/#{ref_name}" }
|
|
173
|
+
return ref_hash unless include_metadata
|
|
174
|
+
|
|
154
175
|
result = {}
|
|
155
176
|
result["description"] = schema.description.to_s if schema.description
|
|
156
177
|
apply_nullable_to_ref(result, schema)
|
|
@@ -161,10 +182,16 @@ module GrapeOAS
|
|
|
161
182
|
result
|
|
162
183
|
end
|
|
163
184
|
else
|
|
164
|
-
Schema.new(schema, @ref_tracker, nullable_strategy: @nullable_strategy).build
|
|
185
|
+
built = Schema.new(schema, @ref_tracker, nullable_strategy: @nullable_strategy).build
|
|
186
|
+
strip_items_metadata(built) unless include_metadata
|
|
187
|
+
built
|
|
165
188
|
end
|
|
166
189
|
end
|
|
167
190
|
|
|
191
|
+
def strip_items_metadata(hash)
|
|
192
|
+
hash.delete("description")
|
|
193
|
+
end
|
|
194
|
+
|
|
168
195
|
def apply_nullable_to_ref(result, schema)
|
|
169
196
|
return unless schema.respond_to?(:nullable) && schema.nullable
|
|
170
197
|
|
|
@@ -244,13 +271,13 @@ module GrapeOAS
|
|
|
244
271
|
when Constants::SchemaTypes::ARRAY, Constants::SchemaTypes::OBJECT, nil
|
|
245
272
|
hash.delete("enum")
|
|
246
273
|
when Constants::SchemaTypes::INTEGER
|
|
247
|
-
hash.delete("enum") unless enum_vals.all?
|
|
274
|
+
hash.delete("enum") unless enum_vals.all?(Integer)
|
|
248
275
|
when Constants::SchemaTypes::NUMBER
|
|
249
|
-
hash.delete("enum") unless enum_vals.all?
|
|
276
|
+
hash.delete("enum") unless enum_vals.all?(Numeric)
|
|
250
277
|
when Constants::SchemaTypes::BOOLEAN
|
|
251
278
|
hash.delete("enum") unless enum_vals.all? { |v| [true, false].include?(v) }
|
|
252
279
|
else # string and fallback
|
|
253
|
-
hash.delete("enum") unless enum_vals.all?
|
|
280
|
+
hash.delete("enum") unless enum_vals.all?(String)
|
|
254
281
|
end
|
|
255
282
|
end
|
|
256
283
|
|
|
@@ -13,8 +13,6 @@ module GrapeOAS
|
|
|
13
13
|
LITERAL_TAGS = %i[value val literal class left right].freeze
|
|
14
14
|
# AST node tags for regex patterns
|
|
15
15
|
PATTERN_TAGS = %i[regexp regex].freeze
|
|
16
|
-
# Maximum size for converting ranges to enum arrays
|
|
17
|
-
MAX_ENUM_RANGE_SIZE = 100
|
|
18
16
|
|
|
19
17
|
def extract_numeric(arg)
|
|
20
18
|
return arg if arg.is_a?(Numeric)
|
|
@@ -36,43 +34,16 @@ module GrapeOAS
|
|
|
36
34
|
def extract_list(arg)
|
|
37
35
|
if list_node?(arg)
|
|
38
36
|
inner = arg[1]
|
|
39
|
-
|
|
40
|
-
# Numeric ranges should use min/max constraints instead
|
|
41
|
-
return range_to_enum_array(inner) if inner.is_a?(Range)
|
|
37
|
+
return RangeUtils.expand_range_to_enum(inner) if inner.is_a?(Range)
|
|
42
38
|
|
|
43
39
|
return inner
|
|
44
40
|
end
|
|
45
41
|
return arg if arg.is_a?(Array)
|
|
46
|
-
return
|
|
42
|
+
return RangeUtils.expand_range_to_enum(arg) if arg.is_a?(Range)
|
|
47
43
|
|
|
48
44
|
nil
|
|
49
45
|
end
|
|
50
46
|
|
|
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
|
-
|
|
76
47
|
def extract_literal(arg)
|
|
77
48
|
return arg unless arg.is_a?(Array)
|
|
78
49
|
return arg[1] if arg.length == 2 && LITERAL_TAGS.include?(arg.first)
|
|
@@ -7,25 +7,16 @@ module GrapeOAS
|
|
|
7
7
|
# Delegates AST walking to AstWalker and merging to ConstraintMerger.
|
|
8
8
|
class ConstraintExtractor
|
|
9
9
|
# Value object holding all possible constraints extracted from a Dry contract.
|
|
10
|
-
ConstraintSet
|
|
11
|
-
:enum,
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
:excluded_values,
|
|
21
|
-
:unhandled_predicates,
|
|
22
|
-
:required,
|
|
23
|
-
:type_predicate,
|
|
24
|
-
:parity,
|
|
25
|
-
:format,
|
|
26
|
-
:extensions,
|
|
27
|
-
keyword_init: true,
|
|
28
|
-
)
|
|
10
|
+
class ConstraintSet
|
|
11
|
+
attr_accessor :enum, :nullable, :min_size, :max_size,
|
|
12
|
+
:minimum, :maximum, :exclusive_minimum, :exclusive_maximum,
|
|
13
|
+
:pattern, :excluded_values, :unhandled_predicates,
|
|
14
|
+
:required, :type_predicate, :parity, :format, :extensions
|
|
15
|
+
|
|
16
|
+
def initialize(**attrs)
|
|
17
|
+
attrs.each { |k, v| public_send(:"#{k}=", v) }
|
|
18
|
+
end
|
|
19
|
+
end
|
|
29
20
|
|
|
30
21
|
def self.extract(contract)
|
|
31
22
|
new(contract).extract
|
|
@@ -83,15 +83,7 @@ module GrapeOAS
|
|
|
83
83
|
return if rng.begin && !rng.begin.is_a?(Numeric)
|
|
84
84
|
return if rng.end && !rng.end.is_a?(Numeric)
|
|
85
85
|
|
|
86
|
-
|
|
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
|
|
86
|
+
RangeUtils.apply_numeric_range(constraints, rng)
|
|
95
87
|
end
|
|
96
88
|
|
|
97
89
|
def apply_excluded_from_list(args)
|
|
@@ -135,7 +127,7 @@ module GrapeOAS
|
|
|
135
127
|
|
|
136
128
|
def handle_range(args)
|
|
137
129
|
rng = ArgumentExtractor.extract_range(args.first)
|
|
138
|
-
|
|
130
|
+
RangeUtils.apply_numeric_range(constraints, rng) if rng
|
|
139
131
|
end
|
|
140
132
|
|
|
141
133
|
def handle_multiple_of(args)
|
|
@@ -5,26 +5,12 @@ module GrapeOAS
|
|
|
5
5
|
module EntityIntrospectorSupport
|
|
6
6
|
# Handles discriminator fields in entity inheritance for polymorphic schemas.
|
|
7
7
|
class DiscriminatorHandler
|
|
8
|
-
# Checks if an entity inherits from a parent that uses discriminator.
|
|
9
|
-
#
|
|
10
|
-
# @param entity_class [Class] the entity class to check
|
|
11
|
-
# @return [Boolean] true if parent has a discriminator field
|
|
12
|
-
def self.inherits_with_discriminator?(entity_class)
|
|
13
|
-
parent = find_parent_entity(entity_class)
|
|
14
|
-
parent && new(parent).discriminator?
|
|
15
|
-
end
|
|
16
|
-
|
|
17
8
|
# Finds the parent entity class if one exists.
|
|
18
9
|
#
|
|
19
10
|
# @param entity_class [Class] the entity class
|
|
20
11
|
# @return [Class, nil] the parent entity class or nil
|
|
21
12
|
def self.find_parent_entity(entity_class)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
parent = entity_class.superclass
|
|
25
|
-
return nil unless parent && parent < Grape::Entity && parent != Grape::Entity
|
|
26
|
-
|
|
27
|
-
parent
|
|
13
|
+
EntityIntrospectorSupport.find_parent_entity(entity_class)
|
|
28
14
|
end
|
|
29
15
|
|
|
30
16
|
def initialize(entity_class)
|
|
@@ -65,17 +51,8 @@ module GrapeOAS
|
|
|
65
51
|
|
|
66
52
|
private
|
|
67
53
|
|
|
68
|
-
# Gets the exposures defined on the entity class.
|
|
69
|
-
#
|
|
70
|
-
# @return [Array] list of entity exposures
|
|
71
54
|
def exposures
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
root = @entity_class.root_exposures
|
|
75
|
-
list = root.instance_variable_get(:@exposures) || []
|
|
76
|
-
Array(list)
|
|
77
|
-
rescue NoMethodError
|
|
78
|
-
[]
|
|
55
|
+
EntityIntrospectorSupport.exposures(@entity_class)
|
|
79
56
|
end
|
|
80
57
|
end
|
|
81
58
|
end
|