gitlab-grape-openapi 0.4.0 → 0.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0abbc799f3af1d884ee9e543865658f3709007fe0cb09febea2792678c09d071
4
- data.tar.gz: 815581f530dbedff8b9cd89135507640df91e58435f42a62c78bd1d213388d46
3
+ metadata.gz: 10a34008541dedbab4063d199bbdb94f2d669d94101be45af1023e362bf8c72f
4
+ data.tar.gz: ba640eb0b0553149b7776bd53afef9eee0c2cc5316dbb4917c44c2099b138d0e
5
5
  SHA512:
6
- metadata.gz: 2574c73242cbbb597331b2919a05ced49beba5dbe733683d88cf90d1ae71d2e55ecffbc2443b399ad5d52e9dc74e53cee7360f3e998b1bc0e8cf7ae8c4603138
7
- data.tar.gz: 2bcf5665113a86d6aee987a543fb101ce582bda2801498765a89ca5c42432b8da9d6461bafeb4cfea11f522cbf75ea77497b8ce580329c088ab0ca485007b795
6
+ metadata.gz: fd55dfbd2de53ec38abfd182f89d2dcbf2f03b916fbde38525dd8566c5984d134b21a9ff112123a7d2d9dbec0fc8077c8bf4a04d40117c902215e4a4cb6eb52b
7
+ data.tar.gz: 4ece03be3b48a4f4082539446d72ab4ac68b8cd0a022ee125f777c7196dd2aac73b154bc98f1518ac078da6b13b26a80c0a0a4dfabada8f220d461b507202e2c
data/README.md CHANGED
@@ -88,22 +88,32 @@ Gitlab::GrapeOpenapi.configure do |config|
88
88
  config.annotations = {
89
89
  lifecycle: 'x-gitlab-lifecycle'
90
90
  }
91
+
92
+ # Cross-field groups larger than this are stated once on the endpoint
93
+ config.cross_field_group_limit = 30
94
+
95
+ # Endpoint wording for those, keyed by "METHOD /path"
96
+ config.cross_field_messages = {
97
+ 'PUT /api/v4/application/settings' => 'At least one setting must be updated.'
98
+ }
91
99
  end
92
100
  ```
93
101
 
94
102
  ### Configuration Options
95
103
 
96
- | Option | Type | Default | Description |
97
- | ---------------------- | ------------------------------- | ------- | ------------------------------------------------------------ |
98
- | `info` | `Models::Info` | `nil` | API metadata (title, description, version, terms of service) |
99
- | `api_prefix` | `String` | `"api"` | URL prefix for API routes |
100
- | `api_version` | `String` | `"v1"` | API version string |
101
- | `servers` | `Array<Models::Server>` | `[]` | Server definitions for the API |
102
- | `security_schemes` | `Array<Models::SecurityScheme>` | `[]` | Authentication/authorization schemes |
103
- | `excluded_api_classes` | `Array<String>` | `[]` | API class names to exclude from generation |
104
- | `tag_overrides` | `Hash` | `{}` | Map of tag names to their display overrides |
105
- | `annotations` | `Hash` | `{}` | Map of Grape route settings to OpenAPI extension names |
106
- | `warnings` | `Boolean` | `false` | Emit stderr warnings for synthesized (undeclared) path params |
104
+ | Option | Type | Default | Description |
105
+ | ---------------------- | ------------------------------- | ------- | ---------------------------------------------------------------- |
106
+ | `info` | `Models::Info` | `nil` | API metadata (title, description, version, terms of service) |
107
+ | `api_prefix` | `String` | `"api"` | URL prefix for API routes |
108
+ | `api_version` | `String` | `"v1"` | API version string |
109
+ | `servers` | `Array<Models::Server>` | `[]` | Server definitions for the API |
110
+ | `security_schemes` | `Array<Models::SecurityScheme>` | `[]` | Authentication/authorization schemes |
111
+ | `excluded_api_classes` | `Array<String>` | `[]` | API class names to exclude from generation |
112
+ | `tag_overrides` | `Hash` | `{}` | Map of tag names to their display overrides |
113
+ | `annotations` | `Hash` | `{}` | Map of Grape route settings to OpenAPI extension names |
114
+ | `warnings` | `Boolean` | `false` | Emit stderr warnings for synthesized params, skipped constraints |
115
+ | `cross_field_group_limit` | `Integer` | `30` | Cross-field groups larger than this are summarised on the endpoint |
116
+ | `cross_field_messages` | `Hash` | `{}` | Endpoint wording for summarised groups, keyed by `"METHOD /path"` |
107
117
 
108
118
  ### Annotations
109
119
 
@@ -186,9 +196,54 @@ Generator
186
196
  │ ├── ResponseConverter - Converts endpoint responses
187
197
  │ └── RequestBodyConverter - Converts request bodies
188
198
  ├── MediaTypeResolver - Maps declared media types to response schemas
189
- └── TypeResolver - Maps Ruby/Grape types to OpenAPI types
199
+ ├── TypeResolver - Maps Ruby/Grape types to OpenAPI types
200
+ └── CrossFieldValidationResolver - Documents Grape cross-field validations in descriptions
190
201
  ```
191
202
 
