grape-oas 1.0.2 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +92 -75
- data/README.md +25 -1
- data/lib/grape_oas/api_model/parameter.rb +5 -2
- data/lib/grape_oas/api_model_builders/concerns/type_resolver.rb +5 -2
- data/lib/grape_oas/api_model_builders/request.rb +125 -9
- data/lib/grape_oas/api_model_builders/request_params.rb +6 -5
- data/lib/grape_oas/api_model_builders/request_params_support/param_schema_builder.rb +59 -8
- data/lib/grape_oas/api_model_builders/request_params_support/schema_enhancer.rb +77 -2
- data/lib/grape_oas/api_model_builders/response.rb +63 -6
- data/lib/grape_oas/api_model_builders/response_parsers/http_codes_parser.rb +114 -10
- data/lib/grape_oas/constants.rb +32 -19
- data/lib/grape_oas/exporter/oas2_schema.rb +0 -3
- data/lib/grape_oas/exporter/oas3/parameter.rb +2 -0
- data/lib/grape_oas/exporter/oas3_schema.rb +0 -3
- data/lib/grape_oas/introspectors/dry_introspector.rb +19 -10
- data/lib/grape_oas/introspectors/dry_introspector_support/argument_extractor.rb +57 -6
- data/lib/grape_oas/introspectors/dry_introspector_support/inheritance_handler.rb +17 -5
- data/lib/grape_oas/introspectors/dry_introspector_support/predicate_handler.rb +38 -12
- data/lib/grape_oas/introspectors/dry_introspector_support/rule_index.rb +196 -0
- data/lib/grape_oas/introspectors/dry_introspector_support/type_schema_builder.rb +89 -17
- data/lib/grape_oas/introspectors/dry_introspector_support/type_unwrapper.rb +19 -0
- data/lib/grape_oas/introspectors/entity_introspector.rb +0 -8
- data/lib/grape_oas/introspectors/entity_introspector_support/exposure_processor.rb +6 -3
- data/lib/grape_oas/version.rb +1 -1
- data/lib/grape_oas.rb +1 -1
- metadata +3 -2
|
@@ -14,7 +14,8 @@ module GrapeOAS
|
|
|
14
14
|
nullable = extract_nullable(spec, doc)
|
|
15
15
|
|
|
16
16
|
schema.description ||= doc[:desc]
|
|
17
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
28
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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
|
{
|
data/lib/grape_oas/constants.rb
CHANGED
|
@@ -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
|
-
#
|
|
63
|
-
#
|
|
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
|
|
@@ -1,10 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "base"
|
|
4
|
-
require_relative "dry_introspector_support/contract_resolver"
|
|
5
|
-
require_relative "dry_introspector_support/inheritance_handler"
|
|
6
|
-
require_relative "dry_introspector_support/type_schema_builder"
|
|
7
|
-
|
|
8
3
|
module GrapeOAS
|
|
9
4
|
module Introspectors
|
|
10
5
|
# Introspector for Dry::Validation contracts and Dry::Schema.
|
|
@@ -92,16 +87,30 @@ module GrapeOAS
|
|
|
92
87
|
end
|
|
93
88
|
|
|
94
89
|
def build_flat_schema
|
|
95
|
-
|
|
90
|
+
contract_schema = contract_resolver.contract_schema
|
|
91
|
+
|
|
92
|
+
constraints_by_path, required_by_object_path =
|
|
93
|
+
DryIntrospectorSupport::RuleIndex.build(contract_schema)
|
|
94
|
+
|
|
95
|
+
type_schema_builder.configure_path_aware_mode(constraints_by_path, required_by_object_path)
|
|
96
|
+
|
|
96
97
|
schema = ApiModel::Schema.new(
|
|
97
98
|
type: Constants::SchemaTypes::OBJECT,
|
|
98
99
|
canonical_name: contract_resolver.canonical_name,
|
|
99
100
|
)
|
|
100
101
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
102
|
+
root_required = required_by_object_path.fetch("", [])
|
|
103
|
+
|
|
104
|
+
contract_schema.types.each do |name, dry_type|
|
|
105
|
+
name_s = name.to_s
|
|
106
|
+
prop_schema = nil
|
|
107
|
+
|
|
108
|
+
type_schema_builder.with_path(name_s) do
|
|
109
|
+
prop_schema = type_schema_builder.build_schema_for_type(dry_type,
|
|
110
|
+
type_schema_builder.constraints_for_current_path,)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
schema.add_property(name, prop_schema, required: root_required.include?(name_s))
|
|
105
114
|
end
|
|
106
115
|
|
|
107
116
|
# Use canonical_name as registry key for schema objects (they don't have unique classes),
|
|
@@ -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
|
|
@@ -17,20 +26,56 @@ module GrapeOAS
|
|
|
17
26
|
def extract_range(arg)
|
|
18
27
|
return arg if arg.is_a?(Range)
|
|
19
28
|
return arg[1] if arg.is_a?(Array) && arg.first == :range
|
|
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)
|
|
20
32
|
|
|
21
33
|
nil
|
|
22
34
|
end
|
|
23
35
|
|
|
24
36
|
def extract_list(arg)
|
|
25
|
-
|
|
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
|
|
26
45
|
return arg if arg.is_a?(Array)
|
|
46
|
+
return range_to_enum_array(arg) if arg.is_a?(Range)
|
|
27
47
|
|
|
28
48
|
nil
|
|
29
49
|
end
|
|
30
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
|
+
|
|
31
76
|
def extract_literal(arg)
|
|
32
77
|
return arg unless arg.is_a?(Array)
|
|
33
|
-
return arg[1] if arg.length == 2 &&
|
|
78
|
+
return arg[1] if arg.length == 2 && LITERAL_TAGS.include?(arg.first)
|
|
34
79
|
return extract_literal(arg.first) if arg.first.is_a?(Array)
|
|
35
80
|
|
|
36
81
|
arg
|
|
@@ -38,13 +83,19 @@ module GrapeOAS
|
|
|
38
83
|
|
|
39
84
|
def extract_pattern(arg)
|
|
40
85
|
return arg.source if arg.is_a?(Regexp)
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
45
91
|
|
|
46
92
|
nil
|
|
47
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
|
|
48
99
|
end
|
|
49
100
|
end
|
|
50
101
|
end
|
|
@@ -68,15 +68,27 @@ module GrapeOAS
|
|
|
68
68
|
def build_child_only_schema(parent_contract, type_schema_builder)
|
|
69
69
|
child_schema = ApiModel::Schema.new(type: Constants::SchemaTypes::OBJECT)
|
|
70
70
|
parent_keys = parent_contract_types(parent_contract)
|
|
71
|
-
|
|
71
|
+
contract_schema = @contract_resolver.contract_schema
|
|
72
72
|
|
|
73
|
-
|
|
73
|
+
constraints_by_path, required_by_object_path =
|
|
74
|
+
RuleIndex.build(contract_schema)
|
|
75
|
+
|
|
76
|
+
type_schema_builder.configure_path_aware_mode(constraints_by_path, required_by_object_path)
|
|
77
|
+
root_required = required_by_object_path.fetch("", [])
|
|
78
|
+
|
|
79
|
+
contract_schema.types.each do |name, dry_type|
|
|
74
80
|
# Skip inherited properties
|
|
75
81
|
next if parent_keys.include?(name.to_s)
|
|
76
82
|
|
|
77
|
-
|
|
78
|
-
prop_schema =
|
|
79
|
-
|
|
83
|
+
name_s = name.to_s
|
|
84
|
+
prop_schema = nil
|
|
85
|
+
|
|
86
|
+
type_schema_builder.with_path(name_s) do
|
|
87
|
+
prop_schema = type_schema_builder.build_schema_for_type(dry_type,
|
|
88
|
+
type_schema_builder.constraints_for_current_path,)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
child_schema.add_property(name, prop_schema, required: root_required.include?(name_s))
|
|
80
92
|
end
|
|
81
93
|
|
|
82
94
|
child_schema
|