gitlab-grape-openapi 0.5.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: 3c0d95f2bff47a6ef1c1c9d251b48b4e12748f8348b112a408cf94c97d62b6d3
4
- data.tar.gz: 166affd62ad738cdd13b2c414bb131fd9065506c18b3dee7e32dc861ad23e3fd
3
+ metadata.gz: 10a34008541dedbab4063d199bbdb94f2d669d94101be45af1023e362bf8c72f
4
+ data.tar.gz: ba640eb0b0553149b7776bd53afef9eee0c2cc5316dbb4917c44c2099b138d0e
5
5
  SHA512:
6
- metadata.gz: a8695acf55cddb13dc6ad5078a2ea7fca4e251abe152f9c45b9457018bc47eda0708ff7a9e018713cf6ba6ac77532d65368a31112cc4cb1d4b61a9be9d1c9f80
7
- data.tar.gz: 7de023af2bf2810e32513a09b2ea6c2520ea1b1d4a46be61f5ff6acf84637dfe6d1ab922bb51a0b223186f0baa3530ad9b19b229eca8c535e63f3e25c1936ee0
6
+ metadata.gz: fd55dfbd2de53ec38abfd182f89d2dcbf2f03b916fbde38525dd8566c5984d134b21a9ff112123a7d2d9dbec0fc8077c8bf4a04d40117c902215e4a4cb6eb52b
7
+ data.tar.gz: 4ece03be3b48a4f4082539446d72ab4ac68b8cd0a022ee125f777c7196dd2aac73b154bc98f1518ac078da6b13b26a80c0a0a4dfabada8f220d461b507202e2c
data/README.md CHANGED
@@ -88,6 +88,14 @@ 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
 
@@ -104,6 +112,8 @@ end
104
112
  | `tag_overrides` | `Hash` | `{}` | Map of tag names to their display overrides |
105
113
  | `annotations` | `Hash` | `{}` | Map of Grape route settings to OpenAPI extension names |
106
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
 
@@ -192,15 +202,37 @@ Generator
192
202
 
193
203
  ### Parameter constraints
194
204
 
195
- The gem documents Grape's `mutually_exclusive` param constraint by appending a
196
- note such as ``Mutually exclusive with `author_username`.`` to each affected
197
- parameter's (or request-body property's) `description` — for both query/path
198
- params (`GET`/`DELETE`) and request-body params (`POST`/`PUT`/`PATCH`).
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.`` |
199
215
 
200
216
  OpenAPI 3.0 has no cross-parameter constraint keyword, and the target renderer
201
217
  (Scalar) does not render JSON Schema `not`/`allOf`, so the constraint is stated
202
- in prose; Grape enforces it at runtime (HTTP 400). A param appearing in several
203
- declarations lists every partner in its note.
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.
204
236
 
205
237
  The gem reads the validation stack through `grape_compat`, so this works on both
206
238
  Grape 2.4 and 3.2. Each group is keyed by the full bracketed param path
@@ -209,8 +241,8 @@ param into the route's query params, a constraint nested inside a `Hash` *query*
209
241
  filter is documented the same as a top-level one. A constraint nested inside a
210
242
  request-body object property has no top-level property to attach to and is
211
243
  skipped; when `warnings` is enabled the gem logs one line per skipped group so it
212
- is visible in source. The sibling constraints (`exactly_one_of`,
213
- `at_least_one_of`, `all_or_none_of`) are not yet supported.
244
+ is visible in source. The remaining sibling constraint (`all_or_none_of`) is not
245
+ yet supported.
214
246
 
215
247
  ### Registries
216
248
 