203
+ ### Parameter constraints
204
+
205
+ The gem documents Grape's cross-field param constraints by appending a note to
206
+ each affected parameter's (or request-body property's) `description` — for both
207
+ query/path params (`GET`/`DELETE`) and request-body params
208
+ (`POST`/`PUT`/`PATCH`). Three constraints are supported:
209
+
210
+ | Grape declaration | Note appended to every member |
211
+ |-------------------|-------------------------------|
212
+ | `mutually_exclusive :author_id, :author_username` | ``Mutually exclusive with `author_username`.`` |
213
+ | `exactly_one_of :project_id, :project_path` | ``Exactly one of `project_id`, `project_path` must be provided.`` |
214
+ | `at_least_one_of :assignee_id, :reviewer_id` | ``At least one of `assignee_id`, `reviewer_id` must be provided.`` |
215
+
216
+ OpenAPI 3.0 has no cross-parameter constraint keyword, and the target renderer
217
+ (Scalar) does not render JSON Schema `not`/`allOf`, so the constraint is stated
218
+ in prose; Grape enforces it at runtime (HTTP 400). Members of these groups stay
219
+ individually optional — the constraint binds the group, not any single param, so
220
+ no `required` entry is emitted.
221
+
222
+ They differ in how several declarations over one param are combined. Only
223
+ `mutually_exclusive` is pairwise, so a param lists every partner in one note. The
224
+ group constraints do not compose that way, so each declaration is stated as its
225
+ own sentence: `at_least_one_of :a, :b` alongside `at_least_one_of :a, :c` forbids
226
+ `b` alone, which the collapsed "At least one of `a`, `b`, `c`" would allow.
227
+
228
+ A group large enough that naming every member becomes unreadable — GitLab's
229
+ `PUT /api/v4/application/settings` declares `at_least_one_of` over 664 params — is
230
+ not enumerated. The rule is stated once on the endpoint's own `description`
231
+ instead, and its members keep their original descriptions. `cross_field_group_limit`
232
+ sets the threshold; `cross_field_messages` supplies wording per endpoint, keyed by
233
+ the same `"METHOD /path"` string the warnings print. Without an entry the gem uses
234
+ a generic sentence and, with `warnings` enabled, names the endpoint so wording can
235
+ be added.
236
+
237
+ The gem reads the validation stack through `grape_compat`, so this works on both
238
+ Grape 2.4 and 3.2. Each group is keyed by the full bracketed param path
239
+ (`not[author_id]`) rather than the bare name. Because Grape flattens every nested
240
+ param into the route's query params, a constraint nested inside a `Hash` *query*
241
+ filter is documented the same as a top-level one. A constraint nested inside a
242
+ request-body object property has no top-level property to attach to and is
243
+ skipped; when `warnings` is enabled the gem logs one line per skipped group so it
244
+ is visible in source. The remaining sibling constraint (`all_or_none_of`) is not
245
+ yet supported.
246
+
192
247
  ### Registries
193
248
 
194
249
  - **SchemaRegistry** - Tracks converted entity schemas
@@ -198,8 +253,23 @@ Generator
198
253
  ### Response media types
199
254
 
200
255
  Responses default to `application/json`, described by the `$ref` of the entity
201
- declared with `success` / `entity`. Endpoints that return a file or plain text
202
- instead declare their media type with `produces` in the `desc` block:
256
+ declared with `success` / `entity`. Declaring the route as a collection wraps
257
+ that `$ref` in a `type: array` / `items:` schema, so a collection endpoint is
258
+ not published as a single object. Both of Grape's forms are honoured — inline
259
+ in the declaration (`success code: 200, model: Entities::Tag, is_array: true`)
260
+ and standalone in the `desc` block:
261
+
262
+ ```ruby
263
+ desc 'List tags' do
264
+ success code: 200, model: Entities::Tag
265
+ is_array true
266
+ end
267
+ ```
268
+
269
+ An inline `is_array:` takes precedence over the standalone setting.
270
+
271
+ Endpoints that return a file or plain text instead declare their media type
272
+ with `produces` in the `desc` block:
203
273
 
