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.
@@ -5,10 +5,11 @@ module Gitlab
5
5
  module Converters
6
6
  class ParameterConverter
7
7
  include CoercerResolver
8
- include Concerns::Serializable
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
- # Strip only the :version path segment (not substrings like :version_id or :package_version),
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
- apply_allow_blank(built_schema)
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
- apply_union_default!(members)
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
- # When a union (oneOf) schema has a `default:`, attach it to every member
192
- # whose schema can accept the default value. For arrays this includes
193
- # checking the items type so `[1, 2]` lands on `items: { type: integer }`
194
- # but not on `items: { type: string }`. Empty arrays are type-agnostic
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
- def member_accepts_default?(member, default)
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
- OPTIONAL_SEGMENT = /\(([^()]*)\)/
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 registers catch-all routes with HTTP method * (matches any method) and
90
- # paths containing *path (wildcard segments). Neither is valid OpenAPI: * isn't
91
- # an HTTP method, and *path isn't a valid path segment. These are internal
92
- # Grape routing artifacts, not actual API endpoints.
93
- method == '*' || path.include?('*')
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
- path = route.pattern.origin
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 = path_key.sub("(#{inner})", '').gsub(%r{//+}, '/').delete_suffix('/')
162
+ collapsed = unescape_parens(squeeze_slashes(path_key.gsub(OPTIONAL_SEGMENT, '')).delete_suffix('/'))
149
163
  collapsed = '/' if collapsed.empty?
150
- expanded = path_key.sub("(#{inner})", inner).gsub(%r{//+}, '/')
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 = @route.options[:entity] || @route.options[:success]
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
- def add_simple_response(status_code:, description:)
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
- path = @route.path
178
- .gsub('.:format', '')
179
- .gsub(':version', '')
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.app.inheritable_setting.namespace_stackable.new_values[:validations]
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
- return validation if validation.is_a?(Hash)
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