gitlab-grape-openapi 0.3.0 → 0.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/README.md +84 -12
- data/lib/gitlab/grape_openapi/concerns/constraint_applier.rb +83 -0
- data/lib/gitlab/grape_openapi/concerns/nullability.rb +53 -0
- data/lib/gitlab/grape_openapi/converters/cross_field_validation_resolver.rb +152 -0
- data/lib/gitlab/grape_openapi/converters/entity_converter.rb +4 -3
- data/lib/gitlab/grape_openapi/converters/media_type_resolver.rb +54 -0
- data/lib/gitlab/grape_openapi/converters/operation_converter.rb +21 -9
- data/lib/gitlab/grape_openapi/converters/parameter_converter.rb +31 -73
- data/lib/gitlab/grape_openapi/converters/path_converter.rb +40 -18
- data/lib/gitlab/grape_openapi/converters/request_body_converter.rb +39 -0
- data/lib/gitlab/grape_openapi/converters/response_converter.rb +73 -14
- data/lib/gitlab/grape_openapi/converters/type_resolver.rb +17 -1
- data/lib/gitlab/grape_openapi/grape_compat.rb +59 -7
- data/lib/gitlab/grape_openapi/models/request_body/parameter_schema.rb +9 -25
- data/lib/gitlab/grape_openapi/models/request_body/parameters.rb +16 -4
- data/lib/gitlab/grape_openapi/normalized_path.rb +68 -0
- data/lib/gitlab/grape_openapi/request_body_registry.rb +1 -1
- data/lib/gitlab/grape_openapi/version.rb +1 -1
- data/lib/gitlab-grape-openapi.rb +5 -0
- metadata +7 -2
|
@@ -5,10 +5,11 @@ module Gitlab
|
|
|
5
5
|
module Converters
|
|
6
6
|
class ParameterConverter
|
|
7
7
|
include CoercerResolver
|
|
8
|
-
include Concerns::
|
|
8
|
+
include Concerns::ConstraintApplier
|
|
9
9
|
include Concerns::LimitResolver
|
|
10
10
|
include Concerns::FailFastAnnotatable
|
|
11
11
|
include Concerns::RegexConverter
|
|
12
|
+
include Concerns::Nullability
|
|
12
13
|
|
|
13
14
|
attr_reader :name, :options, :validations, :route
|
|
14
15
|
|
|
@@ -24,9 +25,7 @@ module Gitlab
|
|
|
24
25
|
end
|
|
25
26
|
|
|
26
27
|
def in_value
|
|
27
|
-
|
|
28
|
-
# then match the param name as a complete segment bounded by / . ( ) or end-of-string.
|
|
29
|
-
route.path.gsub('/:version/', '/').match?(%r{/:#{Regexp.escape(name)}([/.()]|$)}) ? 'path' : 'query'
|
|
28
|
+
path_parameter_names.include?(name.to_s) ? 'path' : 'query'
|
|
30
29
|
end
|
|
31
30
|
|
|
32
31
|
def example
|
|
@@ -59,8 +58,10 @@ module Gitlab
|
|
|
59
58
|
build_basic_schema(object_type, object_format)
|
|
60
59
|
end
|
|
61
60
|
|
|
62
|
-
|
|
61
|
+
apply_nullability!(built_schema, options, in_value: in_value)
|
|
63
62
|
apply_limit!(built_schema, validations)
|
|
63
|
+
apply_array_enum!(built_schema, options[:values])
|
|
64
|
+
apply_default!(built_schema, options[:default], example: example)
|
|
64
65
|
built_schema
|
|
65
66
|
end
|
|
66
67
|
|
|
@@ -72,9 +73,12 @@ module Gitlab
|
|
|
72
73
|
def build_union_schema(object_type)
|
|
73
74
|
types = object_type[1..-2].split(", ")
|
|
74
75
|
members = types.map { |type| TypeResolver.resolve_union_member(type) }
|
|
76
|
+
schema = TypeResolver.union_schema(members)
|
|
77
|
+
members = schema.values.first
|
|
78
|
+
# Unlike `default:`, enum stays inline: it needs per-member type dispatch
|
|
79
|
+
# (coercing values to each member's type) that a generic post-step cannot do.
|
|
75
80
|
apply_union_enum!(members)
|
|
76
|
-
|
|
77
|
-
{ oneOf: members }
|
|
81
|
+
schema
|
|
78
82
|
end
|
|
79
83
|
|
|
80
84
|
def build_range_schema(object_type)
|
|
@@ -83,14 +87,12 @@ module Gitlab
|
|
|
83
87
|
|
|
84
88
|
schema[:minimum] = range.begin if range.begin
|
|
85
89
|
schema[:maximum] = range.end if range.end
|
|
86
|
-
schema[:default] = options[:default] if options[:default] && serializable?(options[:default])
|
|
87
90
|
schema
|
|
88
91
|
end
|
|
89
92
|
|
|
90
93
|
def build_enum_schema(object_type)
|
|
91
94
|
schema = { type: object_type }
|
|
92
95
|
schema[:enum] = options[:values] unless options[:values].is_a?(Proc)
|
|
93
|
-
schema[:default] = options[:default] if options[:default] && serializable?(options[:default])
|
|
94
96
|
schema
|
|
95
97
|
end
|
|
96
98
|
|
|
@@ -102,15 +104,6 @@ module Gitlab
|
|
|
102
104
|
def build_basic_schema(object_type, object_format)
|
|
103
105
|
schema = { type: object_type }
|
|
104
106
|
schema[:format] = object_format if object_format
|
|
105
|
-
if options[:default] && serializable?(options[:default])
|
|
106
|
-
schema[:default] = options[:default]
|
|
107
|
-
elsif options[:default] &&
|
|
108
|
-
defined?(ActiveSupport::TimeWithZone) &&
|
|
109
|
-
options[:default].is_a?(ActiveSupport::TimeWithZone)
|
|
110
|
-
serialized_default = time_serializer.serialize(options[:default], example: example)
|
|
111
|
-
schema[:default] = serialized_default if serialized_default
|
|
112
|
-
end
|
|
113
|
-
|
|
114
107
|
add_regex_validations!(schema)
|
|
115
108
|
schema
|
|
116
109
|
end
|
|
@@ -140,12 +133,15 @@ module Gitlab
|
|
|
140
133
|
# GET and DELETE requests don't have request bodies, so all their parameters are included.
|
|
141
134
|
method = route.request_method
|
|
142
135
|
return nil if method != 'GET' && method != 'DELETE' && in_value != 'path'
|
|
136
|
+
return nil if options.dig(:documentation, :hidden)
|
|
143
137
|
|
|
144
138
|
annotated = options.dup
|
|
145
139
|
if options[:desc] && fail_fast_in_validations?(validations)
|
|
146
140
|
annotated[:desc] = annotate_fail_fast(options[:desc])
|
|
147
141
|
end
|
|
148
142
|
|
|
143
|
+
annotated[:desc] ||= route_param_desc
|
|
144
|
+
|
|
149
145
|
param = Gitlab::GrapeOpenapi::Models::Parameter.new(
|
|
150
146
|
name,
|
|
151
147
|
options: annotated,
|
|
@@ -163,6 +159,18 @@ module Gitlab
|
|
|
163
159
|
|
|
164
160
|
private
|
|
165
161
|
|
|
162
|
+
def path_parameter_names
|
|
163
|
+
@path_parameter_names ||= NormalizedPath.new(route.origin).path_parameter_names
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Only path parameters can come from a `route_param`, and a same-named query
|
|
167
|
+
# parameter must not pick its description up.
|
|
168
|
+
def route_param_desc
|
|
169
|
+
return unless in_value == 'path'
|
|
170
|
+
|
|
171
|
+
GrapeCompat.route_param_desc(route, name)
|
|
172
|
+
end
|
|
173
|
+
|
|
166
174
|
def time_serializer
|
|
167
175
|
@time_serializer ||= Serializers::Time.new
|
|
168
176
|
end
|
|
@@ -188,63 +196,13 @@ module Gitlab
|
|
|
188
196
|
end
|
|
189
197
|
end
|
|
190
198
|
|
|
191
|
-
#
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
# and attach to all array members.
|
|
196
|
-
def apply_union_default!(members)
|
|
197
|
-
default = options[:default]
|
|
198
|
-
return unless default && serializable?(default)
|
|
199
|
-
|
|
200
|
-
members.each do |member|
|
|
201
|
-
member[:default] = default if member_accepts_default?(member, default)
|
|
199
|
+
# Checked before super because serializable? returns false for TimeWithZone.
|
|
200
|
+
def resolve_default(default, example: nil)
|
|
201
|
+
if defined?(ActiveSupport::TimeWithZone) && default.is_a?(ActiveSupport::TimeWithZone)
|
|
202
|
+
return time_serializer.serialize(default, example: example)
|
|
202
203
|
end
|
|
203
|
-
end
|
|
204
204
|
|
|
205
|
-
|
|
206
|
-
case member[:type]
|
|
207
|
-
when 'integer' then default.is_a?(Integer)
|
|
208
|
-
when 'number' then default.is_a?(Numeric)
|
|
209
|
-
when 'boolean' then [true, false].include?(default)
|
|
210
|
-
when 'string' then default.is_a?(String) || default.is_a?(Symbol)
|
|
211
|
-
when 'array' then array_member_accepts?(member, default)
|
|
212
|
-
when 'object' then default.is_a?(Hash)
|
|
213
|
-
end
|
|
214
|
-
end
|
|
215
|
-
|
|
216
|
-
def array_member_accepts?(member, default)
|
|
217
|
-
return false unless default.is_a?(Array)
|
|
218
|
-
return true if default.empty?
|
|
219
|
-
|
|
220
|
-
item_type = member.dig(:items, :type)
|
|
221
|
-
default.all? { |element| openapi_type_accepts?(item_type, element) }
|
|
222
|
-
end
|
|
223
|
-
|
|
224
|
-
def openapi_type_accepts?(openapi_type, value)
|
|
225
|
-
case openapi_type
|
|
226
|
-
when 'integer' then value.is_a?(Integer)
|
|
227
|
-
when 'number' then value.is_a?(Numeric)
|
|
228
|
-
when 'boolean' then [true, false].include?(value)
|
|
229
|
-
when 'string' then value.is_a?(String) || value.is_a?(Symbol)
|
|
230
|
-
else true
|
|
231
|
-
end
|
|
232
|
-
end
|
|
233
|
-
|
|
234
|
-
# allow_blank defaults to true
|
|
235
|
-
# when `allow_blank: false` for a string type minLength should be set to 1
|
|
236
|
-
# when param is required and values option used, the param is not nullable
|
|
237
|
-
def apply_allow_blank(schema)
|
|
238
|
-
if options[:allow_blank] == false || (options[:required] && options[:values])
|
|
239
|
-
schema[:minLength] = 1 if schema[:type] == 'string'
|
|
240
|
-
elsif in_value != 'path'
|
|
241
|
-
# path parameters are never nullable because they are required URL segments
|
|
242
|
-
if schema[:oneOf]
|
|
243
|
-
schema[:oneOf].each { |s| s[:nullable] = true }
|
|
244
|
-
else
|
|
245
|
-
schema[:nullable] = true
|
|
246
|
-
end
|
|
247
|
-
end
|
|
205
|
+
super
|
|
248
206
|
end
|
|
249
207
|
end
|
|
250
208
|
end
|
|
@@ -11,7 +11,14 @@ module Gitlab
|
|
|
11
11
|
RESPONSE_DECLARATIONS = %i[entity success].freeze
|
|
12
12
|
|
|
13
13
|
# A Grape optional path segment: a parenthesised group, e.g. `(/:id)`.
|
|
14
|
-
|
|
14
|
+
# A backslash-escaped paren is a literal character, not a group - OData
|
|
15
|
+
# routes use them, e.g. `nuget/v2/Packages\(Id='*package_name'\)`.
|
|
16
|
+
OPTIONAL_SEGMENT = /(?<!\\)\(([^()]*)(?<!\\)\)/
|
|
17
|
+
|
|
18
|
+
# The counterpart of OPTIONAL_SEGMENT: the parenthesis is a literal URL
|
|
19
|
+
# character and only the backslash is Grape pattern syntax, so the escape
|
|
20
|
+
# is dropped once optional segments have been resolved.
|
|
21
|
+
ESCAPED_PAREN = /\\([()])/
|
|
15
22
|
|
|
16
23
|
# Optional path segment variants are each represented in the OpenAPI spec as a unique path.
|
|
17
24
|
PathVariant = Struct.new(:key, :path_override, :removed_params)
|
|
@@ -77,20 +84,22 @@ module Gitlab
|
|
|
77
84
|
end
|
|
78
85
|
|
|
79
86
|
def skip_route?(route)
|
|
80
|
-
method = extract_method(route)
|
|
81
|
-
path = normalize_path(route)
|
|
82
|
-
|
|
83
87
|
# Hidden routes (declared with `hidden true`) must be skipped before
|
|
84
88
|
# OperationConverter runs. Otherwise it pollutes the shared schema and
|
|
85
89
|
# request-body registries with entries that no emitted operation references,
|
|
86
90
|
# surfacing as `no-unused-components` warnings under `components.schemas`.
|
|
87
91
|
return true if hidden?(route)
|
|
88
92
|
|
|
89
|
-
# Grape
|
|
90
|
-
#
|
|
91
|
-
#
|
|
92
|
-
#
|
|
93
|
-
|
|
93
|
+
# Grape's catch-all (`route :any, '*path'`) is an internal routing artifact,
|
|
94
|
+
# not an API endpoint, and * isn't a valid HTTP method. Grape itself
|
|
95
|
+
# discriminates it by request method - see `collect_route_config_per_pattern`
|
|
96
|
+
# in `grape/api/instance.rb` - so match on the method alone.
|
|
97
|
+
#
|
|
98
|
+
# Do NOT also skip paths containing `*`. Splat segments are declared by
|
|
99
|
+
# developers for path segments that may contain slashes (`*module_name`,
|
|
100
|
+
# `*package_name`), and they are real endpoints. NormalizedPath renders them
|
|
101
|
+
# as ordinary `{name}` placeholders. See issue #22.
|
|
102
|
+
extract_method(route) == '*'
|
|
94
103
|
end
|
|
95
104
|
|
|
96
105
|
def hidden?(route)
|
|
@@ -98,12 +107,7 @@ module Gitlab
|
|
|
98
107
|
end
|
|
99
108
|
|
|
100
109
|
def normalize_path(route)
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
path
|
|
104
|
-
.gsub(/\(\.:format\)$/, '')
|
|
105
|
-
.gsub(/:\w+/) { |match| "{#{match[1..]}}" }
|
|
106
|
-
.gsub('{version}', config.api_version)
|
|
110
|
+
NormalizedPath.new(route.pattern.origin).to_s.gsub('{version}', config.api_version)
|
|
107
111
|
end
|
|
108
112
|
|
|
109
113
|
def grouping_key(route)
|
|
@@ -136,18 +140,28 @@ module Gitlab
|
|
|
136
140
|
# keys: a collapsed variant (segment and param removed entirely) and an
|
|
137
141
|
# expanded variant (param present, rendered as a required path param).
|
|
138
142
|
# Routes without such a segment yield a single, unchanged variant.
|
|
143
|
+
#
|
|
144
|
+
# Both variants resolve *every* optional group, not just the
|
|
145
|
+
# param-bearing one, because a route can put the separator in a group of
|
|
146
|
+
# its own: `releases/permalink/latest(/)(*suffix_path)` needs the `(/)`
|
|
147
|
+
# dropped alongside the param and inlined alongside it, or the expanded
|
|
148
|
+
# key would read `latest(/){suffix_path}` and match no real URL.
|
|
149
|
+
#
|
|
150
|
+
# Every emitted key drops the backslash of an escaped parenthesis, which
|
|
151
|
+
# can only happen once optional segments are resolved: until then it is
|
|
152
|
+
# the escape that tells a literal parenthesis apart from a group.
|
|
139
153
|
def path_variants(path_key, route)
|
|
140
154
|
param_groups = path_key.scan(OPTIONAL_SEGMENT).flatten.select { |inner| inner.match?(/\{\w+\}/) }
|
|
141
155
|
|
|
142
156
|
raise MultipleOptionalSegmentsError, multi_segment_message(route, path_key) if param_groups.length > 1
|
|
143
157
|
|
|
144
158
|
inner = param_groups.first
|
|
145
|
-
return [PathVariant.new(path_key, nil, [])] if inner.nil?
|
|
159
|
+
return [PathVariant.new(unescape_parens(path_key), nil, [])] if inner.nil?
|
|
146
160
|
|
|
147
161
|
removed = inner.scan(/\{(\w+)\}/).flatten
|
|
148
|
-
collapsed =
|
|
162
|
+
collapsed = unescape_parens(squeeze_slashes(path_key.gsub(OPTIONAL_SEGMENT, '')).delete_suffix('/'))
|
|
149
163
|
collapsed = '/' if collapsed.empty?
|
|
150
|
-
expanded = path_key.
|
|
164
|
+
expanded = unescape_parens(squeeze_slashes(path_key.gsub(OPTIONAL_SEGMENT) { Regexp.last_match(1) }))
|
|
151
165
|
|
|
152
166
|
[
|
|
153
167
|
PathVariant.new(collapsed, collapsed, removed),
|
|
@@ -155,6 +169,14 @@ module Gitlab
|
|
|
155
169
|
]
|
|
156
170
|
end
|
|
157
171
|
|
|
172
|
+
def squeeze_slashes(path)
|
|
173
|
+
path.gsub(%r{//+}, '/')
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def unescape_parens(path)
|
|
177
|
+
path.gsub(ESCAPED_PAREN) { Regexp.last_match(1) }
|
|
178
|
+
end
|
|
179
|
+
|
|
158
180
|
def multi_segment_message(route, path_key)
|
|
159
181
|
"Route '#{extract_method(route)} #{path_key}' declares more than one optional path segment " \
|
|
160
182
|
"with a named parameter. gitlab-grape-openapi supports at most one optional segment per route " \
|
|
@@ -50,6 +50,8 @@ module Gitlab
|
|
|
50
50
|
required_params << key.to_s if param_options[:required]
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
+
annotate_cross_field_constraints!(properties)
|
|
54
|
+
|
|
53
55
|
schema = {
|
|
54
56
|
type: 'object',
|
|
55
57
|
properties: properties
|
|
@@ -68,6 +70,43 @@ module Gitlab
|
|
|
68
70
|
}
|
|
69
71
|
end
|
|
70
72
|
|
|
73
|
+
# Documents cross-field constraints (mutually_exclusive, ...) in each
|
|
74
|
+
# affected property's description. OpenAPI has no cross-property keyword
|
|
75
|
+
# and Scalar does not render JSON Schema `not` so the validation constraint
|
|
76
|
+
# is expressed in prose.
|
|
77
|
+
def annotate_cross_field_constraints!(properties)
|
|
78
|
+
resolver = CrossFieldValidationResolver.new(route, properties.keys)
|
|
79
|
+
|
|
80
|
+
resolver.notes.each do |name, note|
|
|
81
|
+
property = properties[name]
|
|
82
|
+
next unless property
|
|
83
|
+
|
|
84
|
+
property[:description] = CrossFieldValidationResolver.append_note(property[:description], note)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
warn_skipped_cross_field_constraints(resolver.skipped)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Warns about a constraint we cannot document yet, because a member of the
|
|
91
|
+
# group is not a property of this body. Usually the group is nested inside
|
|
92
|
+
# an object property - GET/DELETE flatten such params into the query and
|
|
93
|
+
# document them, POST/PUT/PATCH bodies keep the nesting - but a member
|
|
94
|
+
# dropped from the body for another reason (a path param, or one hidden
|
|
95
|
+
# with `documentation: { hidden: true }`) lands here too, hence the wording.
|
|
96
|
+
def warn_skipped_cross_field_constraints(skipped)
|
|
97
|
+
return unless config.warnings
|
|
98
|
+
|
|
99
|
+
path = NormalizedPath.new(route.pattern.origin).to_display_path(config.api_version)
|
|
100
|
+
skipped.each do |group|
|
|
101
|
+
warn "[gitlab-grape-openapi] skipped cross-field constraint: " \
|
|
102
|
+
"#{route_method} #{path} params=#{group.join(',')} (not a top-level body property)"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def config
|
|
107
|
+
@config ||= Gitlab::GrapeOpenapi.configuration
|
|
108
|
+
end
|
|
109
|
+
|
|
71
110
|
def content_type(body_params)
|
|
72
111
|
custom_content_type = extract_consumes_content_type
|
|
73
112
|
return custom_content_type if custom_content_type
|
|
@@ -4,6 +4,10 @@ module Gitlab
|
|
|
4
4
|
module GrapeOpenapi
|
|
5
5
|
module Converters
|
|
6
6
|
class ResponseConverter
|
|
7
|
+
# RFC 9110 forbids a body on these, so a declared media type cannot
|
|
8
|
+
# describe one.
|
|
9
|
+
BODYLESS_STATUS_CODES = %w[204 304].freeze
|
|
10
|
+
|
|
7
11
|
def initialize(route, schema_registry)
|
|
8
12
|
@route = route
|
|
9
13
|
@schema_registry = schema_registry
|
|
@@ -19,14 +23,15 @@ module Gitlab
|
|
|
19
23
|
private
|
|
20
24
|
|
|
21
25
|
def extract_success_response
|
|
22
|
-
entity_definition =
|
|
26
|
+
entity_definition = success_definition
|
|
23
27
|
|
|
24
28
|
case entity_definition
|
|
25
29
|
when nil
|
|
26
|
-
success_code = infer_success_code
|
|
30
|
+
success_code = infer_success_code(with_body: declared_body?)
|
|
27
31
|
add_simple_response(
|
|
28
32
|
status_code: success_code,
|
|
29
|
-
description: http_status_text(success_code)
|
|
33
|
+
description: http_status_text(success_code),
|
|
34
|
+
content: declared_content
|
|
30
35
|
)
|
|
31
36
|
when Class
|
|
32
37
|
process_class_entity(entity_definition)
|
|
@@ -46,10 +51,11 @@ module Gitlab
|
|
|
46
51
|
entity_class: entity_class
|
|
47
52
|
)
|
|
48
53
|
else
|
|
49
|
-
success_code = infer_success_code
|
|
54
|
+
success_code = infer_success_code(with_body: declared_body?)
|
|
50
55
|
add_simple_response(
|
|
51
56
|
status_code: success_code,
|
|
52
|
-
description: http_status_text(success_code)
|
|
57
|
+
description: http_status_text(success_code),
|
|
58
|
+
content: declared_content
|
|
53
59
|
)
|
|
54
60
|
end
|
|
55
61
|
end
|
|
@@ -65,10 +71,11 @@ module Gitlab
|
|
|
65
71
|
examples: entity_hash[:examples]
|
|
66
72
|
)
|
|
67
73
|
else
|
|
68
|
-
status_code = entity_hash[:code] || infer_success_code
|
|
74
|
+
status_code = entity_hash[:code] || infer_success_code(with_body: declared_body?)
|
|
69
75
|
add_simple_response(
|
|
70
76
|
status_code: status_code,
|
|
71
|
-
description: entity_hash[:message] || http_status_text(status_code)
|
|
77
|
+
description: entity_hash[:message] || http_status_text(status_code),
|
|
78
|
+
content: declared_content
|
|
72
79
|
)
|
|
73
80
|
end
|
|
74
81
|
end
|
|
@@ -95,10 +102,11 @@ module Gitlab
|
|
|
95
102
|
examples: definition[:examples]
|
|
96
103
|
)
|
|
97
104
|
else
|
|
98
|
-
status_code = definition[:code] || infer_success_code
|
|
105
|
+
status_code = definition[:code] || infer_success_code(with_body: declared_body?)
|
|
99
106
|
add_simple_response(
|
|
100
107
|
status_code: status_code,
|
|
101
|
-
description: definition[:message] || http_status_text(status_code)
|
|
108
|
+
description: definition[:message] || http_status_text(status_code),
|
|
109
|
+
content: declared_content
|
|
102
110
|
)
|
|
103
111
|
end
|
|
104
112
|
end
|
|
@@ -136,7 +144,9 @@ module Gitlab
|
|
|
136
144
|
@responses[response.status_code] = response.to_h(@schema_registry)
|
|
137
145
|
end
|
|
138
146
|
|
|
139
|
-
|
|
147
|
+
# `content` is only ever passed for the success response, so failure
|
|
148
|
+
# responses stay JSON.
|
|
149
|
+
def add_simple_response(status_code:, description:, content: nil)
|
|
140
150
|
key = status_code.to_s
|
|
141
151
|
|
|
142
152
|
# `http_codes` (processed by `extract_failure_responses`) may include
|
|
@@ -148,7 +158,57 @@ module Gitlab
|
|
|
148
158
|
@responses[key][:description] = description
|
|
149
159
|
else
|
|
150
160
|
@responses[key] = { description: description }
|
|
161
|
+
@responses[key][:content] = content if content && BODYLESS_STATUS_CODES.exclude?(key)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Whether the route describes a body, which decides between 200 and 204
|
|
166
|
+
# for a DELETE.
|
|
167
|
+
def declared_body?
|
|
168
|
+
!declared_content.nil?
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# The content map for a response is described with `produces` (or
|
|
172
|
+
# `success File`) rather than an entity. `nil` when the route declares
|
|
173
|
+
# nothing the gem can describe.
|
|
174
|
+
def declared_content
|
|
175
|
+
return @declared_content if defined?(@declared_content)
|
|
176
|
+
|
|
177
|
+
@declared_content = build_declared_content
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# If there is no schema for the declared type it is dropped as guessing
|
|
181
|
+
# would misdescribe it. See MediaTypeResolver for the mapping.
|
|
182
|
+
def build_declared_content
|
|
183
|
+
content = declared_media_types.each_with_object({}) do |media_type, acc|
|
|
184
|
+
schema = MediaTypeResolver.schema_for(media_type)
|
|
185
|
+
acc[media_type] = { schema: schema } if schema
|
|
151
186
|
end
|
|
187
|
+
|
|
188
|
+
content unless content.empty?
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# An explicit `produces` overrides `success File`, matching grape-swagger existing behaviour.
|
|
192
|
+
def declared_media_types
|
|
193
|
+
produces = MediaTypeResolver.normalize(@route.settings.dig(:description, :produces))
|
|
194
|
+
return produces if produces.any?
|
|
195
|
+
return [] unless file_response?(success_definition)
|
|
196
|
+
|
|
197
|
+
[MediaTypeResolver::OCTET_STREAM_MEDIA_TYPE]
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Recognizes `success File` in any of its shapes. Compared by name so if `File`
|
|
201
|
+
# is passed as a String it matches. Aligns with grape-swagger's `file_response?`
|
|
202
|
+
def file_response?(definition)
|
|
203
|
+
case definition
|
|
204
|
+
when Array then definition.any? { |item| file_response?(item) }
|
|
205
|
+
when Hash then file_response?(definition[:model])
|
|
206
|
+
else definition.to_s == 'File'
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def success_definition
|
|
211
|
+
@route.options[:entity] || @route.options[:success]
|
|
152
212
|
end
|
|
153
213
|
|
|
154
214
|
# 204 means "No Content", so it only applies when the response has no
|
|
@@ -174,10 +234,9 @@ module Gitlab
|
|
|
174
234
|
end
|
|
175
235
|
|
|
176
236
|
def path_has_resource_parameters?
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
path.include?(':')
|
|
237
|
+
# Splat segments count: `/packages/npm/*package_name` addresses a resource
|
|
238
|
+
# just as `/packages/:id` does, so it earns the same inferred 404.
|
|
239
|
+
NormalizedPath.new(@route.origin).path_parameter_names.any?
|
|
181
240
|
end
|
|
182
241
|
|
|
183
242
|
def http_status_text(code)
|
|
@@ -66,9 +66,25 @@ module Gitlab
|
|
|
66
66
|
item_type = type[1..-2]
|
|
67
67
|
{ type: 'array', items: { type: resolve_type(item_type) || 'string' } }
|
|
68
68
|
else
|
|
69
|
-
{ type: resolve_type(type) || 'string' }
|
|
69
|
+
member = { type: resolve_type(type) || 'string' }
|
|
70
|
+
format = resolve_format(nil, type)
|
|
71
|
+
member[:format] = format if format
|
|
72
|
+
member
|
|
70
73
|
end
|
|
71
74
|
end
|
|
75
|
+
|
|
76
|
+
# `oneOf` means "valid against exactly one". Members that differ only by `format`
|
|
77
|
+
# are indistinguishable to a validator, because `format` annotates rather than
|
|
78
|
+
# constrains, so nothing could satisfy it and `anyOf` ("at least one") is the
|
|
79
|
+
# accurate keyword. Keep `oneOf` everywhere else: it is the stricter of the two
|
|
80
|
+
# and says more about the parameter.
|
|
81
|
+
def self.union_schema(members)
|
|
82
|
+
members = members.uniq
|
|
83
|
+
asserted = members.map { |member| member.except(:format) }
|
|
84
|
+
keyword = asserted.uniq.size == members.size ? :oneOf : :anyOf
|
|
85
|
+
|
|
86
|
+
{ keyword => members }
|
|
87
|
+
end
|
|
72
88
|
end
|
|
73
89
|
end
|
|
74
90
|
end
|
|
@@ -11,12 +11,8 @@ module Gitlab
|
|
|
11
11
|
module GrapeCompat
|
|
12
12
|
class << self
|
|
13
13
|
# Declared validations for a single attribute, newest scope only.
|
|
14
|
-
#
|
|
15
|
-
# Reads `new_values` rather than `[]` or `route[:saved_validations]` on
|
|
16
|
-
# purpose: those also include validations inherited from parent scopes,
|
|
17
|
-
# which would change the generated output.
|
|
18
14
|
def validations_for(route, attribute)
|
|
19
|
-
validations = route
|
|
15
|
+
validations = declared_validations(route)
|
|
20
16
|
return unless validations
|
|
21
17
|
|
|
22
18
|
validations.filter_map do |validation|
|
|
@@ -25,10 +21,49 @@ module Gitlab
|
|
|
25
21
|
end
|
|
26
22
|
end
|
|
27
23
|
|
|
24
|
+
# Every declared validation for a route, normalized, newest scope only.
|
|
25
|
+
# Unlike `validations_for` this keeps group validators (mutually_exclusive
|
|
26
|
+
# and friends) whose `:attributes` span several params.
|
|
27
|
+
def all_validations(route)
|
|
28
|
+
validations = declared_validations(route)
|
|
29
|
+
return [] unless validations
|
|
30
|
+
|
|
31
|
+
validations.filter_map { |validation| normalize(validation) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The `desc:` an author writes on `route_param`. Grape forwards only `type:` into
|
|
35
|
+
# the `requires` it declares internally and discards the rest, so the description
|
|
36
|
+
# never reaches the route's params. It survives on the namespace whose space is
|
|
37
|
+
# the `:placeholder` segment. Searched innermost first.
|
|
38
|
+
def route_param_desc(route, name)
|
|
39
|
+
return unless route.app.respond_to?(:inheritable_setting)
|
|
40
|
+
|
|
41
|
+
namespaces = route.app.inheritable_setting&.namespace_stackable&.[](:namespace) || []
|
|
42
|
+
namespace = namespaces.reverse.find { |candidate| candidate.space.to_s == ":#{name}" }
|
|
43
|
+
options = namespace&.options
|
|
44
|
+
return unless options.is_a?(Hash)
|
|
45
|
+
|
|
46
|
+
desc = options[:desc]
|
|
47
|
+
desc if desc.is_a?(String)
|
|
48
|
+
end
|
|
49
|
+
|
|
28
50
|
private
|
|
29
51
|
|
|
52
|
+
# The route's raw, un-normalized validation stack, and the only place this
|
|
53
|
+
# gem reaches into Grape for it.
|
|
54
|
+
#
|
|
55
|
+
# Reads `new_values` rather than `[]` or `route[:saved_validations]` on
|
|
56
|
+
# purpose: those also include validations inherited from parent scopes,
|
|
57
|
+
# which would change the generated output.
|
|
58
|
+
def declared_validations(route)
|
|
59
|
+
route.app.inheritable_setting.namespace_stackable.new_values[:validations]
|
|
60
|
+
end
|
|
61
|
+
|
|
30
62
|
def normalize(validation)
|
|
31
|
-
|
|
63
|
+
if validation.is_a?(Hash) # Grape < 3.2
|
|
64
|
+
# Do not mutate the author's own Hash; add the derived key on a copy.
|
|
65
|
+
return validation.merge(full_names: full_names_for(validation[:params_scope], validation[:attributes]))
|
|
66
|
+
end
|
|
32
67
|
|
|
33
68
|
# Grape 3.2's ContractScopeValidator declares no attributes, so it maps
|
|
34
69
|
# to no parameter.
|
|
@@ -41,9 +76,26 @@ module Gitlab
|
|
|
41
76
|
# carry regexp patterns and limits. Switch to the reader if one is
|
|
42
77
|
# added upstream.
|
|
43
78
|
options: validation.instance_variable_get(:@options),
|
|
44
|
-
opts: { fail_fast: validation.fail_fast? }
|
|
79
|
+
opts: { fail_fast: validation.fail_fast? },
|
|
80
|
+
# Full bracketed paths (e.g. "filter[x]") for cross-field constraints
|
|
81
|
+
# in nested scopes; equal to the bare name at the root. No public
|
|
82
|
+
# reader for the scope on 3.2, hence the ivar.
|
|
83
|
+
full_names: full_names_for(validation.instance_variable_get(:@scope), validation.attrs)
|
|
45
84
|
}
|
|
46
85
|
end
|
|
86
|
+
|
|
87
|
+
# Maps each attribute to its full bracketed path via the param scope, so a
|
|
88
|
+
# nested `x` becomes "filter[x]" while a top-level `x` stays "x". Falls
|
|
89
|
+
# back to the bare name if the scope cannot resolve it.
|
|
90
|
+
def full_names_for(scope, attributes)
|
|
91
|
+
Array(attributes).map do |attribute|
|
|
92
|
+
if scope.respond_to?(:full_name)
|
|
93
|
+
scope.full_name(attribute).to_s
|
|
94
|
+
else
|
|
95
|
+
attribute.to_s
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
47
99
|
end
|
|
48
100
|
end
|
|
49
101
|
end
|