204
274
  ```ruby
205
275
  desc 'Download an export' do
@@ -16,7 +16,7 @@ module Gitlab
16
16
  return unless schema[:type] == 'array' && schema[:items].is_a?(Hash)
17
17
  return unless values.is_a?(Array)
18
18
 
19
- schema[:items][:enum] = values
19
+ schema[:items][:enum] = stringify_symbols(values)
20
20
  end
21
21
 
22
22
  def apply_default!(schema, default, example: nil)
@@ -33,7 +33,7 @@ module Gitlab
33
33
  def resolve_default(default, example: nil) # rubocop:disable Lint/UnusedMethodArgument -- overridden
34
34
  return unless serializable?(default)
35
35
 
36
- default
36
+ stringify_symbols(default)
37
37
  end
38
38
 
39
39
  # When a union (oneOf) schema has a `default:`, attach it to every member
@@ -45,7 +45,7 @@ module Gitlab
45
45
  return unless serializable?(default)
46
46
 
47
47
  members.each do |member|
48
- member[:default] = default if member_accepts_default?(member, default)
48
+ member[:default] = stringify_symbols(default) if member_accepts_default?(member, default)
49
49
  end
50
50
  end
51
51
 
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Determines whether a param is nullable.
4
+ #
5
+ # Grape default behaviour sets nullability true for plain params.
6
+ # Two declarations remove it: a `default:`, which Grape's
7
+ # DefaultValidator substitutes for the null before the endpoint runs, and a path
8
+ # parameter, which is a required URL segment.
9
+ #
10
+ # The `minLength: 1` branch is mutually exclusive with nullability and stays coupled
11
+ # here deliberately: applying the two independently would mark every required enum
12
+ # nullable.
13
+ #
14
+ # NOTE: `param_options[:allow_blank]` is always nil. Grape omits allow_blank from
15
+ # route.params, so that clause is dead and only `required && values` fires.
16
+
17
+ module Gitlab
18
+ module GrapeOpenapi
19
+ module Concerns
20
+ module Nullability
21
+ private
22
+
23
+ def apply_nullability!(schema, param_options, in_value: nil)
24
+ if blank_rejected?(param_options)
25
+ schema[:minLength] = 1 if schema[:type] == 'string'
26
+ elsif nullable?(param_options, in_value)
27
+ mark_nullable!(schema)
28
+ end
29
+ end
30
+
31
+ def blank_rejected?(param_options)
32
+ param_options[:allow_blank] == false ||
33
+ (param_options[:required] && param_options[:values])
34
+ end
35
+
36
+ def nullable?(param_options, in_value)
37
+ return false if in_value == 'path'
38
+
39
+ param_options[:default].nil?
40
+ end
41
+
42
+ # Grape substitutes the default regardless of which union member matched, so
43
+ # nullability applies to every member or none.
44
+ def mark_nullable!(schema)
45
+ members = schema[:oneOf] || schema[:anyOf]
46
+ return members.each { |member| member[:nullable] = true } if members
47
+
48
+ schema[:nullable] = true
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -13,6 +13,16 @@ module Gitlab
13
13
 
14
14
  true
15
15
  end
16
+
17
+ # Symbol maps to OpenAPI `string`, so Symbol values must become strings too.
18
+ # Otherwise a Symbol serializes as `:value` in YAML, which parsers read back as that string.
19
+ def stringify_symbols(value)
20
+ case value
21
+ when Symbol then value.to_s
22
+ when Array then value.map { |element| stringify_symbols(element) }
23
+ else value
24
+ end
25
+ end
16
26
  end
17
27
  end
18
28
  end
@@ -4,7 +4,8 @@ module Gitlab
4
4
  module GrapeOpenapi
5
5
  class Configuration
6
6
  attr_accessor :api_version, :api_prefix, :excluded_api_classes, :servers, :security_schemes, :info,
7
- :tag_overrides, :annotations, :coercer_mappings, :warnings
7
+ :tag_overrides, :annotations, :coercer_mappings, :warnings,
8
+ :cross_field_group_limit, :cross_field_messages
8
9
 
9
10
  def initialize
10
11
  @api_prefix = "api"
@@ -19,6 +20,12 @@ module Gitlab
19
20
  @annotations = {}
20
21
  @coercer_mappings = {}
21
22
  @warnings = false
23
+
24
+ # Cross field validations
25
+ # Allow custom descriptions for endpoints whose groups of cross-validated
26
+ # params exceed cross_field_group_limit
27
+ @cross_field_group_limit = 30
28
+ @cross_field_messages = {}
22
29
  end
23
30
  end
24
31
  end
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Documents Grape cross-field param validations by appending a note to each
4
+ # affected parameter's / request-body property's description.
5
+ #
6
+ # OpenAPI 3.0 has no cross-parameter keyword and Scalar (the renderer) does not
7
+ # render JSON Schema `not`/`allOf`, so these constraints are stated in prose;
8
+ # Grape enforces them at runtime (HTTP 400).
9
+ #
10
+ # Each cross-field validator is a CONSTRAINTS entry pairing the validator class
11
+ # names it carries across supported Grape versions (Grape 3.2 renamed one)
12
+ # with a note strategy. A strategy receives every group of its kind found on the
13
+ # route (each group an array of top-level attribute-name strings) and returns
14
+ # `{ param => note }`.
15
+ #
16
+ # The validations read and the Grape 2.x/3.2 shape difference are isolated in
17
+ # GrapeCompat.
18
+
19
+ module Gitlab
20
+ module GrapeOpenapi
21
+ module Converters
22
+ class CrossFieldValidationResolver
23
+ GROUP_SENTENCE = lambda do |lead|
24
+ lambda do |groups|
25
+ groups.each_with_object({}) do |group, result|
26
+ note = "#{lead} #{group.map { |name| "`#{name}`" }.join(', ')} must be provided."
27
+
28
+ group.each { |name| result[name] = append_note(result[name], note) }
29
+ end
30
+ end
31
+ end
32
+
33
+ MUTUALLY_EXCLUSIVE = {
34
+ classes: [
35
+ 'Grape::Validations::Validators::MutualExclusionValidator', # Grape < 3.2
36
+ 'Grape::Validations::Validators::MutuallyExclusiveValidator' # Grape >= 3.2
37
+ ].freeze,
38
+ summary_format: 'At most one of %d related parameters may be provided.',
39
+ notes: lambda do |groups|
40
+ partners = Hash.new { |hash, key| hash[key] = [] }
41
+
42
+ groups.each do |group|
43
+ group.each do |name|
44
+ (group - [name]).each do |other|
45
+ partners[name] << other unless partners[name].include?(other)
46
+ end
47
+ end
48
+ end
49
+
50
+ partners.transform_values do |others|
51
+ "Mutually exclusive with #{others.map { |name| "`#{name}`" }.join(', ')}."
52
+ end
53
+ end
54
+ }.freeze
55
+
56
+ EXACTLY_ONE_OF = {
57
+ classes: [
58
+ 'Grape::Validations::Validators::ExactlyOneOfValidator' # Grape all versions
59
+ ].freeze,
60
+ summary_format: 'Exactly one of %d related parameters must be provided.',
61
+ notes: GROUP_SENTENCE.call('Exactly one of')
62
+ }.freeze
63
+
64
+ AT_LEAST_ONE_OF = {
65
+ classes: [
66
+ 'Grape::Validations::Validators::AtLeastOneOfValidator' # Grape all versions
67
+ ].freeze,
68
+ summary_format: 'At least one of %d related parameters must be provided.',
69
+ notes: GROUP_SENTENCE.call('At least one of')
70
+ }.freeze
71
+
72
+ CONSTRAINTS = [MUTUALLY_EXCLUSIVE, EXACTLY_ONE_OF, AT_LEAST_ONE_OF].freeze
73
+
74
+ # For callers that need notes alone, which is every caller that has no
75
+ # nested scope to skip - see `#notes`.
76
+ def self.notes_for(route, attributes)
77
+ new(route, attributes).notes
78
+ end
79
+
80
+ # Appends a note to an existing description so the two read as separate
81
+ # sentences. Returns the note alone when there is no
82
+ # existing text.
83
+ def self.append_note(description, note)
84
+ text = description.to_s.strip
85
+ return note if text.empty?
86
+
87
+ text += '.' unless text.end_with?('.', '!', '?')
88
+ "#{text} #{note}"
89
+ end
90
+
91
+ # `attributes` are the param names the caller is able to annotate: a
92
+ # route's query params, or a request body's top-level properties. Both
93
+ # readers below share one walk of the route's validations, so a caller
94
+ # that needs both pays for it once.
95
+ def initialize(route, attributes)
96
+ @route = route
97
+ @attributes = attributes
98
+ end
99
+
100
+ # Per-param description notes for every cross-field constraint on the
101
+ # route, e.g. { 'files' => 'Mutually exclusive with `content`.' }.
102
+ # Notes from different constraints on the same param are
103
+ # joined. Returns {} when there are none.
104
+ def notes
105
+ @notes ||= matched_entries.each_with_object({}) do |(constraint, entries), result|
106
+ groups = annotatable_groups(entries)
107
+ next if groups.empty?
108
+
109
+ constraint[:notes].call(groups).each do |param, note|
110
+ result[param] = self.class.append_note(result[param], note)
111
+ end
112
+ end
113
+ end
114
+
115
+ # Constraints whose groups are too big to enumerate
116
+ # Adds configured custom summary or default summary for the entire group
117
+ # rather than one sentence per group member.
118
+ def oversized
119
+ @oversized ||= matched_entries.flat_map do |constraint, entries|
120
+ declared_groups(entries)
121
+ .select { |group| oversized?(group) }
122
+ .map { |group| { summary: format(constraint[:summary_format], group.length), size: group.length } }
123
+ end
124
+ end
125
+
126
+ # The groups `notes` could NOT annotate (a member isn't one of `attributes`).
127
+ def skipped
128
+ @skipped ||= matched_entries.flat_map do |_constraint, entries|
129
+ constrained_groups(entries).reject { |group| fully_known?(group) }
130
+ end.uniq
131
+ end
132
+
133
+ private
134
+
135
+ attr_reader :route, :attributes
136
+
137
+ def known_names
138
+ @known_names ||= Array(attributes).map(&:to_s)
139
+ end
140
+
141
+ # Groups all validations on the route
142
+ # Returns only validations with classes in CONSTRAINTS:
143
+ # [[MUTUALLY_EXCLUSIVE, [
144
+ # { validator_class: Grape::Validations::Validators::MutualExclusionValidator,
145
+ # full_names: ["content", "files"] }
146
+ # ]]]
147
+ def matched_entries
148
+ @matched_entries ||= begin
149
+ by_validator = GrapeCompat.all_validations(route).group_by { |entry| validator_name(entry) }
150
+
151
+ CONSTRAINTS.map do |constraint|
152
+ [constraint, by_validator.values_at(*constraint[:classes]).compact.flatten]
153
+ end
154
+ end
155
+ end
156
+
157
+ # Groups whose members are all in the known set. The exact complement of
158
+ # what `skipped` keeps, so both derive from `fully_known?` rather than
159
+ # restating the condition and risking drift.
160
+ def annotatable_groups(entries)
161
+ constrained_groups(entries).select { |group| fully_known?(group) }
162
+ end
163
+
164
+ # Groups eligible for a per-param note: declared, and small enough that
165
+ # naming every member reads well.
166
+ def constrained_groups(entries)
167
+ declared_groups(entries).reject { |group| oversized?(group) }
168
+ end
169
+
170
+ # Every distinct group a constraint declares. A single-member group
171
+ # constrains nothing - Grape accepts `mutually_exclusive :a` - so it is
172
+ # neither annotated nor reported as skipped.
173
+ def declared_groups(entries)
174
+ entries.map { |entry| group_members(entry) }.uniq.select { |group| group.length >= 2 }
175
+ end
176
+
177
+ def oversized?(group)
178
+ group.length > config.cross_field_group_limit
179
+ end
180
+
181
+ def config
182
+ @config ||= Gitlab::GrapeOpenapi.configuration
183
+ end
184
+
185
+ # Whether the caller can annotate every member of the group.
186
+ def fully_known?(group)
187
+ (group - known_names).empty?
188
+ end
189
+
190
+ # eg. => 'Grape::Validations::Validators::MutuallyExclusiveValidator'
191
+ def validator_name(entry)
192
+ entry[:validator_class]&.name
193
+ end
194
+
195
+ # Full bracketed paths
196
+ # top level eg. ["author_id", "author_username"]
197
+ # nested(nested under `not:`) eg. ["not[author_id]", "not[author_username]"]
198
+ def group_members(entry)
199
+ Array(entry[:full_names]).map(&:to_s)
200
+ end
201
+ end
202
+ end
203
+ end
204
+ end
@@ -4,6 +4,8 @@ module Gitlab
4
4
  module GrapeOpenapi
