grape-oas 1.3.0 → 1.5.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 +59 -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 +6 -1
- data/lib/grape_oas/api_model/schema.rb +2 -2
- data/lib/grape_oas/api_model_builder.rb +3 -2
- data/lib/grape_oas/api_model_builders/concerns/content_type_resolver.rb +30 -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 +7 -8
- 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 +29 -22
- data/lib/grape_oas/api_model_builders/request_params.rb +83 -3
- data/lib/grape_oas/api_model_builders/request_params_support/nested_params_builder.rb +1 -1
- data/lib/grape_oas/api_model_builders/request_params_support/param_location_resolver.rb +7 -2
- data/lib/grape_oas/api_model_builders/request_params_support/param_schema_builder.rb +4 -0
- data/lib/grape_oas/api_model_builders/request_params_support/schema_enhancer.rb +14 -9
- data/lib/grape_oas/api_model_builders/response.rb +28 -4
- data/lib/grape_oas/constants.rb +22 -1
- 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 +74 -13
- 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 +85 -38
- 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 +8 -3
- 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 +273 -117
- 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 +7 -2
- data/lib/grape_oas/introspectors/entity_introspector_support/exposure_processor.rb +55 -20
- data/lib/grape_oas/introspectors/entity_introspector_support/inheritance_builder.rb +1 -1
- 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/introspectors/entity_introspector_support.rb +24 -0
- data/lib/grape_oas/range_utils.rb +25 -2
- data/lib/grape_oas/type_resolvers/array_resolver.rb +54 -22
- 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,6 +3,7 @@
|
|
|
3
3
|
module GrapeOAS
|
|
4
4
|
module ApiModelBuilders
|
|
5
5
|
class Request
|
|
6
|
+
include Concerns::RouteValidations
|
|
6
7
|
include Concerns::TypeResolver
|
|
7
8
|
include Concerns::OasUtilities
|
|
8
9
|
|
|
@@ -96,7 +97,9 @@ module GrapeOAS
|
|
|
96
97
|
|
|
97
98
|
# Find contract from Grape's contract storage locations.
|
|
98
99
|
# Contracts can be defined in several ways:
|
|
99
|
-
# 1. Via `contract MyContract` DSL -
|
|
100
|
+
# 1. Via `contract MyContract` DSL - stored on the endpoint's inheritable
|
|
101
|
+
# settings (`route_validations` on Grape 4.0, `route[:saved_validations]`
|
|
102
|
+
# on Grape 3.x)
|
|
100
103
|
# 2. Via `desc "...", contract: MyContract` - stores in route.options[:contract]
|
|
101
104
|
# 3. Via `desc "...", schema: MySchema` - stores in route.options[:schema]
|
|
102
105
|
# 4. Via route.settings[:contract] - used by mounted APIs or legacy configuration
|
|
@@ -116,38 +119,42 @@ module GrapeOAS
|
|
|
116
119
|
end
|
|
117
120
|
|
|
118
121
|
# Extract contract from Grape's native contract() DSL storage location.
|
|
119
|
-
# When using `contract MyContract` in Grape DSL, the contract is stored
|
|
120
|
-
#
|
|
121
|
-
# This is a point-in-time copy specific to this endpoint, ensuring each route
|
|
122
|
+
# When using `contract MyContract` in Grape DSL, the contract is stored as
|
|
123
|
+
# a point-in-time copy specific to this endpoint, ensuring each route
|
|
122
124
|
# gets only its own contract even when multiple routes define different contracts.
|
|
123
125
|
#
|
|
124
126
|
# @return [Object, nil] The contract instance or nil if not found
|
|
125
127
|
def extract_contract_from_grape_validations
|
|
126
128
|
return unless route.respond_to?(:app) && route.app.respond_to?(:inheritable_setting)
|
|
127
129
|
|
|
128
|
-
|
|
129
|
-
return unless setting.respond_to?(:route)
|
|
130
|
-
|
|
131
|
-
# Use route[:saved_validations] which contains only the validations
|
|
132
|
-
# for this specific endpoint (point-in-time copy), not the shared
|
|
133
|
-
# namespace_stackable[:validations] which contains all validators for the API class
|
|
134
|
-
validations = setting.route[:saved_validations]
|
|
130
|
+
validations = grape_route_validations(route.app.inheritable_setting)
|
|
135
131
|
return unless validations.is_a?(Array)
|
|
136
132
|
|
|
137
|
-
# Find ContractScopeValidator which holds the Dry contract/schema
|
|
138
|
-
|
|
139
|
-
|
|
133
|
+
# Find ContractScopeValidator which holds the Dry contract/schema.
|
|
134
|
+
# Grape < 3.2 stores hashes: {validator_class: ..., opts: {schema: ...}}
|
|
135
|
+
# Grape >= 3.2 stores validator instances directly (instantiated at definition time)
|
|
136
|
+
return unless defined?(Grape::Validations::Validators::ContractScopeValidator)
|
|
140
137
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
validator_class
|
|
138
|
+
validations.each do |v|
|
|
139
|
+
case v
|
|
140
|
+
when Hash
|
|
141
|
+
next unless v[:validator_class].is_a?(Class) &&
|
|
142
|
+
v[:validator_class] <= Grape::Validations::Validators::ContractScopeValidator
|
|
143
|
+
|
|
144
|
+
return v.dig(:opts, :schema)
|
|
145
|
+
when Grape::Validations::Validators::ContractScopeValidator
|
|
146
|
+
schema = contract_schema_from(v)
|
|
147
|
+
GrapeOAS.logger&.warn("ContractScopeValidator found but @schema is nil") if schema.nil?
|
|
148
|
+
return schema
|
|
149
|
+
end
|
|
145
150
|
end
|
|
146
151
|
|
|
147
|
-
|
|
152
|
+
nil
|
|
153
|
+
end
|
|
148
154
|
|
|
149
|
-
|
|
150
|
-
|
|
155
|
+
def contract_schema_from(validator)
|
|
156
|
+
schema = validator.schema if validator.respond_to?(:schema)
|
|
157
|
+
schema || validator.instance_variable_get(:@schema)
|
|
151
158
|
end
|
|
152
159
|
|
|
153
160
|
def build_contract_schema
|
|
@@ -256,7 +263,7 @@ module GrapeOAS
|
|
|
256
263
|
end
|
|
257
264
|
|
|
258
265
|
def path_param_names
|
|
259
|
-
names = route.path
|
|
266
|
+
names = RequestParams.path_param_names(route.path)
|
|
260
267
|
mapped_names = path_param_name_map ? path_param_name_map.values : []
|
|
261
268
|
(names + mapped_names).map(&:to_s).uniq
|
|
262
269
|
end
|
|
@@ -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,8 +20,8 @@ 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?("[") }
|
|
@@ -152,6 +158,80 @@ module GrapeOAS
|
|
|
152
158
|
spec.dig(:documentation, :collectionFormat) || spec.dig(:documentation, :collection_format)
|
|
153
159
|
end
|
|
154
160
|
|
|
161
|
+
# Grape 3.x stores documented params in `route.options[:params]`.
|
|
162
|
+
# Grape 4.0 moved them to `Route#params` (grape#2785) and no longer
|
|
163
|
+
# copies the hash into options. Path captures that are not Hash specs
|
|
164
|
+
# (empty-string defaults from the pattern) are dropped.
|
|
165
|
+
def declared_params
|
|
166
|
+
specs = params_from_options || params_from_route
|
|
167
|
+
return {} unless specs.is_a?(Hash)
|
|
168
|
+
|
|
169
|
+
flat = specs.select { |_name, spec| spec.is_a?(Hash) }
|
|
170
|
+
conditional = conditional_param_names
|
|
171
|
+
return flat if conditional.empty?
|
|
172
|
+
|
|
173
|
+
flat.each_with_object({}) do |(name, spec), params|
|
|
174
|
+
params[name] = conditional.include?(name.to_s) ? spec.merge(required: false) : spec
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Returns names of params that Grape will only validate conditionally
|
|
179
|
+
# (declared inside a `given` block). Their validators have a
|
|
180
|
+
# `params_scope` with `@dependent_on` set.
|
|
181
|
+
def conditional_param_names
|
|
182
|
+
return Set.new unless route.respond_to?(:app) && route.app.respond_to?(:inheritable_setting)
|
|
183
|
+
|
|
184
|
+
validations = grape_route_validations(route.app.inheritable_setting)
|
|
185
|
+
return Set.new unless validations.is_a?(Array)
|
|
186
|
+
|
|
187
|
+
conditional = Set.new
|
|
188
|
+
unconditional = Set.new
|
|
189
|
+
validations.each do |validator|
|
|
190
|
+
scope, attrs = validator_details(validator)
|
|
191
|
+
next unless scope.respond_to?(:full_name)
|
|
192
|
+
|
|
193
|
+
target = conditional_scope?(scope) ? conditional : unconditional
|
|
194
|
+
Array(attrs).each { |attr| target << scope.full_name(attr) }
|
|
195
|
+
end
|
|
196
|
+
conditional - unconditional
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Grape < 3.2 stores validators as hashes; >= 3.2 stores instances.
|
|
200
|
+
def validator_details(validator)
|
|
201
|
+
presence = Grape::Validations::Validators::PresenceValidator
|
|
202
|
+
if validator.is_a?(Hash)
|
|
203
|
+
return unless validator[:validator_class].is_a?(Class) && validator[:validator_class] <= presence
|
|
204
|
+
|
|
205
|
+
[validator[:params_scope], validator[:attributes]]
|
|
206
|
+
elsif validator.is_a?(presence)
|
|
207
|
+
[validator.instance_variable_get(:@scope), validator.attrs]
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def conditional_scope?(scope)
|
|
212
|
+
while scope
|
|
213
|
+
return true if scope.instance_variable_get(:@dependent_on)&.any?
|
|
214
|
+
|
|
215
|
+
scope = scope.respond_to?(:parent) ? scope.parent : nil
|
|
216
|
+
end
|
|
217
|
+
false
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def params_from_options
|
|
221
|
+
params = route.options[:params]
|
|
222
|
+
params if params.is_a?(Hash) && params.any?
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def params_from_route
|
|
226
|
+
return unless route.respond_to?(:params)
|
|
227
|
+
# Grape 3.x Route#params(input = nil) merges path captures; skip it.
|
|
228
|
+
# Grape 4.0 Route#params takes no arguments and is the documentation hash.
|
|
229
|
+
return unless route.method(:params).arity.zero?
|
|
230
|
+
|
|
231
|
+
params = route.params
|
|
232
|
+
params if params.is_a?(Hash) && params.any?
|
|
233
|
+
end
|
|
234
|
+
|
|
155
235
|
def location_resolver
|
|
156
236
|
RequestParamsSupport::ParamLocationResolver
|
|
157
237
|
end
|
|
@@ -147,7 +147,7 @@ module GrapeOAS
|
|
|
147
147
|
schema.additional_properties = doc[:additional_properties] if doc.key?(:additional_properties)
|
|
148
148
|
schema.unevaluated_properties = doc[:unevaluated_properties] if doc.key?(:unevaluated_properties)
|
|
149
149
|
schema.format = doc[:format] if doc[:format]
|
|
150
|
-
schema.examples = doc[:example] if doc
|
|
150
|
+
schema.examples = doc[:example] if doc.key?(:example)
|
|
151
151
|
nullable = SchemaEnhancer.extract_nullable(doc)
|
|
152
152
|
schema.nullable = (schema.nullable || nullable) if schema.respond_to?(:nullable=)
|
|
153
153
|
end
|
|
@@ -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.
|
|
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
|
|
84
|
+
explicit_location = (param_type || in_location)&.to_s&.downcase
|
|
85
|
+
return explicit_location if explicit_location
|
|
86
|
+
|
|
87
|
+
# Default: body for write methods (POST/PUT/PATCH), query for read methods (GET/DELETE/HEAD)
|
|
88
|
+
http_method = route.request_method.to_s.downcase
|
|
89
|
+
Constants::HttpMethods::BODYLESS_HTTP_METHODS.include?(http_method) ? "query" : "body"
|
|
85
90
|
end
|
|
86
91
|
end
|
|
87
92
|
end
|
|
@@ -33,6 +33,10 @@ module GrapeOAS
|
|
|
33
33
|
|
|
34
34
|
return build_entity_array_schema(spec, raw_type, doc_type) if entity_array_type?(type_source, doc_type, spec)
|
|
35
35
|
return build_doc_entity_array_schema(doc_type) if doc[:is_array] && grape_entity?(doc_type)
|
|
36
|
+
|
|
37
|
+
# is_array: true on a typed array like "[String]" is redundant and would
|
|
38
|
+
# double-wrap it as Array<Array<...>> via build_primitive_array_schema.
|
|
39
|
+
return GrapeOAS.type_resolvers.build_schema(raw_type) if doc[:is_array] && TypeResolvers::ArrayResolver.handles?(raw_type)
|
|
36
40
|
return build_primitive_array_schema(doc_type, raw_type) if doc[:is_array]
|
|
37
41
|
return build_entity_schema(doc_type) if grape_entity?(doc_type)
|
|
38
42
|
return build_entity_schema(raw_type) if grape_entity?(raw_type)
|
|
@@ -21,17 +21,12 @@ module GrapeOAS
|
|
|
21
21
|
apply_format_and_example(schema, doc)
|
|
22
22
|
SchemaConstraints.apply(schema, doc)
|
|
23
23
|
apply_values(schema, spec)
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
# Extracts nullable flag from a documentation hash.
|
|
27
|
-
#
|
|
28
|
-
# @param doc [Hash] the documentation hash
|
|
29
|
-
# @return [Boolean] true if nullable
|
|
30
|
-
def self.extract_nullable(doc)
|
|
31
|
-
doc[:nullable] || (doc[:x].is_a?(Hash) && doc[:x][:nullable]) || false
|
|
24
|
+
apply_default(schema, spec, doc)
|
|
32
25
|
end
|
|
33
26
|
|
|
34
27
|
class << self
|
|
28
|
+
include GrapeOAS::ApiModelBuilders::Concerns::OasUtilities
|
|
29
|
+
|
|
35
30
|
private
|
|
36
31
|
|
|
37
32
|
def apply_additional_properties(schema, doc)
|
|
@@ -45,9 +40,19 @@ module GrapeOAS
|
|
|
45
40
|
schema.defs = defs if defs.is_a?(Hash) && schema.respond_to?(:defs=)
|
|
46
41
|
end
|
|
47
42
|
|
|
43
|
+
def apply_default(schema, spec, doc)
|
|
44
|
+
return unless schema.respond_to?(:default=)
|
|
45
|
+
|
|
46
|
+
if spec.key?(:default)
|
|
47
|
+
schema.default = spec[:default]
|
|
48
|
+
elsif doc.key?(:default)
|
|
49
|
+
schema.default = doc[:default]
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
48
53
|
def apply_format_and_example(schema, doc)
|
|
49
54
|
schema.format = doc[:format] if doc[:format] && schema.respond_to?(:format=)
|
|
50
|
-
schema.examples = doc[:example] if doc
|
|
55
|
+
schema.examples = doc[:example] if doc.key?(:example) && schema.respond_to?(:examples=)
|
|
51
56
|
end
|
|
52
57
|
|
|
53
58
|
def apply_values(schema, spec)
|
|
@@ -65,6 +65,9 @@ module GrapeOAS
|
|
|
65
65
|
# Else if any spec has `one_of:`, build a oneOf response from one_of entries and
|
|
66
66
|
# any regular specs in the group (this branch only runs when no `as:` entries exist)
|
|
67
67
|
def build_response_from_group(group_specs)
|
|
68
|
+
first_spec = group_specs.first
|
|
69
|
+
return build_response_without_content(first_spec) if bodyless_status?(first_spec[:code])
|
|
70
|
+
|
|
68
71
|
has_one_of = group_specs.any? { |s| s[:one_of] && !s[:one_of].empty? }
|
|
69
72
|
has_as = group_specs.any? { |s| !s[:as].nil? }
|
|
70
73
|
|
|
@@ -169,14 +172,16 @@ module GrapeOAS
|
|
|
169
172
|
|
|
170
173
|
# Merges examples from multiple specs
|
|
171
174
|
def merge_examples(specs)
|
|
172
|
-
examples = specs.
|
|
175
|
+
examples = specs.filter_map { |s| s[:examples] }
|
|
173
176
|
return nil if examples.empty?
|
|
174
177
|
|
|
175
178
|
examples.reduce({}, :merge)
|
|
176
179
|
end
|
|
177
180
|
|
|
178
181
|
def build_response_from_spec(spec)
|
|
182
|
+
code_str = spec[:code].to_s
|
|
179
183
|
entity_schema = build_schema(spec[:entity])
|
|
184
|
+
entity_schema = array_schema(entity_schema) if spec[:is_array]
|
|
180
185
|
schema = wrap_with_root(entity_schema, spec[:entity], is_array: spec[:is_array])
|
|
181
186
|
media_types = Array(response_content_types).map do |mime|
|
|
182
187
|
build_media_type(
|
|
@@ -189,7 +194,7 @@ module GrapeOAS
|
|
|
189
194
|
description = message.is_a?(String) ? message : message&.to_s
|
|
190
195
|
|
|
191
196
|
GrapeOAS::ApiModel::Response.new(
|
|
192
|
-
http_status:
|
|
197
|
+
http_status: code_str,
|
|
193
198
|
description: description || "Success",
|
|
194
199
|
media_types: media_types,
|
|
195
200
|
headers: normalize_headers(spec[:headers]) || headers_from_route,
|
|
@@ -198,6 +203,25 @@ module GrapeOAS
|
|
|
198
203
|
)
|
|
199
204
|
end
|
|
200
205
|
|
|
206
|
+
def build_response_without_content(spec)
|
|
207
|
+
message = spec[:message]
|
|
208
|
+
description = message.is_a?(String) ? message : message&.to_s
|
|
209
|
+
|
|
210
|
+
GrapeOAS::ApiModel::Response.new(
|
|
211
|
+
http_status: spec[:code].to_s,
|
|
212
|
+
description: description || "Success",
|
|
213
|
+
media_types: [],
|
|
214
|
+
headers: normalize_headers(spec[:headers]) || headers_from_route,
|
|
215
|
+
extensions: spec[:extensions] || extensions_from_route,
|
|
216
|
+
)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# RFC 9110 §6.4.1 and §15.3.6: these responses never carry a message body.
|
|
220
|
+
def bodyless_status?(code)
|
|
221
|
+
status = code.to_s.to_i
|
|
222
|
+
status.between?(100, 199) || status == 204 || status == 205 || status == 304
|
|
223
|
+
end
|
|
224
|
+
|
|
201
225
|
def array_schema(schema)
|
|
202
226
|
GrapeOAS::ApiModel::Schema.new(
|
|
203
227
|
type: Constants::SchemaTypes::ARRAY,
|
|
@@ -238,7 +262,7 @@ module GrapeOAS
|
|
|
238
262
|
# Build schema for response body
|
|
239
263
|
# Delegates to EntityIntrospector when entity is present
|
|
240
264
|
def build_schema(entity_class)
|
|
241
|
-
return GrapeOAS::ApiModel::Schema.new
|
|
265
|
+
return GrapeOAS::ApiModel::Schema.new unless entity_class
|
|
242
266
|
|
|
243
267
|
GrapeOAS.introspectors.build_schema(entity_class, stack: [], registry: {})
|
|
244
268
|
end
|
|
@@ -291,7 +315,7 @@ module GrapeOAS
|
|
|
291
315
|
end
|
|
292
316
|
|
|
293
317
|
def response_content_types
|
|
294
|
-
resolve_content_types
|
|
318
|
+
explicit_media_types(:produces) || resolve_content_types
|
|
295
319
|
end
|
|
296
320
|
end
|
|
297
321
|
end
|
data/lib/grape_oas/constants.rb
CHANGED
|
@@ -12,6 +12,7 @@ module GrapeOAS
|
|
|
12
12
|
OBJECT = "object"
|
|
13
13
|
ARRAY = "array"
|
|
14
14
|
FILE = "file"
|
|
15
|
+
NULL = "null"
|
|
15
16
|
|
|
16
17
|
ALL = [STRING, INTEGER, NUMBER, BOOLEAN, OBJECT, ARRAY, FILE].freeze
|
|
17
18
|
end
|
|
@@ -45,12 +46,32 @@ module GrapeOAS
|
|
|
45
46
|
TYPE_ARRAY = :type_array
|
|
46
47
|
# OAS 2.0: emits `"x-nullable": true` extension
|
|
47
48
|
EXTENSION = :extension
|
|
49
|
+
|
|
50
|
+
# Version-specific defaults used by exporters
|
|
51
|
+
OAS2_DEFAULT = EXTENSION
|
|
52
|
+
OAS3_DEFAULT = KEYWORD
|
|
53
|
+
OAS31_DEFAULT = TYPE_ARRAY
|
|
48
54
|
end
|
|
49
55
|
|
|
50
56
|
# Maximum number of elements to expand from a non-numeric Range into an enum array.
|
|
51
57
|
# Prevents OOM on wide string ranges (e.g. "a".."zzzzzz").
|
|
52
58
|
MAX_ENUM_RANGE_SIZE = 100
|
|
53
59
|
|
|
60
|
+
# Regex patterns for Grape's stringified type notations.
|
|
61
|
+
# Grape's ParamsDocumentation TypeCache stores `coerce_type.to_s`:
|
|
62
|
+
# - `type: [SomeClass]` / `type: Array[SomeClass]` → "[SomeClass]" (Array#to_s)
|
|
63
|
+
# - `types: [String, Integer]` → "[String, Integer]" (Array#to_s of the types list)
|
|
64
|
+
# - Grape 3.3+ `type: Array[Integer, String]` → "Array[Integer, String]"
|
|
65
|
+
# (VariantCollectionCoercer#to_s, grape#2758). Distinct from the `types:`
|
|
66
|
+
# form so a collection of variant members is not confused with a scalar
|
|
67
|
+
# that accepts multiple types. `Set[...]` is the same for Set containers.
|
|
68
|
+
module TypePatterns
|
|
69
|
+
CONST_NAME = /(?:::)?[A-Z]\w*(?:::[A-Z]\w*)*/
|
|
70
|
+
TYPED_ARRAY = /\A(?:(?<container>Array|Set))?\[(?<inner>#{CONST_NAME})\]\z/
|
|
71
|
+
MULTI_TYPE = /\A\[(#{CONST_NAME}(?:,\s*#{CONST_NAME})+)\]\z/
|
|
72
|
+
VARIANT_COLLECTION = /\A(?<container>Array|Set)\[(?<inner>#{CONST_NAME}(?:,\s*#{CONST_NAME})+)\]\z/
|
|
73
|
+
end
|
|
74
|
+
|
|
54
75
|
# Default values for OpenAPI spec when not provided by user
|
|
55
76
|
module Defaults
|
|
56
77
|
LICENSE_NAME = "Proprietary"
|
|
@@ -86,7 +107,7 @@ module GrapeOAS
|
|
|
86
107
|
"float" => { type: SchemaTypes::NUMBER, format: "float" },
|
|
87
108
|
"bigdecimal" => { type: SchemaTypes::NUMBER, format: "double" },
|
|
88
109
|
"string" => { type: SchemaTypes::STRING },
|
|
89
|
-
"integer" => { type: SchemaTypes::INTEGER
|
|
110
|
+
"integer" => { type: SchemaTypes::INTEGER },
|
|
90
111
|
"number" => { type: SchemaTypes::NUMBER, format: "double" },
|
|
91
112
|
"boolean" => { type: SchemaTypes::BOOLEAN },
|
|
92
113
|
"grape::api::boolean" => { type: SchemaTypes::BOOLEAN },
|
|
@@ -3,9 +3,7 @@
|
|
|
3
3
|
module GrapeOAS
|
|
4
4
|
module DocumentationExtension
|
|
5
5
|
# Primary entry for grape-oas documentation
|
|
6
|
-
def add_oas_documentation(**options)
|
|
7
|
-
return if options.delete(:hide_documentation_path)
|
|
8
|
-
|
|
6
|
+
def add_oas_documentation(hide_documentation_path: true, **options)
|
|
9
7
|
# Prefer grape-oas namespaced options to avoid clashing with grape-swagger
|
|
10
8
|
default_mount_path = options.delete(:oas_mount_path) ||
|
|
11
9
|
options.delete(:mount_path) ||
|
|
@@ -25,13 +23,13 @@ module GrapeOAS
|
|
|
25
23
|
api = self
|
|
26
24
|
|
|
27
25
|
mount_paths.each do |key, path|
|
|
28
|
-
add_documentation_routes(path, key, default_format, cache_control, etag_value, options, api)
|
|
26
|
+
add_documentation_routes(path, key, default_format, cache_control, etag_value, options, api, hidden: hide_documentation_path)
|
|
29
27
|
end
|
|
30
28
|
end
|
|
31
29
|
|
|
32
|
-
def add_documentation_routes(path, key, default_format, cache_control, etag_value, options, api)
|
|
30
|
+
def add_documentation_routes(path, key, default_format, cache_control, etag_value, options, api, hidden: false)
|
|
33
31
|
# Main route without namespace filter
|
|
34
|
-
add_route(path) do
|
|
32
|
+
add_route(path, hidden: hidden) do
|
|
35
33
|
GrapeOAS::DocumentationExtension.generate_documentation(
|
|
36
34
|
self, api, key, default_format, cache_control, etag_value, options, nil,
|
|
37
35
|
)
|
|
@@ -39,7 +37,7 @@ module GrapeOAS
|
|
|
39
37
|
|
|
40
38
|
# Route with namespace filter: /swagger_doc/:namespace
|
|
41
39
|
# Supports nested namespaces via *namespace (catches slashes)
|
|
42
|
-
add_route("#{path}/*namespace") do
|
|
40
|
+
add_route("#{path}/*namespace", hidden: hidden) do
|
|
43
41
|
namespace_filter = params[:namespace]&.sub(/\.json$/, "")
|
|
44
42
|
GrapeOAS::DocumentationExtension.generate_documentation(
|
|
45
43
|
self, api, key, default_format, cache_control, etag_value, options, namespace_filter,
|
|
@@ -89,10 +87,10 @@ module GrapeOAS
|
|
|
89
87
|
private
|
|
90
88
|
|
|
91
89
|
# Minimal route mounting helper
|
|
92
|
-
def add_route(path, &block)
|
|
90
|
+
def add_route(path, hidden: false, &block)
|
|
93
91
|
api_class = self
|
|
94
92
|
namespace do
|
|
95
|
-
get(path) { instance_exec(api_class, &block) }
|
|
93
|
+
get(path, hidden: hidden) { instance_exec(api_class, &block) }
|
|
96
94
|
end
|
|
97
95
|
end
|
|
98
96
|
|
|
@@ -47,10 +47,21 @@ module GrapeOAS
|
|
|
47
47
|
end
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
-
def index_schema(schema, index)
|
|
51
|
-
return unless schema
|
|
50
|
+
def index_schema(schema, index, seen = Set.new)
|
|
51
|
+
return unless schema
|
|
52
|
+
|
|
53
|
+
schema_id = schema.object_id
|
|
54
|
+
return if seen.include?(schema_id)
|
|
52
55
|
|
|
53
|
-
|
|
56
|
+
seen << schema_id
|
|
57
|
+
index[schema.canonical_name] ||= schema if schema.respond_to?(:canonical_name) && schema.canonical_name
|
|
58
|
+
index_schema(schema.items, index, seen) if schema.respond_to?(:items) && schema.items
|
|
59
|
+
schema.properties.each_value { |child| index_schema(child, index, seen) } if schema.respond_to?(:properties) && schema.properties
|
|
60
|
+
%i[all_of one_of any_of].each do |composition|
|
|
61
|
+
next unless schema.respond_to?(composition)
|
|
62
|
+
|
|
63
|
+
Array(schema.public_send(composition)).each { |child| index_schema(child, index, seen) }
|
|
64
|
+
end
|
|
54
65
|
end
|
|
55
66
|
|
|
56
67
|
def collect_refs(schema, pending, seen = Set.new)
|
|
@@ -15,8 +15,10 @@ module GrapeOAS
|
|
|
15
15
|
{
|
|
16
16
|
"consumes" => consumes,
|
|
17
17
|
"produces" => produces,
|
|
18
|
-
"parameters" => Parameter.new(@op, @ref_tracker, nullable_strategy: strategy
|
|
19
|
-
|
|
18
|
+
"parameters" => Parameter.new(@op, @ref_tracker, nullable_strategy: strategy,
|
|
19
|
+
composition_extensions: @options[:composition_extensions],).build,
|
|
20
|
+
"responses" => Response.new(@op.responses, @ref_tracker, nullable_strategy: strategy,
|
|
21
|
+
composition_extensions: @options[:composition_extensions],).build
|
|
20
22
|
}
|
|
21
23
|
end
|
|
22
24
|
|
|
@@ -5,7 +5,7 @@ module GrapeOAS
|
|
|
5
5
|
module OAS2
|
|
6
6
|
class Parameter
|
|
7
7
|
PRIMITIVE_MAPPINGS = {
|
|
8
|
-
Constants::SchemaTypes::INTEGER => { type: Constants::SchemaTypes::INTEGER
|
|
8
|
+
Constants::SchemaTypes::INTEGER => { type: Constants::SchemaTypes::INTEGER },
|
|
9
9
|
"long" => { type: Constants::SchemaTypes::INTEGER, format: "int64" },
|
|
10
10
|
"float" => { type: Constants::SchemaTypes::NUMBER, format: "float" },
|
|
11
11
|
"double" => { type: Constants::SchemaTypes::NUMBER, format: "double" },
|
|
@@ -18,15 +18,24 @@ module GrapeOAS
|
|
|
18
18
|
"uuid" => { type: Constants::SchemaTypes::STRING, format: "uuid" }
|
|
19
19
|
}.freeze
|
|
20
20
|
|
|
21
|
-
def initialize(operation, ref_tracker = nil, nullable_strategy: nil)
|
|
21
|
+
def initialize(operation, ref_tracker = nil, nullable_strategy: nil, composition_extensions: false)
|
|
22
22
|
@op = operation
|
|
23
23
|
@ref_tracker = ref_tracker
|
|
24
24
|
@nullable_strategy = nullable_strategy
|
|
25
|
+
@composition_extensions = composition_extensions
|
|
25
26
|
end
|
|
26
27
|
|
|
28
|
+
FORM_MEDIA_TYPES = %w[application/x-www-form-urlencoded multipart/form-data].freeze
|
|
29
|
+
|
|
27
30
|
def build
|
|
28
31
|
params = Array(@op.parameters).map { |param| build_parameter(param) }
|
|
29
|
-
|
|
32
|
+
if @op.request_body
|
|
33
|
+
if form_only_request?
|
|
34
|
+
params.concat(build_form_parameters(@op.request_body))
|
|
35
|
+
else
|
|
36
|
+
params << build_body_parameter(@op.request_body)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
30
39
|
params
|
|
31
40
|
end
|
|
32
41
|
|
|
@@ -76,22 +85,27 @@ module GrapeOAS
|
|
|
76
85
|
result["maxLength"] = schema.max_length if schema.respond_to?(:max_length) && !schema.max_length.nil?
|
|
77
86
|
result["minItems"] = schema.min_items if schema.respond_to?(:min_items) && !schema.min_items.nil?
|
|
78
87
|
result["maxItems"] = schema.max_items if schema.respond_to?(:max_items) && !schema.max_items.nil?
|
|
88
|
+
result["uniqueItems"] = true if schema.respond_to?(:unique_items) && schema.unique_items
|
|
79
89
|
result["pattern"] = schema.pattern if schema.respond_to?(:pattern) && schema.pattern
|
|
80
90
|
result["enum"] = normalize_enum(schema.enum, result["type"]) if schema.respond_to?(:enum) && schema.enum
|
|
91
|
+
result["default"] = schema.default if schema.respond_to?(:default) && !schema.default.nil?
|
|
81
92
|
end
|
|
82
93
|
|
|
83
94
|
def normalize_enum(enum_vals, type)
|
|
84
95
|
return nil unless enum_vals.is_a?(Array)
|
|
85
96
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
97
|
+
result = enum_vals.each_with_object([]) do |v, acc|
|
|
98
|
+
next if v.nil?
|
|
99
|
+
|
|
100
|
+
coerced_v = case type
|
|
101
|
+
when Constants::SchemaTypes::INTEGER then v.to_i if v.respond_to?(:to_i)
|
|
102
|
+
when Constants::SchemaTypes::NUMBER then v.to_f if v.respond_to?(:to_f)
|
|
103
|
+
else v
|
|
104
|
+
end
|
|
105
|
+
acc << coerced_v unless coerced_v.nil?
|
|
106
|
+
end
|
|
93
107
|
|
|
94
|
-
result
|
|
108
|
+
result.uniq!
|
|
95
109
|
return nil if result.empty?
|
|
96
110
|
|
|
97
111
|
result
|
|
@@ -99,12 +113,58 @@ module GrapeOAS
|
|
|
99
113
|
|
|
100
114
|
def apply_collection_format(result, param, type)
|
|
101
115
|
return unless type == Constants::SchemaTypes::ARRAY
|
|
116
|
+
|
|
117
|
+
result["items"] = build_schema_or_ref(param.schema.items) if param.schema.items
|
|
102
118
|
return unless param.collection_format
|
|
103
119
|
|
|
104
120
|
valid_formats = %w[csv ssv tsv pipes multi brackets]
|
|
105
121
|
result["collectionFormat"] = param.collection_format if valid_formats.include?(param.collection_format)
|
|
106
122
|
end
|
|
107
123
|
|
|
124
|
+
def form_only_request?
|
|
125
|
+
consumes = Array(@op.consumes)
|
|
126
|
+
consumes.any? && consumes.all? { |mime| FORM_MEDIA_TYPES.include?(mime) }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def build_form_parameters(request_body)
|
|
130
|
+
schema = Array(request_body.media_types).first&.schema
|
|
131
|
+
return [] unless schema
|
|
132
|
+
|
|
133
|
+
unless [nil, "object"].include?(schema.type) && !composition?(schema)
|
|
134
|
+
raise ArgumentError, "OAS2 form bodies must have object properties; use OAS3 for this request schema"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
required = Array(schema.required).map(&:to_s)
|
|
138
|
+
schema.properties.map do |name, property_schema|
|
|
139
|
+
unless form_property?(property_schema)
|
|
140
|
+
raise ArgumentError, "OAS2 cannot represent form field #{name.inspect}; use OAS3 for complex form fields"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
build_parameter(
|
|
144
|
+
ApiModel::Parameter.new(
|
|
145
|
+
name: name.to_s,
|
|
146
|
+
location: "formData",
|
|
147
|
+
required: required.include?(name.to_s),
|
|
148
|
+
description: property_schema.description,
|
|
149
|
+
schema: property_schema,
|
|
150
|
+
),
|
|
151
|
+
)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def form_property?(schema, array_item: false)
|
|
156
|
+
return false unless schema && !composition?(schema)
|
|
157
|
+
return false if array_item && schema.canonical_name
|
|
158
|
+
return form_property?(schema.items, array_item: true) if schema.type == "array"
|
|
159
|
+
|
|
160
|
+
types = array_item ? %w[string integer number boolean] : %w[string integer number boolean file]
|
|
161
|
+
types.include?(schema.type)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def composition?(schema)
|
|
165
|
+
schema.all_of&.any? || schema.one_of&.any? || schema.any_of&.any?
|
|
166
|
+
end
|
|
167
|
+
|
|
108
168
|
def build_body_parameter(request_body)
|
|
109
169
|
schema = build_body_schema(request_body)
|
|
110
170
|
name = derive_body_name(request_body)
|
|
@@ -138,10 +198,11 @@ module GrapeOAS
|
|
|
138
198
|
def build_schema_or_ref(schema)
|
|
139
199
|
if schema.respond_to?(:canonical_name) && schema.canonical_name
|
|
140
200
|
@ref_tracker << schema.canonical_name if @ref_tracker
|
|
141
|
-
ref_name = schema.canonical_name
|
|
201
|
+
ref_name = GrapeOAS.schema_ref_name.call(schema.canonical_name)
|
|
142
202
|
{ "$ref" => "#/definitions/#{ref_name}" }
|
|
143
203
|
else
|
|
144
|
-
Schema.new(schema, @ref_tracker, nullable_strategy: @nullable_strategy
|
|
204
|
+
Schema.new(schema, @ref_tracker, nullable_strategy: @nullable_strategy,
|
|
205
|
+
composition_extensions: @composition_extensions,).build
|
|
145
206
|
end
|
|
146
207
|
end
|
|
147
208
|
end
|
|
@@ -12,6 +12,7 @@ module GrapeOAS
|
|
|
12
12
|
def build_operation(operation)
|
|
13
13
|
Operation.new(operation, @ref_tracker,
|
|
14
14
|
nullable_strategy: @options[:nullable_strategy],
|
|
15
|
+
composition_extensions: @options[:composition_extensions],
|
|
15
16
|
suppress_default_error_response: @options[:suppress_default_error_response],).build
|
|
16
17
|
end
|
|
17
18
|
end
|