grape-oas 1.4.0 → 1.5.1
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 +63 -0
- data/README.md +27 -10
- data/UPGRADING.md +104 -0
- data/grape-oas.gemspec +2 -1
- data/lib/grape_oas/api_model/api.rb +2 -1
- data/lib/grape_oas/api_model/schema.rb +1 -1
- data/lib/grape_oas/api_model_builder.rb +3 -2
- data/lib/grape_oas/api_model_builders/concerns/content_type_resolver.rb +9 -1
- data/lib/grape_oas/api_model_builders/concerns/oas_utilities.rb +13 -0
- data/lib/grape_oas/api_model_builders/concerns/route_validations.rb +26 -0
- data/lib/grape_oas/api_model_builders/concerns/type_resolver.rb +4 -2
- data/lib/grape_oas/api_model_builders/operation.rb +3 -4
- data/lib/grape_oas/api_model_builders/path.rb +33 -6
- data/lib/grape_oas/api_model_builders/request.rb +22 -29
- data/lib/grape_oas/api_model_builders/request_params.rb +121 -44
- data/lib/grape_oas/api_model_builders/request_params_support/nested_params_builder.rb +1 -2
- data/lib/grape_oas/api_model_builders/request_params_support/param_location_resolver.rb +33 -27
- data/lib/grape_oas/api_model_builders/request_params_support/param_schema_builder.rb +8 -27
- data/lib/grape_oas/api_model_builders/request_params_support/schema_enhancer.rb +4 -10
- data/lib/grape_oas/api_model_builders/response.rb +33 -5
- data/lib/grape_oas/api_model_builders/response_parsers/base.rb +11 -0
- data/lib/grape_oas/api_model_builders/response_parsers/documentation_responses_parser.rb +5 -2
- data/lib/grape_oas/api_model_builders/response_parsers/http_codes_parser.rb +82 -17
- data/lib/grape_oas/constants.rb +30 -4
- data/lib/grape_oas/documentation_extension.rb +7 -9
- data/lib/grape_oas/exporter/concerns/schema_indexer.rb +14 -3
- data/lib/grape_oas/exporter/oas2/operation.rb +4 -2
- data/lib/grape_oas/exporter/oas2/parameter.rb +125 -22
- data/lib/grape_oas/exporter/oas2/paths.rb +1 -0
- data/lib/grape_oas/exporter/oas2/response.rb +6 -4
- data/lib/grape_oas/exporter/oas2/schema.rb +56 -28
- data/lib/grape_oas/exporter/oas2_schema.rb +7 -5
- data/lib/grape_oas/exporter/oas3/operation.rb +5 -3
- data/lib/grape_oas/exporter/oas3/parameter.rb +4 -2
- data/lib/grape_oas/exporter/oas3/paths.rb +1 -0
- data/lib/grape_oas/exporter/oas3/request_body.rb +5 -8
- data/lib/grape_oas/exporter/oas3/response.rb +6 -9
- data/lib/grape_oas/exporter/oas3/schema.rb +232 -84
- data/lib/grape_oas/exporter/oas31/schema.rb +12 -2
- data/lib/grape_oas/exporter/oas31_schema.rb +1 -1
- data/lib/grape_oas/exporter/oas3_schema.rb +3 -2
- data/lib/grape_oas/introspectors/entity_introspector.rb +2 -1
- data/lib/grape_oas/introspectors/entity_introspector_support/exposure_processor.rb +73 -23
- data/lib/grape_oas/introspectors/entity_introspector_support/property_extractor.rb +2 -8
- data/lib/grape_oas/introspectors/entity_introspector_support/type_schema_resolver.rb +4 -20
- data/lib/grape_oas/range_utils.rb +25 -2
- data/lib/grape_oas/type_resolvers/array_resolver.rb +46 -19
- data/lib/grape_oas/type_resolvers/base.rb +2 -2
- data/lib/grape_oas/type_resolvers/default_resolver.rb +23 -0
- data/lib/grape_oas/type_resolvers/dry_type_resolver.rb +1 -1
- data/lib/grape_oas/type_resolvers/primitive_resolver.rb +29 -46
- data/lib/grape_oas/type_resolvers/registry.rb +31 -15
- data/lib/grape_oas/version.rb +1 -1
- data/lib/grape_oas.rb +37 -7
- metadata +5 -4
- data/CONTRIBUTING.md +0 -87
- data/RELEASING.md +0 -109
|
@@ -3,7 +3,13 @@
|
|
|
3
3
|
module GrapeOAS
|
|
4
4
|
module ApiModelBuilders
|
|
5
5
|
class RequestParams
|
|
6
|
-
|
|
6
|
+
include Concerns::RouteValidations
|
|
7
|
+
|
|
8
|
+
ROUTE_PARAM_REGEX = /(?<=[:*])\w+/
|
|
9
|
+
|
|
10
|
+
def self.path_param_names(path)
|
|
11
|
+
path.sub(/(\(\.[^)]+\))+$/, "").scan(ROUTE_PARAM_REGEX)
|
|
12
|
+
end
|
|
7
13
|
|
|
8
14
|
attr_reader :api, :route, :path_param_name_map
|
|
9
15
|
|
|
@@ -14,29 +20,43 @@ module GrapeOAS
|
|
|
14
20
|
end
|
|
15
21
|
|
|
16
22
|
def build
|
|
17
|
-
route_params = route.path
|
|
18
|
-
all_params =
|
|
23
|
+
route_params = self.class.path_param_names(route.path)
|
|
24
|
+
all_params = declared_params
|
|
19
25
|
|
|
20
26
|
# Check if we have nested params (bracket notation)
|
|
21
27
|
has_nested = all_params.keys.any? { |k| k.include?("[") }
|
|
22
28
|
|
|
23
29
|
if has_nested
|
|
24
|
-
build_with_nested_params(all_params, route_params)
|
|
30
|
+
body_schema, parameters = build_with_nested_params(all_params, route_params)
|
|
25
31
|
else
|
|
26
|
-
build_flat_params(all_params, route_params)
|
|
32
|
+
body_schema, parameters = build_flat_params(all_params, route_params)
|
|
27
33
|
end
|
|
34
|
+
|
|
35
|
+
[body_schema, parameters]
|
|
28
36
|
end
|
|
29
37
|
|
|
30
38
|
private
|
|
31
39
|
|
|
32
40
|
# Builds params when nested structures are detected.
|
|
33
41
|
def build_with_nested_params(all_params, route_params)
|
|
34
|
-
|
|
42
|
+
body_params = nested_body_params(all_params, route_params)
|
|
43
|
+
body_schema = nested_params_builder.build(body_params, path_params: route_params)
|
|
35
44
|
non_body_params = extract_non_body_params(all_params, route_params)
|
|
36
45
|
|
|
37
46
|
[body_schema, non_body_params]
|
|
38
47
|
end
|
|
39
48
|
|
|
49
|
+
def nested_body_params(all_params, route_params)
|
|
50
|
+
body_roots = all_params.filter_map do |name, spec|
|
|
51
|
+
next if name.include?("[")
|
|
52
|
+
|
|
53
|
+
location = location_resolver.resolve(name: name, spec: spec, route_params: route_params, route: route)
|
|
54
|
+
name if location == "body"
|
|
55
|
+
end.to_set
|
|
56
|
+
|
|
57
|
+
all_params.select { |name, _spec| body_roots.include?(name.split("[", 2).first) }
|
|
58
|
+
end
|
|
59
|
+
|
|
40
60
|
# Builds params for flat (non-nested) structures.
|
|
41
61
|
def build_flat_params(all_params, route_params)
|
|
42
62
|
body_schema = ApiModel::Schema.new(type: Constants::SchemaTypes::OBJECT)
|
|
@@ -66,33 +86,38 @@ module GrapeOAS
|
|
|
66
86
|
end
|
|
67
87
|
|
|
68
88
|
# Extracts non-body params (path, query, header) from flat params.
|
|
69
|
-
#
|
|
70
|
-
#
|
|
71
|
-
#
|
|
89
|
+
# Nested params (bracket notation, e.g. "tax_id[type]") are included as
|
|
90
|
+
# flat non-body parameters, taking their parent's resolved location,
|
|
91
|
+
# regardless of HTTP method — a nested Hash explicitly documented
|
|
92
|
+
# `in: "header"` on a write route (POST/PUT/PATCH) still belongs in
|
|
93
|
+
# the header, not the body.
|
|
72
94
|
def extract_non_body_params(all_params, route_params)
|
|
73
95
|
params = []
|
|
74
|
-
http_method = route.request_method.to_s.downcase
|
|
75
|
-
flatten_nested = should_flatten_nested_to_query?(http_method, all_params)
|
|
76
96
|
|
|
77
97
|
all_params.each do |name, spec|
|
|
78
98
|
# Skip hidden params
|
|
79
99
|
next if location_resolver.hidden_parameter?(spec)
|
|
80
100
|
|
|
81
|
-
|
|
82
|
-
|
|
101
|
+
if name.include?("[")
|
|
102
|
+
root = name.split("[", 2).first
|
|
103
|
+
root_spec = all_params[root] || {}
|
|
104
|
+
next if location_resolver.hidden_parameter?(root_spec)
|
|
83
105
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
106
|
+
root_location = location_resolver.resolve(name: root, spec: root_spec, route_params: route_params, route: route)
|
|
107
|
+
next if root_location == "body"
|
|
108
|
+
# A Hash root can itself be a real route capture (e.g. ":filter"),
|
|
109
|
+
# but that doesn't make its bracket children path segments too —
|
|
110
|
+
# only the root itself matches the URL template.
|
|
111
|
+
next if root_location == "path"
|
|
88
112
|
|
|
89
|
-
params << build_parameter(name,
|
|
113
|
+
params << build_parameter(name, root_location, spec[:required] || false, schema_builder.build(spec), spec)
|
|
90
114
|
next
|
|
91
115
|
end
|
|
92
116
|
|
|
93
|
-
# Skip Hash type params
|
|
94
|
-
# or via body schema
|
|
95
|
-
|
|
117
|
+
# Skip Hash type params with nested children of their own (they're
|
|
118
|
+
# handled via the nested bracket branch above or via body schema).
|
|
119
|
+
# A childless Hash falls through to the generic path below.
|
|
120
|
+
next if location_resolver.hash_param?(spec) && all_params.keys.any? { |k| k.start_with?("#{name}[") }
|
|
96
121
|
|
|
97
122
|
location = location_resolver.resolve(
|
|
98
123
|
name: name,
|
|
@@ -109,28 +134,6 @@ module GrapeOAS
|
|
|
109
134
|
params
|
|
110
135
|
end
|
|
111
136
|
|
|
112
|
-
# Determines whether nested params should be flattened to query params.
|
|
113
|
-
# Returns true for GET/HEAD/DELETE unless body is explicitly requested via:
|
|
114
|
-
# - route-level `request_body: true` option
|
|
115
|
-
# - any parameter with `documentation: { in: 'body' }` or `documentation: { param_type: 'body' }`
|
|
116
|
-
def should_flatten_nested_to_query?(http_method, all_params)
|
|
117
|
-
return false unless Constants::HttpMethods::BODYLESS_HTTP_METHODS.include?(http_method)
|
|
118
|
-
|
|
119
|
-
# If request_body is explicitly enabled at route level, use body schema
|
|
120
|
-
return false if route.options.dig(:documentation, :request_body) || route.options[:request_body]
|
|
121
|
-
|
|
122
|
-
# If any parameter is explicitly marked as body, use body schema
|
|
123
|
-
has_explicit_body_param = all_params.any? do |name, spec|
|
|
124
|
-
next false if name.include?("[") # Skip bracket params, check parent Hash params only
|
|
125
|
-
|
|
126
|
-
param_type = spec.dig(:documentation, :param_type)&.to_s&.downcase
|
|
127
|
-
in_location = spec.dig(:documentation, :in)&.to_s&.downcase
|
|
128
|
-
param_type == "body" || in_location == "body"
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
!has_explicit_body_param
|
|
132
|
-
end
|
|
133
|
-
|
|
134
137
|
def build_parameter(name, location, required, schema, spec)
|
|
135
138
|
doc = spec[:documentation] || {}
|
|
136
139
|
style = doc.fetch(:style) { doc["style"] }
|
|
@@ -139,7 +142,7 @@ module GrapeOAS
|
|
|
139
142
|
ApiModel::Parameter.new(
|
|
140
143
|
location: location,
|
|
141
144
|
name: name,
|
|
142
|
-
required: required,
|
|
145
|
+
required: location == "path" || required,
|
|
143
146
|
schema: schema,
|
|
144
147
|
description: spec[:documentation]&.dig(:desc) || spec[:desc],
|
|
145
148
|
collection_format: extract_collection_format(spec),
|
|
@@ -152,6 +155,80 @@ module GrapeOAS
|
|
|
152
155
|
spec.dig(:documentation, :collectionFormat) || spec.dig(:documentation, :collection_format)
|
|
153
156
|
end
|
|
154
157
|
|
|
158
|
+
# Grape 3.x stores documented params in `route.options[:params]`.
|
|
159
|
+
# Grape 4.0 moved them to `Route#params` (grape#2785) and no longer
|
|
160
|
+
# copies the hash into options. Path captures that are not Hash specs
|
|
161
|
+
# (empty-string defaults from the pattern) are dropped.
|
|
162
|
+
def declared_params
|
|
163
|
+
specs = params_from_options || params_from_route
|
|
164
|
+
return {} unless specs.is_a?(Hash)
|
|
165
|
+
|
|
166
|
+
flat = specs.select { |_name, spec| spec.is_a?(Hash) }
|
|
167
|
+
conditional = conditional_param_names
|
|
168
|
+
return flat if conditional.empty?
|
|
169
|
+
|
|
170
|
+
flat.each_with_object({}) do |(name, spec), params|
|
|
171
|
+
params[name] = conditional.include?(name.to_s) ? spec.merge(required: false) : spec
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Returns names of params that Grape will only validate conditionally
|
|
176
|
+
# (declared inside a `given` block). Their validators have a
|
|
177
|
+
# `params_scope` with `@dependent_on` set.
|
|
178
|
+
def conditional_param_names
|
|
179
|
+
return Set.new unless route.respond_to?(:app) && route.app.respond_to?(:inheritable_setting)
|
|
180
|
+
|
|
181
|
+
validations = grape_route_validations(route.app.inheritable_setting)
|
|
182
|
+
return Set.new unless validations.is_a?(Array)
|
|
183
|
+
|
|
184
|
+
conditional = Set.new
|
|
185
|
+
unconditional = Set.new
|
|
186
|
+
validations.each do |validator|
|
|
187
|
+
scope, attrs = validator_details(validator)
|
|
188
|
+
next unless scope.respond_to?(:full_name)
|
|
189
|
+
|
|
190
|
+
target = conditional_scope?(scope) ? conditional : unconditional
|
|
191
|
+
Array(attrs).each { |attr| target << scope.full_name(attr) }
|
|
192
|
+
end
|
|
193
|
+
conditional - unconditional
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Grape < 3.2 stores validators as hashes; >= 3.2 stores instances.
|
|
197
|
+
def validator_details(validator)
|
|
198
|
+
presence = Grape::Validations::Validators::PresenceValidator
|
|
199
|
+
if validator.is_a?(Hash)
|
|
200
|
+
return unless validator[:validator_class].is_a?(Class) && validator[:validator_class] <= presence
|
|
201
|
+
|
|
202
|
+
[validator[:params_scope], validator[:attributes]]
|
|
203
|
+
elsif validator.is_a?(presence)
|
|
204
|
+
[validator.instance_variable_get(:@scope), validator.attrs]
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def conditional_scope?(scope)
|
|
209
|
+
while scope
|
|
210
|
+
return true if scope.instance_variable_get(:@dependent_on)&.any?
|
|
211
|
+
|
|
212
|
+
scope = scope.respond_to?(:parent) ? scope.parent : nil
|
|
213
|
+
end
|
|
214
|
+
false
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def params_from_options
|
|
218
|
+
params = route.options[:params]
|
|
219
|
+
params if params.is_a?(Hash) && params.any?
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def params_from_route
|
|
223
|
+
return unless route.respond_to?(:params)
|
|
224
|
+
# Grape 3.x Route#params(input = nil) merges path captures; skip it.
|
|
225
|
+
# Grape 4.0 Route#params takes no arguments and is the documentation hash.
|
|
226
|
+
return unless route.method(:params).arity.zero?
|
|
227
|
+
|
|
228
|
+
params = route.params
|
|
229
|
+
params if params.is_a?(Hash) && params.any?
|
|
230
|
+
end
|
|
231
|
+
|
|
155
232
|
def location_resolver
|
|
156
233
|
RequestParamsSupport::ParamLocationResolver
|
|
157
234
|
end
|
|
@@ -30,7 +30,6 @@ module GrapeOAS
|
|
|
30
30
|
|
|
31
31
|
top_level.each do |name, spec|
|
|
32
32
|
next if path_params.include?(name)
|
|
33
|
-
next if ParamLocationResolver.explicit_non_body_param?(spec)
|
|
34
33
|
next if ParamLocationResolver.hidden_parameter?(spec)
|
|
35
34
|
|
|
36
35
|
child_schema = @schema_builder.build(spec)
|
|
@@ -147,7 +146,7 @@ module GrapeOAS
|
|
|
147
146
|
schema.additional_properties = doc[:additional_properties] if doc.key?(:additional_properties)
|
|
148
147
|
schema.unevaluated_properties = doc[:unevaluated_properties] if doc.key?(:unevaluated_properties)
|
|
149
148
|
schema.format = doc[:format] if doc[:format]
|
|
150
|
-
schema.examples = doc[:example] if doc
|
|
149
|
+
schema.examples = doc[:example] if doc.key?(:example)
|
|
151
150
|
nullable = SchemaEnhancer.extract_nullable(doc)
|
|
152
151
|
schema.nullable = (schema.nullable || nullable) if schema.respond_to?(:nullable=)
|
|
153
152
|
end
|
|
@@ -5,6 +5,12 @@ module GrapeOAS
|
|
|
5
5
|
module RequestParamsSupport
|
|
6
6
|
# Resolves the location (path, query, body, header) for a parameter.
|
|
7
7
|
class ParamLocationResolver
|
|
8
|
+
# Locations `param_type:`/`in:` may explicitly name. An unrecognized
|
|
9
|
+
# value (a typo, or a grape-swagger location this library doesn't
|
|
10
|
+
# resolve to, like `formData`) is treated as unset rather than
|
|
11
|
+
# emitted verbatim, since nothing downstream validates `Parameter#location`.
|
|
12
|
+
VALID_EXPLICIT_LOCATIONS = %w[body query header path cookie].freeze
|
|
13
|
+
|
|
8
14
|
# Determines the location for a parameter.
|
|
9
15
|
#
|
|
10
16
|
# @param name [String] the parameter name
|
|
@@ -18,29 +24,14 @@ module GrapeOAS
|
|
|
18
24
|
extract_from_spec(spec, route)
|
|
19
25
|
end
|
|
20
26
|
|
|
21
|
-
# Checks if a parameter
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
# @param spec [Hash] the parameter specification
|
|
25
|
-
# @return [Boolean] true if it's a body parameter
|
|
26
|
-
def self.body_param?(spec)
|
|
27
|
-
param_type = spec.dig(:documentation, :param_type)&.to_s&.downcase
|
|
28
|
-
in_location = spec.dig(:documentation, :in)&.to_s&.downcase
|
|
29
|
-
|
|
30
|
-
param_type == "body" || in_location == "body" || [Hash, "Hash"].include?(spec[:type])
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
# Checks if a parameter is explicitly marked as NOT a body param.
|
|
34
|
-
# Supports both `param_type` and `in` for grape-swagger compatibility.
|
|
27
|
+
# Checks if a parameter is a Hash type. Hash parents are represented
|
|
28
|
+
# by their children (nested params) or the request body, never
|
|
29
|
+
# themselves as a parameter.
|
|
35
30
|
#
|
|
36
31
|
# @param spec [Hash] the parameter specification
|
|
37
|
-
# @return [Boolean] true if
|
|
38
|
-
def self.
|
|
39
|
-
|
|
40
|
-
in_location = spec.dig(:documentation, :in)&.to_s&.downcase
|
|
41
|
-
location = param_type || in_location
|
|
42
|
-
|
|
43
|
-
location && %w[query header path].include?(location)
|
|
32
|
+
# @return [Boolean] true if the parameter type is Hash
|
|
33
|
+
def self.hash_param?(spec)
|
|
34
|
+
[Hash, "Hash"].include?(spec[:type])
|
|
44
35
|
end
|
|
45
36
|
|
|
46
37
|
# Checks if a parameter should be hidden from documentation.
|
|
@@ -56,6 +47,10 @@ module GrapeOAS
|
|
|
56
47
|
hidden
|
|
57
48
|
end
|
|
58
49
|
|
|
50
|
+
def self.route_body_opted_in?(route)
|
|
51
|
+
!!(route.options[:body_name] || route.options.dig(:documentation, :request_body) || route.options[:request_body])
|
|
52
|
+
end
|
|
53
|
+
|
|
59
54
|
class << self
|
|
60
55
|
private
|
|
61
56
|
|
|
@@ -70,24 +65,35 @@ module GrapeOAS
|
|
|
70
65
|
# Note: If both `param_type` and `in` are specified, `param_type` takes precedence.
|
|
71
66
|
# For example, `{ param_type: 'query', in: 'body' }` will be treated as query.
|
|
72
67
|
#
|
|
68
|
+
# `resolve` already returns "path" for an actual route capture before
|
|
69
|
+
# calling this method, so an explicit `in: "path"` / `param_type: "path"`
|
|
70
|
+
# reaching here is always a mismatch (the name can never appear in the
|
|
71
|
+
# URL template) and is ignored, same as an unrecognized location.
|
|
72
|
+
#
|
|
73
73
|
# @param spec [Hash] the parameter specification
|
|
74
74
|
# @param route [Object] the Grape route object
|
|
75
75
|
# @return [String] the parameter location
|
|
76
76
|
def extract_from_spec(spec, route)
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
return "body" if route.options[:body_name] && !param_type && !in_location
|
|
77
|
+
location = explicit_location(spec)
|
|
78
|
+
location = nil if location == "path"
|
|
79
|
+
return "body" if route_body_opted_in?(route) && location.nil?
|
|
81
80
|
|
|
82
81
|
# Support both param_type and in for grape-swagger compatibility
|
|
83
82
|
# param_type takes precedence over in when both are specified
|
|
84
|
-
|
|
85
|
-
return explicit_location if explicit_location
|
|
83
|
+
return location if location
|
|
86
84
|
|
|
87
85
|
# Default: body for write methods (POST/PUT/PATCH), query for read methods (GET/DELETE/HEAD)
|
|
88
86
|
http_method = route.request_method.to_s.downcase
|
|
89
87
|
Constants::HttpMethods::BODYLESS_HTTP_METHODS.include?(http_method) ? "query" : "body"
|
|
90
88
|
end
|
|
89
|
+
|
|
90
|
+
def explicit_location(spec)
|
|
91
|
+
doc = spec[:documentation] || {}
|
|
92
|
+
param_type = doc[:param_type] || doc["param_type"]
|
|
93
|
+
in_location = doc[:in] || doc["in"]
|
|
94
|
+
location = (param_type || in_location)&.to_s&.downcase
|
|
95
|
+
location if VALID_EXPLICIT_LOCATIONS.include?(location)
|
|
96
|
+
end
|
|
91
97
|
end
|
|
92
98
|
end
|
|
93
99
|
end
|
|
@@ -36,7 +36,7 @@ module GrapeOAS
|
|
|
36
36
|
|
|
37
37
|
# is_array: true on a typed array like "[String]" is redundant and would
|
|
38
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] &&
|
|
39
|
+
return GrapeOAS.type_resolvers.build_schema(raw_type) if doc[:is_array] && TypeResolvers::ArrayResolver.handles?(raw_type)
|
|
40
40
|
return build_primitive_array_schema(doc_type, raw_type) if doc[:is_array]
|
|
41
41
|
return build_entity_schema(doc_type) if grape_entity?(doc_type)
|
|
42
42
|
return build_entity_schema(raw_type) if grape_entity?(raw_type)
|
|
@@ -119,19 +119,17 @@ module GrapeOAS
|
|
|
119
119
|
type_names = extract_multi_types(type)
|
|
120
120
|
|
|
121
121
|
# OPTIMIZE: [Type, Nil] becomes nullable Type instead of oneOf
|
|
122
|
-
if
|
|
123
|
-
non_nil_type = type_names.find { |t| !nil_type_name?(t) }
|
|
122
|
+
if (nullable_type = Constants.nullable_type(type_names))
|
|
124
123
|
return ApiModel::Schema.new(
|
|
125
|
-
type: resolve_schema_type(
|
|
126
|
-
format: Constants.format_for_type(
|
|
124
|
+
type: resolve_schema_type(nullable_type),
|
|
125
|
+
format: Constants.format_for_type(nullable_type),
|
|
127
126
|
nullable: true,
|
|
128
127
|
)
|
|
129
128
|
end
|
|
130
129
|
|
|
131
130
|
# General case: build oneOf schema
|
|
132
131
|
# Filter out nil types - OpenAPI 3.0 uses nullable property instead
|
|
133
|
-
|
|
134
|
-
non_nil_types = type_names.reject { |t| nil_type_name?(t) }
|
|
132
|
+
nil_types, non_nil_types = type_names.partition { |type_name| Constants.nil_type?(type_name) }
|
|
135
133
|
|
|
136
134
|
schemas = non_nil_types.map do |type_name|
|
|
137
135
|
ApiModel::Schema.new(
|
|
@@ -139,26 +137,9 @@ module GrapeOAS
|
|
|
139
137
|
format: Constants.format_for_type(type_name),
|
|
140
138
|
)
|
|
141
139
|
end
|
|
142
|
-
ApiModel::Schema.new(one_of: schemas
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
# Checks if type_names is a pair of [SomeType, NilType]
|
|
146
|
-
def nullable_type_pair?(type_names)
|
|
147
|
-
return false unless type_names.size == 2
|
|
148
|
-
|
|
149
|
-
type_names.one? { |t| nil_type_name?(t) }
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
# Checks if the type name represents a nil/null type
|
|
153
|
-
def nil_type_name?(type_name)
|
|
154
|
-
normalized = type_name.to_s
|
|
155
|
-
# Match common nil type patterns:
|
|
156
|
-
# - "NilClass" (Ruby's nil type)
|
|
157
|
-
# - "Nil" (shorthand)
|
|
158
|
-
# - "Foo::Nil", "Types::Nil" (namespaced nil types)
|
|
159
|
-
normalized == "NilClass" ||
|
|
160
|
-
normalized == "Nil" ||
|
|
161
|
-
normalized.end_with?("::Nil")
|
|
140
|
+
schema = ApiModel::Schema.new(one_of: schemas)
|
|
141
|
+
schema.nullable = true if nil_types.any?
|
|
142
|
+
schema
|
|
162
143
|
end
|
|
163
144
|
|
|
164
145
|
def build_primitive_schema(raw_type, doc)
|
|
@@ -13,7 +13,7 @@ module GrapeOAS
|
|
|
13
13
|
def self.apply(schema, spec, doc)
|
|
14
14
|
nullable = extract_nullable(doc)
|
|
15
15
|
|
|
16
|
-
schema.description ||= doc[:desc]
|
|
16
|
+
schema.description ||= doc[:desc] || spec[:desc]
|
|
17
17
|
# Preserve existing nullable: true (e.g., from [Type, Nil] optimization)
|
|
18
18
|
schema.nullable = (schema.nullable || nullable) if schema.respond_to?(:nullable=)
|
|
19
19
|
|
|
@@ -24,15 +24,9 @@ module GrapeOAS
|
|
|
24
24
|
apply_default(schema, spec, doc)
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
-
# Extracts nullable flag from a documentation hash.
|
|
28
|
-
#
|
|
29
|
-
# @param doc [Hash] the documentation hash
|
|
30
|
-
# @return [Boolean] true if nullable
|
|
31
|
-
def self.extract_nullable(doc)
|
|
32
|
-
doc[:nullable] || (doc[:x].is_a?(Hash) && doc[:x][:nullable]) || false
|
|
33
|
-
end
|
|
34
|
-
|
|
35
27
|
class << self
|
|
28
|
+
include GrapeOAS::ApiModelBuilders::Concerns::OasUtilities
|
|
29
|
+
|
|
36
30
|
private
|
|
37
31
|
|
|
38
32
|
def apply_additional_properties(schema, doc)
|
|
@@ -58,7 +52,7 @@ module GrapeOAS
|
|
|
58
52
|
|
|
59
53
|
def apply_format_and_example(schema, doc)
|
|
60
54
|
schema.format = doc[:format] if doc[:format] && schema.respond_to?(:format=)
|
|
61
|
-
schema.examples = doc[:example] if doc
|
|
55
|
+
schema.examples = doc[:example] if doc.key?(:example) && schema.respond_to?(:examples=)
|
|
62
56
|
end
|
|
63
57
|
|
|
64
58
|
def apply_values(schema, spec)
|
|
@@ -48,7 +48,11 @@ module GrapeOAS
|
|
|
48
48
|
# Parsers are tried in order of priority
|
|
49
49
|
def response_specs
|
|
50
50
|
parser = parsers.find { |p| p.applicable?(route) }
|
|
51
|
-
|
|
51
|
+
return [] unless parser
|
|
52
|
+
|
|
53
|
+
parser.parse(route).each do |spec|
|
|
54
|
+
spec[:code] = ResponseParsers::Base.normalize_status_code(spec[:code])
|
|
55
|
+
end
|
|
52
56
|
end
|
|
53
57
|
|
|
54
58
|
def parsers
|
|
@@ -65,6 +69,9 @@ module GrapeOAS
|
|
|
65
69
|
# Else if any spec has `one_of:`, build a oneOf response from one_of entries and
|
|
66
70
|
# any regular specs in the group (this branch only runs when no `as:` entries exist)
|
|
67
71
|
def build_response_from_group(group_specs)
|
|
72
|
+
first_spec = group_specs.first
|
|
73
|
+
return build_response_without_content(first_spec) if bodyless_status?(first_spec[:code])
|
|
74
|
+
|
|
68
75
|
has_one_of = group_specs.any? { |s| s[:one_of] && !s[:one_of].empty? }
|
|
69
76
|
has_as = group_specs.any? { |s| !s[:as].nil? }
|
|
70
77
|
|
|
@@ -169,14 +176,16 @@ module GrapeOAS
|
|
|
169
176
|
|
|
170
177
|
# Merges examples from multiple specs
|
|
171
178
|
def merge_examples(specs)
|
|
172
|
-
examples = specs.
|
|
179
|
+
examples = specs.filter_map { |s| s[:examples] }
|
|
173
180
|
return nil if examples.empty?
|
|
174
181
|
|
|
175
182
|
examples.reduce({}, :merge)
|
|
176
183
|
end
|
|
177
184
|
|
|
178
185
|
def build_response_from_spec(spec)
|
|
186
|
+
code_str = spec[:code].to_s
|
|
179
187
|
entity_schema = build_schema(spec[:entity])
|
|
188
|
+
entity_schema = array_schema(entity_schema) if spec[:is_array]
|
|
180
189
|
schema = wrap_with_root(entity_schema, spec[:entity], is_array: spec[:is_array])
|
|
181
190
|
media_types = Array(response_content_types).map do |mime|
|
|
182
191
|
build_media_type(
|
|
@@ -189,7 +198,7 @@ module GrapeOAS
|
|
|
189
198
|
description = message.is_a?(String) ? message : message&.to_s
|
|
190
199
|
|
|
191
200
|
GrapeOAS::ApiModel::Response.new(
|
|
192
|
-
http_status:
|
|
201
|
+
http_status: code_str,
|
|
193
202
|
description: description || "Success",
|
|
194
203
|
media_types: media_types,
|
|
195
204
|
headers: normalize_headers(spec[:headers]) || headers_from_route,
|
|
@@ -198,6 +207,25 @@ module GrapeOAS
|
|
|
198
207
|
)
|
|
199
208
|
end
|
|
200
209
|
|
|
210
|
+
def build_response_without_content(spec)
|
|
211
|
+
message = spec[:message]
|
|
212
|
+
description = message.is_a?(String) ? message : message&.to_s
|
|
213
|
+
|
|
214
|
+
GrapeOAS::ApiModel::Response.new(
|
|
215
|
+
http_status: spec[:code].to_s,
|
|
216
|
+
description: description || "Success",
|
|
217
|
+
media_types: [],
|
|
218
|
+
headers: normalize_headers(spec[:headers]) || headers_from_route,
|
|
219
|
+
extensions: spec[:extensions] || extensions_from_route,
|
|
220
|
+
)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# RFC 9110 §6.4.1 and §15.3.6: these responses never carry a message body.
|
|
224
|
+
def bodyless_status?(code)
|
|
225
|
+
status = code.to_s.to_i
|
|
226
|
+
status.between?(100, 199) || status == 204 || status == 205 || status == 304
|
|
227
|
+
end
|
|
228
|
+
|
|
201
229
|
def array_schema(schema)
|
|
202
230
|
GrapeOAS::ApiModel::Schema.new(
|
|
203
231
|
type: Constants::SchemaTypes::ARRAY,
|
|
@@ -238,7 +266,7 @@ module GrapeOAS
|
|
|
238
266
|
# Build schema for response body
|
|
239
267
|
# Delegates to EntityIntrospector when entity is present
|
|
240
268
|
def build_schema(entity_class)
|
|
241
|
-
return GrapeOAS::ApiModel::Schema.new
|
|
269
|
+
return GrapeOAS::ApiModel::Schema.new unless entity_class
|
|
242
270
|
|
|
243
271
|
GrapeOAS.introspectors.build_schema(entity_class, stack: [], registry: {})
|
|
244
272
|
end
|
|
@@ -291,7 +319,7 @@ module GrapeOAS
|
|
|
291
319
|
end
|
|
292
320
|
|
|
293
321
|
def response_content_types
|
|
294
|
-
resolve_content_types
|
|
322
|
+
explicit_media_types(:produces) || resolve_content_types
|
|
295
323
|
end
|
|
296
324
|
end
|
|
297
325
|
end
|
|
@@ -29,6 +29,17 @@ module GrapeOAS
|
|
|
29
29
|
|
|
30
30
|
private
|
|
31
31
|
|
|
32
|
+
def normalize_status_code(status)
|
|
33
|
+
return status unless status.is_a?(Symbol)
|
|
34
|
+
|
|
35
|
+
status_name = status.to_s
|
|
36
|
+
return status_name if status_name == "default" || status_name.match?(/\A[1-5](?:\d{2}|XX)\z/)
|
|
37
|
+
|
|
38
|
+
Rack::Utils.status_code(status)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
module_function :normalize_status_code
|
|
42
|
+
|
|
32
43
|
# Extract status code from hash, supporting multiple key names
|
|
33
44
|
def extract_status_code(hash, default_code)
|
|
34
45
|
hash[:code] || hash[:status] || hash[:http_status] || default_code
|
|
@@ -17,10 +17,10 @@ module GrapeOAS
|
|
|
17
17
|
doc_resps = route.options.dig(:documentation, :responses)
|
|
18
18
|
return [] unless doc_resps.is_a?(Hash)
|
|
19
19
|
|
|
20
|
-
doc_resps.map do |code, doc|
|
|
20
|
+
specs = doc_resps.map do |code, doc|
|
|
21
21
|
doc = normalize_hash_keys(doc)
|
|
22
22
|
{
|
|
23
|
-
code: code,
|
|
23
|
+
code: normalize_status_code(code),
|
|
24
24
|
message: extract_description(doc),
|
|
25
25
|
headers: doc[:headers],
|
|
26
26
|
entity: extract_entity(doc, route.options[:entity]),
|
|
@@ -28,6 +28,9 @@ module GrapeOAS
|
|
|
28
28
|
examples: doc[:examples]
|
|
29
29
|
}
|
|
30
30
|
end
|
|
31
|
+
return specs if specs.any? { |spec| spec[:code].to_s == HttpCodesParser::DEFAULT_RESPONSE_CODE }
|
|
32
|
+
|
|
33
|
+
specs + HttpCodesParser.new.default_response_specs(route)
|
|
31
34
|
end
|
|
32
35
|
end
|
|
33
36
|
end
|