5
5
  module Converters
6
6
  class EntityConverter
7
+ include Concerns::Serializable
8
+
7
9
  attr_reader :entity_class, :schema_registry
8
10
 
9
11
  OBJECT_TYPE = 'object'
@@ -179,8 +181,8 @@ module Gitlab
179
181
 
180
182
  TypeResolver.union_schema(members).merge(
181
183
  description: documentation[:desc],
182
- default: default_value,
183
- example: documentation[:example]
184
+ default: stringify_symbols(default_value),
185
+ example: stringify_symbols(documentation[:example])
184
186
  )
185
187
  end
186
188
 
@@ -193,8 +195,8 @@ module Gitlab
193
195
  description: documentation[:desc],
194
196
  format: TypeResolver.resolve_format(documentation[:format], actual_type),
195
197
  enum: enum_values(documentation),
196
- default: default_value,
197
- example: documentation[:example]
198
+ default: stringify_symbols(default_value),
199
+ example: stringify_symbols(documentation[:example])
198
200
  }
199
201
  end
200
202
 
@@ -206,7 +208,7 @@ module Gitlab
206
208
  values = documentation[:values]
207
209
  return unless values.is_a?(Array) && values.any?
208
210
 
209
- values.map { |value| value.is_a?(Symbol) ? value.to_s : value }
211
+ stringify_symbols(values)
210
212
  end