@@ -221,8 +253,23 @@ is visible in source. The sibling constraints (`exactly_one_of`,
221
253
  ### Response media types
222
254
 
223
255
  Responses default to `application/json`, described by the `$ref` of the entity
224
- declared with `success` / `entity`. Endpoints that return a file or plain text
225
- 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:
226
273
 
227
274
  ```ruby
228
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
 
@@ -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
@@ -8,7 +8,7 @@
8
8
  # Grape enforces them at runtime (HTTP 400).
9
9
  #
10
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 several)
11
+ # names it carries across supported Grape versions (Grape 3.2 renamed one)
12
12
  # with a note strategy. A strategy receives every group of its kind found on the
13
13
  # route (each group an array of top-level attribute-name strings) and returns
14
14
  # `{ param => note }`.
@@ -20,11 +20,22 @@ module Gitlab
20
20
  module GrapeOpenapi
21
21
  module Converters
22
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
+
23
33
  MUTUALLY_EXCLUSIVE = {
24
34
  classes: [
25
35
  'Grape::Validations::Validators::MutualExclusionValidator', # Grape < 3.2
26
36
  'Grape::Validations::Validators::MutuallyExclusiveValidator' # Grape >= 3.2
27
37
  ].freeze,
38
+ summary_format: 'At most one of %d related parameters may be provided.',
28
39
  notes: lambda do |groups|
29
40
  partners = Hash.new { |hash, key| hash[key] = [] }
30
41
 
@@ -42,7 +53,23 @@ module Gitlab
42
53
  end
43
54
  }.freeze
44
55
 
45
- CONSTRAINTS = [MUTUALLY_EXCLUSIVE].freeze
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
46
73
 
47
74
  # For callers that need notes alone, which is every caller that has no
48
75
  # nested scope to skip - see `#notes`.
@@ -85,6 +112,17 @@ module Gitlab
85
112
  end
86
113
  end
87
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
+
88
126
  # The groups `notes` could NOT annotate (a member isn't one of `attributes`).
89
127
  def skipped
90
128
  @skipped ||= matched_entries.flat_map do |_constraint, entries|
@@ -123,13 +161,27 @@ module Gitlab
123
161
  constrained_groups(entries).select { |group| fully_known?(group) }
124
162
  end
125
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
+
126
170
  # Every distinct group a constraint declares. A single-member group
127
171
  # constrains nothing - Grape accepts `mutually_exclusive :a` - so it is
128
172
  # neither annotated nor reported as skipped.
129
- def constrained_groups(entries)
173
+ def declared_groups(entries)
130
174
  entries.map { |entry| group_members(entry) }.uniq.select { |group| group.length >= 2 }
131
175
  end
132
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
+
133
185
  # Whether the caller can annotate every member of the group.
134
186
  def fully_known?(group)
135
187
  (group - known_names).empty?
@@ -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
@@ -107,8 +107,53 @@ module Gitlab
107
107
  # (`not[author_id]`, which Grape flattens into `options[:params]`) is
108
108
  # annotated the same way a top-level param is.
109
109
  def cross_field_notes
110
- @cross_field_notes ||=
111
- Converters::CrossFieldValidationResolver.notes_for(route, options[:params].keys.map(&:to_s))
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}"
112
157
  end
113
158
 
114
159
  def inject_missing_path_parameters(params)
@@ -92,7 +92,7 @@ module Gitlab
92
92
 
93
93
  def build_enum_schema(object_type)
94
94
  schema = { type: object_type }
95
- schema[:enum] = options[:values] unless options[:values].is_a?(Proc)
95
+ schema[:enum] = stringify_symbols(options[:values]) unless options[:values].is_a?(Proc)
96
96
  schema
97
97
  end
98
98
 
@@ -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))
@@ -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
@@ -129,7 +129,7 @@ module Gitlab
129
129
 
130
130
  def build_enum_schema(object_type)
131
131
  schema = { type: object_type }
132
- 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)
133
133
  schema[:description] = annotated_description if param_options[:desc]
134
134
  schema
135
135
  end
@@ -203,7 +203,7 @@ module Gitlab
203
203
  schema[:description] = annotated_description if param_options[:desc]
204
204
 
205
205
  if param_options.dig(:documentation, :example)
206
- schema[:example] = param_options.dig(:documentation, :example)
206
+ schema[:example] = stringify_symbols(param_options.dig(:documentation, :example))
207
207
  end
208
208
 
209
209
  # Add regex validations
@@ -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}"
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Gitlab
4
4
  module GrapeOpenapi
5
- VERSION = "0.5.0"
5
+ VERSION = "0.6.0"
6
6
  end
7
7
  end
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.5.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-09-03 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