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 +4 -4
- data/README.md +57 -10
- data/lib/gitlab/grape_openapi/concerns/constraint_applier.rb +3 -3
- data/lib/gitlab/grape_openapi/concerns/serializable.rb +10 -0
- data/lib/gitlab/grape_openapi/configuration.rb +8 -1
- data/lib/gitlab/grape_openapi/converters/cross_field_validation_resolver.rb +55 -3
- data/lib/gitlab/grape_openapi/converters/entity_converter.rb +7 -5
- data/lib/gitlab/grape_openapi/converters/operation_converter.rb +48 -3
- data/lib/gitlab/grape_openapi/converters/parameter_converter.rb +1 -1
- data/lib/gitlab/grape_openapi/converters/response_converter.rb +18 -5
- data/lib/gitlab/grape_openapi/models/parameter.rb +3 -1
- data/lib/gitlab/grape_openapi/models/request_body/parameter_schema.rb +2 -2
- data/lib/gitlab/grape_openapi/models/response.rb +12 -2
- data/lib/gitlab/grape_openapi/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 10a34008541dedbab4063d199bbdb94f2d669d94101be45af1023e362bf8c72f
|
|
4
|
+
data.tar.gz: ba640eb0b0553149b7776bd53afef9eee0c2cc5316dbb4917c44c2099b138d0e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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).
|
|
203
|
-
|
|
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
|
|
213
|
-
|
|
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`.
|
|
225
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
111
|
-
|
|
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(
|
|
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:
|
|
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}"
|
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
|
+
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-
|
|
11
|
+
date: 2026-09-30 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: grape
|