211
213
 
212
214
  def build_type_schema(type, documentation)
@@ -41,7 +41,7 @@ module Gitlab
41
41
  Models::Operation.new.tap do |operation|
42
42
  operation.operation_id = operation_id
43
43
  operation.summary = extract_description
44
- operation.description = extract_detail
44
+ operation.description = annotate_oversized_constraints(extract_detail)
45
45
  operation.tags = extract_tags
46
46
  operation.deprecated = extract_deprecated
47
47
  operation.hidden = extract_hidden
@@ -77,10 +77,10 @@ module Gitlab
77
77
  params = if options[:params].empty?
78
78
  []
79
79
  else
80
- options[:params].filter_map do |key, options|
80
+ options[:params].filter_map do |key, param_options|
81
81
  Converters::ParameterConverter.convert(
82
82
  key,
83
- options: options,
83
+ options: annotate_constraint(key, param_options),
84
84
  validations: validations_for(key.to_sym),
85
85
  route: route
86
86
  )
@@ -94,6 +94,68 @@ module Gitlab
94
94
  params.reject { |param| removed.include?(param.name.to_s) }
95
95
  end
96
96
 
97
+ def annotate_constraint(key, param_options)
98
+ note = cross_field_notes[key.to_s]
99
+ return param_options unless note
100
+
101
+ param_options.merge(desc: Converters::CrossFieldValidationResolver.append_note(param_options[:desc], note))
102
+ end
103
+
104
+ # Per-param cross-field constraint notes to fold into descriptions, e.g.
105
+ # { 'author_id' => 'Mutually exclusive with `author_username`.' }.
106
+ # Keyed by the full bracketed param name, so a nested-Hash query filter
107
+ # (`not[author_id]`, which Grape flattens into `options[:params]`) is
108
+ # annotated the same way a top-level param is.
109
+ def cross_field_notes
110
+ cross_field_resolver.notes
111
+ end
112
+
113
+ # Built over every declared param, body ones included, so an oversized
114
+ # group is found even when its members document a request body.
115
+ def cross_field_resolver
116
+ @cross_field_resolver ||=
117
+ Converters::CrossFieldValidationResolver.new(route, options[:params].keys.map(&:to_s))
118
+ end
119
+
120
+ # A group too large to enumerate is stated once on the operation rather
121
+ # than on every member.
122
+ def annotate_oversized_constraints(description)
123
+ oversized = cross_field_resolver.oversized
124
+ return description if oversized.empty?
125
+
126
+ message = configured_message
127
+ report_oversized_constraints(oversized) unless message
128
+ sentences = message ? [message] : oversized.map { |group| group[:summary] }.uniq
129
+
130
+ sentences.reduce(description) do |text, sentence|
131
+ Converters::CrossFieldValidationResolver.append_note(text, sentence)
132
+ end
133
+ end
134
+
135
+ # A blank entry counts as absent. Left as-is it would satisfy the
136
+ # `unless message` guard, silencing the sentence and the warning together
137
+ # and leaving the constraint undocumented with nothing to show for it.
138
+ def configured_message
139
+ message = config.cross_field_messages[endpoint_key]
140
+ message unless message.to_s.strip.empty?
141
+ end
142
+
143
+ def report_oversized_constraints(oversized)
144
+ return unless config.warnings
145
+
146
+ oversized.each do |group|
147
+ warn "[gitlab-grape-openapi] oversized cross-field constraint: " \
148
+ "#{endpoint_key} params=#{group[:size]} " \
149
+ "(limit #{config.cross_field_group_limit}, using generic sentence)"
150
+ end
151
+ end
152
+
153
+ # Keyed on the route's own path rather than `normalized_path`. Matches the
154
+ # string the warnings print, so stderr says what to put in the config.
155
+ def endpoint_key
156
+ "#{http_method} #{normalize_path_pattern}"
157
+ end
158
+
97
159
  def inject_missing_path_parameters(params)
