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 +4 -4
- data/README.md +84 -14
- data/lib/gitlab/grape_openapi/concerns/constraint_applier.rb +3 -3
- data/lib/gitlab/grape_openapi/concerns/nullability.rb +53 -0
- 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 +204 -0
- data/lib/gitlab/grape_openapi/converters/entity_converter.rb +7 -5
- data/lib/gitlab/grape_openapi/converters/operation_converter.rb +66 -6
- data/lib/gitlab/grape_openapi/converters/parameter_converter.rb +3 -20
- data/lib/gitlab/grape_openapi/converters/request_body_converter.rb +39 -0
- data/lib/gitlab/grape_openapi/converters/response_converter.rb +18 -5
- data/lib/gitlab/grape_openapi/grape_compat.rb +43 -7
- data/lib/gitlab/grape_openapi/models/parameter.rb +3 -1
- data/lib/gitlab/grape_openapi/models/request_body/parameter_schema.rb +4 -15
- data/lib/gitlab/grape_openapi/models/response.rb +12 -2
- data/lib/gitlab/grape_openapi/normalized_path.rb +12 -0
- data/lib/gitlab/grape_openapi/version.rb +1 -1
- data/lib/gitlab-grape-openapi.rb +2 -0
- metadata +4 -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,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
|
|
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
|
-
|
|
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`.
|
|
202
|
-
|
|
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
|
|
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,
|
|
80
|
+
options[:params].filter_map do |key, param_options|
|
|
81
81
|
Converters::ParameterConverter.convert(
|
|
82
82
|
key,
|
|
83
|
-
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).
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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 `/`,
|
data/lib/gitlab-grape-openapi.rb
CHANGED
|
@@ -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
|
+
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-
|
|
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
|