98
160
  declared_names = params.map(&:name).to_set
99
161
 
@@ -218,9 +280,7 @@ module Gitlab
218
280
  end
219
281
 
220
282
  def normalize_path_pattern
221
- NormalizedPath.new(pattern.origin).to_s
222
- .gsub(/[()\\]/, '')
223
- .gsub('{version}', config.api_version)
283
+ NormalizedPath.new(pattern.origin).to_display_path(config.api_version)
224
284
  end
225
285
 
226
286
  def camelize(string)
@@ -9,6 +9,7 @@ module Gitlab
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
 
@@ -57,7 +58,7 @@ module Gitlab
57
58
  build_basic_schema(object_type, object_format)
58
59
  end
59
60
 
60
- apply_allow_blank(built_schema)
61
+ apply_nullability!(built_schema, options, in_value: in_value)
61
62
  apply_limit!(built_schema, validations)
62
63
  apply_array_enum!(built_schema, options[:values])
63
64
  apply_default!(built_schema, options[:default], example: example)
@@ -91,7 +92,7 @@ module Gitlab
91
92
 
92
93
  def build_enum_schema(object_type)
93
94
  schema = { type: object_type }
94
- schema[:enum] = options[:values] unless options[:values].is_a?(Proc)
95
+ schema[:enum] = stringify_symbols(options[:values]) unless options[:values].is_a?(Proc)
95
96
  schema
96
97
  end
97
98
 
@@ -203,24 +204,6 @@ module Gitlab
203
204
 
204
205
  super
205
206
  end
206
-
207
- # allow_blank defaults to true
208
- # when `allow_blank: false` for a string type minLength should be set to 1
209
- # when param is required and values option used, the param is not nullable
210
- def apply_allow_blank(schema)
211
- union_members = schema[:oneOf] || schema[:anyOf]
212
-
213
- if options[:allow_blank] == false || (options[:required] && options[:values])
214
- schema[:minLength] = 1 if schema[:type] == 'string'
215
- elsif in_value != 'path'
216
- # path parameters are never nullable because they are required URL segments
217
- if union_members
218
- union_members.each { |s| s[:nullable] = true }
219
- else
220
- schema[:nullable] = true
221
- end
222
- end
223
- end
224
207
  end
225
208
  end
226
209
  end
@@ -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
@@ -48,7 +48,8 @@ module Gitlab
48
48
  add_response_with_entity(
49
49
  status_code: success_code,
50
50
  description: http_status_text(success_code),
51
- entity_class: entity_class
51
+ entity_class: entity_class,
52
+ is_array: declared_is_array?
52
53
  )
53
54
  else
54
55
  success_code = infer_success_code(with_body: declared_body?)
@@ -68,7 +69,8 @@ module Gitlab
68
69
  description: entity_hash[:message] || http_status_text(status_code),
69
70
  entity_class: entity_hash[:model],
70
71
  example: entity_hash[:example],
71
- examples: entity_hash[:examples]
72
+ examples: entity_hash[:examples],
73
+ is_array: entity_hash.fetch(:is_array, declared_is_array?)
72
74
  )
73
75
  else
74
76
  status_code = entity_hash[:code] || infer_success_code(with_body: declared_body?)
@@ -99,7 +101,8 @@ module Gitlab
99
101
  description: definition[:message] || http_status_text(status_code),
100
102
  entity_class: definition[:model],
101
103
  example: definition[:example],
102
- examples: definition[:examples]
104
+ examples: definition[:examples],
105
+ is_array: definition.fetch(:is_array, declared_is_array?)
103
106
  )
104
107
  else
105
108
  status_code = definition[:code] || infer_success_code(with_body: declared_body?)
@@ -132,13 +135,15 @@ module Gitlab
132
135
  end
133
136
  end
134
137
 
135
- def add_response_with_entity(status_code:, description:, entity_class:, example: nil, examples: nil)
138
+ def add_response_with_entity(
139
+ status_code:, description:, entity_class:, example: nil, examples: nil, is_array: false)
136
140
  response = Models::Response.new(
137
141
  status_code: status_code,
138
142
  description: description,
139
143
  entity_class: entity_class,
140
144
  example: example,
141
- examples: examples
145
+ examples: examples,
146
+ is_array: is_array
142
147
  )
143
148
 
144
149
  @responses[response.status_code] = response.to_h(@schema_registry)
@@ -188,6 +193,14 @@ module Gitlab
188
193
  content unless content.empty?
189
194
  end
190
195
 
196
+ # `is_array` can be declared standalone in the `desc` block (the common
197
+ # form) instead of inline in the `success`/`entity` hash, in which case
198
+ # it lands next to `produces` rather than in the success declaration.
199
+ # Inline keys take precedence over this fallback.
200
+ def declared_is_array?
201
+ !!@route.settings.dig(:description, :is_array)
202
+ end
203
+
191
204
  # An explicit `produces` overrides `success File`, matching grape-swagger existing behaviour.
192
205
  def declared_media_types
193
206
  produces = MediaTypeResolver.normalize(@route.settings.dig(:description, :produces))
@@ -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,6 +21,16 @@ 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
+
28
34
  # The `desc:` an author writes on `route_param`. Grape forwards only `type:` into
29
35
  # the `requires` it declares internally and discards the rest, so the description
30
36
  # never reaches the route's params. It survives on the namespace whose space is
@@ -43,8 +49,21 @@ module Gitlab
43
49
 
44
50
  private
45
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
+
46
62
  def normalize(validation)
47
- 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
48
67
 
49
68
  # Grape 3.2's ContractScopeValidator declares no attributes, so it maps
50
69
  # to no parameter.
@@ -57,9 +76,26 @@ module Gitlab
57
76
  # carry regexp patterns and limits. Switch to the reader if one is
58
77
  # added upstream.
59
78
  options: validation.instance_variable_get(:@options),
60
- 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)
61
84
  }
62
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
63
99
  end
64
100
  end
65
101
  end
@@ -4,6 +4,8 @@ module Gitlab
4
4
  module GrapeOpenapi
5
5
  module Models
6
6
  class Parameter
7
+ include Concerns::Serializable
8
+
7
9
  # https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.0.md#parameter-object
8
10
  attr_reader :name, :required, :in_value, :description, :options, :schema, :example
9
11
  attr_accessor :style, :explode
@@ -17,7 +19,7 @@ module Gitlab
17
19
  @description = options[:desc]
18
20
  @schema = schema
19
21
  @in_value = in_value
20
- @example = options.dig(:documentation, :example)
22
+ @example = stringify_symbols(options.dig(:documentation, :example))
21
23
  @default = options.dig(:documentation, :default)
22
24
  @style = nil
23
25
  @explode = nil
@@ -10,6 +10,7 @@ module Gitlab
10
10
  include Concerns::LimitResolver
11
11
  include Concerns::FailFastAnnotatable
12
12
  include Concerns::RegexConverter
13
+ include Concerns::Nullability
13
14
 
14
15
  def initialize(route:, key:, param_options:)
15
16
  @route = route
@@ -29,7 +30,7 @@ module Gitlab
29
30
  built_schema = build_resolved_schema(object_type, object_format)
30
31
  end
31
32
 
32
- apply_allow_blank(built_schema)
33
+ apply_nullability!(built_schema, param_options)
33
34
  apply_limit!(built_schema, validations)
34
35
  apply_array_enum!(built_schema, param_options[:values])
35
36
  apply_default!(built_schema, param_options[:default])
@@ -128,7 +129,7 @@ module Gitlab
128
129
 
129
130
  def build_enum_schema(object_type)
130
131
  schema = { type: object_type }
131
- schema[:enum] = param_options[:values] unless param_options[:values].is_a?(Proc)
132
+ schema[:enum] = stringify_symbols(param_options[:values]) unless param_options[:values].is_a?(Proc)
132
133
  schema[:description] = annotated_description if param_options[:desc]
133
134
  schema
134
135
  end
@@ -202,7 +203,7 @@ module Gitlab
202
203
  schema[:description] = annotated_description if param_options[:desc]
203
204
 
204
205
  if param_options.dig(:documentation, :example)
205
- schema[:example] = param_options.dig(:documentation, :example)
206
+ schema[:example] = stringify_symbols(param_options.dig(:documentation, :example))
206
207
  end
207
208
 
208
209
  # Add regex validations
@@ -221,18 +222,6 @@ module Gitlab
221
222
  def validations_for(attribute)
222
223
  GrapeCompat.validations_for(route, attribute)
223
224
  end
224
-
225
- def apply_allow_blank(schema)
226
- union_members = schema[:oneOf] || schema[:anyOf]
227
-
228
- if param_options[:allow_blank] == false || (param_options[:required] && param_options[:values])
229
- schema[:minLength] = 1 if schema[:type] == 'string'
230
- elsif union_members
231
- union_members.each { |s| s[:nullable] = true }
232
- else
233
- schema[:nullable] = true
234
- end
235
- end
236
225
  end
237
226
  end
238
227
  end
@@ -9,7 +9,7 @@ module Gitlab
9
9
 
10
10
  def initialize(
11
11
  status_code:, description:, entity_class:, headers: {}, content_type: 'application/json',
12
- example: nil, examples: nil)
12
+ example: nil, examples: nil, is_array: false)
13
13
  @status_code = status_code.to_s
14
14
  @description = description
15
15
  @entity_class = entity_class
@@ -17,6 +17,7 @@ module Gitlab
17
17
  @content_type = content_type
18
18
  @example = example
19
19
  @examples = examples
20
+ @is_array = is_array
20
21
  end
21
22
 
22
23
  def to_h(schema_registry)
@@ -24,7 +25,7 @@ module Gitlab
24
25
  description: description,
25
26
  content: {
26
27
  content_type => {
27
- schema: { '$ref' => schema_ref(schema_registry) },
28
+ schema: schema(schema_registry),
28
29
  example: @example,
29
30
  examples: @examples
30
31
  }.compact
@@ -38,6 +39,15 @@ module Gitlab
38
39
 
39
40
  private
40
41
 
42
+ # `is_array: true` on a `success`/`entity` declaration means the route
43
+ # renders a collection of the entity, not a single instance.
44
+ def schema(schema_registry)
45
+ reference = { '$ref' => schema_ref(schema_registry) }
46
+ return reference unless @is_array
47
+
48
+ { type: 'array', items: reference }
49
+ end
50
+
41
51
  def schema_ref(schema_registry)
42
52
  normalized_name = schema_registry.register(entity_class, nil)
43
53
  "#/components/schemas/#{normalized_name}"
@@ -21,6 +21,11 @@ module Gitlab
21
21
  PLACEHOLDER = /[:*](\w+)/
22
22
  NORMALIZED_PLACEHOLDER = /\{(\w+)\}/
23
23
 
24
+ # Grape's optional-segment markup: the parentheses grouping an optional
25
+ # segment, and the backslashes escaping a literal parenthesis. Both are
26
+ # noise once a path is rendered for a human.
27
+ OPTIONAL_SEGMENT_MARKUP = /[()\\]/
28
+
24
29
  # The API version is substituted away with the configured value before a path
25
30
  # is emitted, so it never surfaces as a path parameter.
26
31
  API_VERSION_PLACEHOLDER = 'version'
@@ -40,6 +45,13 @@ module Gitlab
40
45
  .gsub(PLACEHOLDER) { "{#{Regexp.last_match(1)}}" }
41
46
  end
42
47
 
48
+ # The path as shown to humans in emitted paths and warnings: placeholders
49
+ # collapsed (via `to_s`), optional-segment markup removed, and the API
50
+ # version substituted in for `{version}`.
51
+ def to_display_path(api_version)
52
+ to_s.gsub(OPTIONAL_SEGMENT_MARKUP, '').gsub("{#{API_VERSION_PLACEHOLDER}}", api_version)
53
+ end
54
+
43
55
  def placeholder_names
44
56
  # Scanning whole `{name}` placeholders sidesteps the boundary problem a regex
45
57
  # over the raw pattern has: real routes introduce a placeholder after `/`,
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Gitlab
4
4
  module GrapeOpenapi
5
- VERSION = "0.4.0"
5
+ VERSION = "0.6.0"
6
6
  end
7
7
  end
@@ -15,12 +15,14 @@ require_relative "gitlab/grape_openapi/concerns/constraint_applier"
15
15
  require_relative "gitlab/grape_openapi/concerns/limit_resolver"
16
16
  require_relative "gitlab/grape_openapi/concerns/fail_fast_annotatable"
17
17
  require_relative "gitlab/grape_openapi/concerns/regex_converter"
18
+ require_relative "gitlab/grape_openapi/concerns/nullability"
18
19
 
19
20
  # Serializers
20
21
  require_relative "gitlab/grape_openapi/serializers/time"
21
22
 
22
23
  # Converters
23
24
  require_relative "gitlab/grape_openapi/converters/coercer_resolver"
25
+ require_relative "gitlab/grape_openapi/converters/cross_field_validation_resolver"
24
26
  require_relative "gitlab/grape_openapi/converters/entity_converter"
25
27
  require_relative "gitlab/grape_openapi/converters/media_type_resolver"
26
28
  require_relative "gitlab/grape_openapi/converters/type_resolver"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gitlab-grape-openapi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - group::api
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-19 00:00:00.000000000 Z
11
+ date: 2026-09-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: grape
@@ -142,10 +142,12 @@ files:
142
142
  - lib/gitlab/grape_openapi/concerns/constraint_applier.rb
143
143
  - lib/gitlab/grape_openapi/concerns/fail_fast_annotatable.rb
144
144
  - lib/gitlab/grape_openapi/concerns/limit_resolver.rb
145
+ - lib/gitlab/grape_openapi/concerns/nullability.rb
145
146
  - lib/gitlab/grape_openapi/concerns/regex_converter.rb
146
147
  - lib/gitlab/grape_openapi/concerns/serializable.rb
147
148
  - lib/gitlab/grape_openapi/configuration.rb
148
149
  - lib/gitlab/grape_openapi/converters/coercer_resolver.rb
150
+ - lib/gitlab/grape_openapi/converters/cross_field_validation_resolver.rb
149
151
  - lib/gitlab/grape_openapi/converters/entity_converter.rb
150
152
  - lib/gitlab/grape_openapi/converters/media_type_resolver.rb
151
153
  - lib/gitlab/grape_openapi/converters/operation_converter.rb