gitlab-grape-openapi 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +84 -12
- data/lib/gitlab/grape_openapi/concerns/constraint_applier.rb +83 -0
- data/lib/gitlab/grape_openapi/concerns/nullability.rb +53 -0
- data/lib/gitlab/grape_openapi/converters/cross_field_validation_resolver.rb +152 -0
- data/lib/gitlab/grape_openapi/converters/entity_converter.rb +4 -3
- data/lib/gitlab/grape_openapi/converters/media_type_resolver.rb +54 -0
- data/lib/gitlab/grape_openapi/converters/operation_converter.rb +21 -9
- data/lib/gitlab/grape_openapi/converters/parameter_converter.rb +31 -73
- data/lib/gitlab/grape_openapi/converters/path_converter.rb +40 -18
- data/lib/gitlab/grape_openapi/converters/request_body_converter.rb +39 -0
- data/lib/gitlab/grape_openapi/converters/response_converter.rb +73 -14
- data/lib/gitlab/grape_openapi/converters/type_resolver.rb +17 -1
- data/lib/gitlab/grape_openapi/grape_compat.rb +59 -7
- data/lib/gitlab/grape_openapi/models/request_body/parameter_schema.rb +9 -25
- data/lib/gitlab/grape_openapi/models/request_body/parameters.rb +16 -4
- data/lib/gitlab/grape_openapi/normalized_path.rb +68 -0
- data/lib/gitlab/grape_openapi/request_body_registry.rb +1 -1
- data/lib/gitlab/grape_openapi/version.rb +1 -1
- data/lib/gitlab-grape-openapi.rb +5 -0
- metadata +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3c0d95f2bff47a6ef1c1c9d251b48b4e12748f8348b112a408cf94c97d62b6d3
|
|
4
|
+
data.tar.gz: 166affd62ad738cdd13b2c414bb131fd9065506c18b3dee7e32dc861ad23e3fd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a8695acf55cddb13dc6ad5078a2ea7fca4e251abe152f9c45b9457018bc47eda0708ff7a9e018713cf6ba6ac77532d65368a31112cc4cb1d4b61a9be9d1c9f80
|
|
7
|
+
data.tar.gz: 7de023af2bf2810e32513a09b2ea6c2520ea1b1d4a46be61f5ff6acf84637dfe6d1ab922bb51a0b223186f0baa3530ad9b19b229eca8c535e63f3e25c1936ee0
|
data/README.md
CHANGED
|
@@ -93,17 +93,17 @@ end
|
|
|
93
93
|
|
|
94
94
|
### Configuration Options
|
|
95
95
|
|
|
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
|
|
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 params, skipped constraints |
|
|
107
107
|
|
|
108
108
|
### Annotations
|
|
109
109
|
|
|
@@ -185,15 +185,87 @@ Generator
|
|
|
185
185
|
│ ├── ParameterConverter - Converts endpoint parameters
|
|
186
186
|
│ ├── ResponseConverter - Converts endpoint responses
|
|
187
187
|
│ └── RequestBodyConverter - Converts request bodies
|
|
188
|
-
|
|
188
|
+
├── MediaTypeResolver - Maps declared media types to response schemas
|
|
189
|
+
├── TypeResolver - Maps Ruby/Grape types to OpenAPI types
|
|
190
|
+
└── CrossFieldValidationResolver - Documents Grape cross-field validations in descriptions
|
|
189
191
|
```
|
|
190
192
|
|
|
193
|
+
### Parameter constraints
|
|
194
|
+
|
|
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`).
|
|
199
|
+
|
|
200
|
+
OpenAPI 3.0 has no cross-parameter constraint keyword, and the target renderer
|
|
201
|
+
(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.
|
|
204
|
+
|
|
205
|
+
The gem reads the validation stack through `grape_compat`, so this works on both
|
|
206
|
+
Grape 2.4 and 3.2. Each group is keyed by the full bracketed param path
|
|
207
|
+
(`not[author_id]`) rather than the bare name. Because Grape flattens every nested
|
|
208
|
+
param into the route's query params, a constraint nested inside a `Hash` *query*
|
|
209
|
+
filter is documented the same as a top-level one. A constraint nested inside a
|
|
210
|
+
request-body object property has no top-level property to attach to and is
|
|
211
|
+
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.
|
|
214
|
+
|
|
191
215
|
### Registries
|
|
192
216
|
|
|
193
217
|
- **SchemaRegistry** - Tracks converted entity schemas
|
|
194
218
|
- **RequestBodyRegistry** - Tracks request body schemas
|
|
195
219
|
- **TagRegistry** - Tracks API tags
|
|
196
220
|
|
|
221
|
+
### Response media types
|
|
222
|
+
|
|
223
|
+
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:
|
|
226
|
+
|
|
227
|
+
```ruby
|
|
228
|
+
desc 'Download an export' do
|
|
229
|
+
produces %w[application/octet-stream]
|
|
230
|
+
success code: 200
|
|
231
|
+
end
|
|
232
|
+
get ':id/export/download' do
|
|
233
|
+
# ...
|
|
234
|
+
end
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
which becomes:
|
|
238
|
+
|
|
239
|
+
```yaml
|
|
240
|
+
'200':
|
|
241
|
+
description: OK
|
|
242
|
+
content:
|
|
243
|
+
application/octet-stream:
|
|
244
|
+
schema:
|
|
245
|
+
type: string
|
|
246
|
+
format: binary
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
`produces` accepts a bare String as well as an Array, and each declared type
|
|
250
|
+
gets its own entry in `content`. The mapping is:
|
|
251
|
+
|
|
252
|
+
| Declared media type | Emitted schema |
|
|
253
|
+
| ------------------- | -------------- |
|
|
254
|
+
| `application/octet-stream`, `application/gzip`, `application/x-tar` | `{ type: string, format: binary }` |
|
|
255
|
+
| `text/*`, `application/yaml` | `{ type: string }` |
|
|
256
|
+
| `application/json` | none — the entity `$ref` describes it |
|
|
257
|
+
|
|
258
|
+
A type outside this table is **skipped** rather than guessed at, since assuming
|
|
259
|
+
"binary" would misdescribe a structured payload such as `application/xml`. The
|
|
260
|
+
response keeps its bare shape, so a new media type needs adding to
|
|
261
|
+
`MediaTypeResolver` before it appears in the spec.
|
|
262
|
+
|
|
263
|
+
`produces` only affects the success response; failure responses stay JSON.
|
|
264
|
+
|
|
265
|
+
A route that declares `success File` (with no `produces`) is treated as
|
|
266
|
+
`application/octet-stream`, matching `grape-swagger`. An explicit `produces`
|
|
267
|
+
takes precedence, so it can override that inference.
|
|
268
|
+
|
|
197
269
|
### Optional path segments
|
|
198
270
|
|
|
199
271
|
Grape lets a route mark a path segment as optional with parentheses, e.g.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Applies the `values:` and `default:` parameter constraints to an already-built schema.
|
|
4
|
+
|
|
5
|
+
module Gitlab
|
|
6
|
+
module GrapeOpenapi
|
|
7
|
+
module Concerns
|
|
8
|
+
module ConstraintApplier
|
|
9
|
+
include Serializable
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
# values: on an array-typed parameter constrains each element, so it belongs
|
|
14
|
+
# on items.enum rather than on the array schema itself.
|
|
15
|
+
def apply_array_enum!(schema, values)
|
|
16
|
+
return unless schema[:type] == 'array' && schema[:items].is_a?(Hash)
|
|
17
|
+
return unless values.is_a?(Array)
|
|
18
|
+
|
|
19
|
+
schema[:items][:enum] = values
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def apply_default!(schema, default, example: nil)
|
|
23
|
+
return if default.nil?
|
|
24
|
+
|
|
25
|
+
members = schema[:oneOf] || schema[:anyOf]
|
|
26
|
+
return apply_union_default!(members, default) if members
|
|
27
|
+
|
|
28
|
+
value = resolve_default(default, example: example)
|
|
29
|
+
schema[:default] = value unless value.nil?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# `example` is unused here; ParameterConverter overrides this to feed it to its time serializer.
|
|
33
|
+
def resolve_default(default, example: nil) # rubocop:disable Lint/UnusedMethodArgument -- overridden
|
|
34
|
+
return unless serializable?(default)
|
|
35
|
+
|
|
36
|
+
default
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# When a union (oneOf) schema has a `default:`, attach it to every member
|
|
40
|
+
# whose schema can accept the default value. For arrays this includes
|
|
41
|
+
# checking the items type so `[1, 2]` lands on `items: { type: integer }`
|
|
42
|
+
# but not on `items: { type: string }`. Empty arrays are type-agnostic
|
|
43
|
+
# and attach to all array members.
|
|
44
|
+
def apply_union_default!(members, default)
|
|
45
|
+
return unless serializable?(default)
|
|
46
|
+
|
|
47
|
+
members.each do |member|
|
|
48
|
+
member[:default] = default if member_accepts_default?(member, default)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def member_accepts_default?(member, default)
|
|
53
|
+
case member[:type]
|
|
54
|
+
when 'integer' then default.is_a?(Integer)
|
|
55
|
+
when 'number' then default.is_a?(Numeric)
|
|
56
|
+
when 'boolean' then [true, false].include?(default)
|
|
57
|
+
when 'string' then default.is_a?(String) || default.is_a?(Symbol)
|
|
58
|
+
when 'array' then array_member_accepts?(member, default)
|
|
59
|
+
when 'object' then default.is_a?(Hash)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def array_member_accepts?(member, default)
|
|
64
|
+
return false unless default.is_a?(Array)
|
|
65
|
+
return true if default.empty?
|
|
66
|
+
|
|
67
|
+
item_type = member.dig(:items, :type)
|
|
68
|
+
default.all? { |element| openapi_type_accepts?(item_type, element) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def openapi_type_accepts?(openapi_type, value)
|
|
72
|
+
case openapi_type
|
|
73
|
+
when 'integer' then value.is_a?(Integer)
|
|
74
|
+
when 'number' then value.is_a?(Numeric)
|
|
75
|
+
when 'boolean' then [true, false].include?(value)
|
|
76
|
+
when 'string' then value.is_a?(String) || value.is_a?(Symbol)
|
|
77
|
+
else true
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -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
|
|
@@ -0,0 +1,152 @@
|
|
|
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 several)
|
|
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
|
+
MUTUALLY_EXCLUSIVE = {
|
|
24
|
+
classes: [
|
|
25
|
+
'Grape::Validations::Validators::MutualExclusionValidator', # Grape < 3.2
|
|
26
|
+
'Grape::Validations::Validators::MutuallyExclusiveValidator' # Grape >= 3.2
|
|
27
|
+
].freeze,
|
|
28
|
+
notes: lambda do |groups|
|
|
29
|
+
partners = Hash.new { |hash, key| hash[key] = [] }
|
|
30
|
+
|
|
31
|
+
groups.each do |group|
|
|
32
|
+
group.each do |name|
|
|
33
|
+
(group - [name]).each do |other|
|
|
34
|
+
partners[name] << other unless partners[name].include?(other)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
partners.transform_values do |others|
|
|
40
|
+
"Mutually exclusive with #{others.map { |name| "`#{name}`" }.join(', ')}."
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
}.freeze
|
|
44
|
+
|
|
45
|
+
CONSTRAINTS = [MUTUALLY_EXCLUSIVE].freeze
|
|
46
|
+
|
|
47
|
+
# For callers that need notes alone, which is every caller that has no
|
|
48
|
+
# nested scope to skip - see `#notes`.
|
|
49
|
+
def self.notes_for(route, attributes)
|
|
50
|
+
new(route, attributes).notes
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Appends a note to an existing description so the two read as separate
|
|
54
|
+
# sentences. Returns the note alone when there is no
|
|
55
|
+
# existing text.
|
|
56
|
+
def self.append_note(description, note)
|
|
57
|
+
text = description.to_s.strip
|
|
58
|
+
return note if text.empty?
|
|
59
|
+
|
|
60
|
+
text += '.' unless text.end_with?('.', '!', '?')
|
|
61
|
+
"#{text} #{note}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# `attributes` are the param names the caller is able to annotate: a
|
|
65
|
+
# route's query params, or a request body's top-level properties. Both
|
|
66
|
+
# readers below share one walk of the route's validations, so a caller
|
|
67
|
+
# that needs both pays for it once.
|
|
68
|
+
def initialize(route, attributes)
|
|
69
|
+
@route = route
|
|
70
|
+
@attributes = attributes
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Per-param description notes for every cross-field constraint on the
|
|
74
|
+
# route, e.g. { 'files' => 'Mutually exclusive with `content`.' }.
|
|
75
|
+
# Notes from different constraints on the same param are
|
|
76
|
+
# joined. Returns {} when there are none.
|
|
77
|
+
def notes
|
|
78
|
+
@notes ||= matched_entries.each_with_object({}) do |(constraint, entries), result|
|
|
79
|
+
groups = annotatable_groups(entries)
|
|
80
|
+
next if groups.empty?
|
|
81
|
+
|
|
82
|
+
constraint[:notes].call(groups).each do |param, note|
|
|
83
|
+
result[param] = self.class.append_note(result[param], note)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# The groups `notes` could NOT annotate (a member isn't one of `attributes`).
|
|
89
|
+
def skipped
|
|
90
|
+
@skipped ||= matched_entries.flat_map do |_constraint, entries|
|
|
91
|
+
constrained_groups(entries).reject { |group| fully_known?(group) }
|
|
92
|
+
end.uniq
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
attr_reader :route, :attributes
|
|
98
|
+
|
|
99
|
+
def known_names
|
|
100
|
+
@known_names ||= Array(attributes).map(&:to_s)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Groups all validations on the route
|
|
104
|
+
# Returns only validations with classes in CONSTRAINTS:
|
|
105
|
+
# [[MUTUALLY_EXCLUSIVE, [
|
|
106
|
+
# { validator_class: Grape::Validations::Validators::MutualExclusionValidator,
|
|
107
|
+
# full_names: ["content", "files"] }
|
|
108
|
+
# ]]]
|
|
109
|
+
def matched_entries
|
|
110
|
+
@matched_entries ||= begin
|
|
111
|
+
by_validator = GrapeCompat.all_validations(route).group_by { |entry| validator_name(entry) }
|
|
112
|
+
|
|
113
|
+
CONSTRAINTS.map do |constraint|
|
|
114
|
+
[constraint, by_validator.values_at(*constraint[:classes]).compact.flatten]
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Groups whose members are all in the known set. The exact complement of
|
|
120
|
+
# what `skipped` keeps, so both derive from `fully_known?` rather than
|
|
121
|
+
# restating the condition and risking drift.
|
|
122
|
+
def annotatable_groups(entries)
|
|
123
|
+
constrained_groups(entries).select { |group| fully_known?(group) }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Every distinct group a constraint declares. A single-member group
|
|
127
|
+
# constrains nothing - Grape accepts `mutually_exclusive :a` - so it is
|
|
128
|
+
# neither annotated nor reported as skipped.
|
|
129
|
+
def constrained_groups(entries)
|
|
130
|
+
entries.map { |entry| group_members(entry) }.uniq.select { |group| group.length >= 2 }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Whether the caller can annotate every member of the group.
|
|
134
|
+
def fully_known?(group)
|
|
135
|
+
(group - known_names).empty?
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# eg. => 'Grape::Validations::Validators::MutuallyExclusiveValidator'
|
|
139
|
+
def validator_name(entry)
|
|
140
|
+
entry[:validator_class]&.name
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Full bracketed paths
|
|
144
|
+
# top level eg. ["author_id", "author_username"]
|
|
145
|
+
# nested(nested under `not:`) eg. ["not[author_id]", "not[author_username]"]
|
|
146
|
+
def group_members(entry)
|
|
147
|
+
Array(entry[:full_names]).map(&:to_s)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -175,12 +175,13 @@ module Gitlab
|
|
|
175
175
|
end
|
|
176
176
|
|
|
177
177
|
def build_one_of_property(types, documentation, default_value)
|
|
178
|
-
{
|
|
179
|
-
|
|
178
|
+
members = types.map { |type| build_type_schema(type, documentation) }
|
|
179
|
+
|
|
180
|
+
TypeResolver.union_schema(members).merge(
|
|
180
181
|
description: documentation[:desc],
|
|
181
182
|
default: default_value,
|
|
182
183
|
example: documentation[:example]
|
|
183
|
-
|
|
184
|
+
)
|
|
184
185
|
end
|
|
185
186
|
|
|
186
187
|
def build_single_type_property(type, documentation, default_value)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gitlab
|
|
4
|
+
module GrapeOpenapi
|
|
5
|
+
module Converters
|
|
6
|
+
# Maps the media types a route declares via `produces` to the OpenAPI
|
|
7
|
+
# schema describing that response body.
|
|
8
|
+
#
|
|
9
|
+
# Media types the gem cannot describe resolve to `nil` so callers skip
|
|
10
|
+
# them instead of guessing.
|
|
11
|
+
class MediaTypeResolver
|
|
12
|
+
BINARY_SCHEMA = { type: 'string', format: 'binary' }.freeze
|
|
13
|
+
TEXT_SCHEMA = { type: 'string' }.freeze
|
|
14
|
+
|
|
15
|
+
OCTET_STREAM_MEDIA_TYPE = 'application/octet-stream'
|
|
16
|
+
|
|
17
|
+
BINARY_MEDIA_TYPES = [
|
|
18
|
+
'application/gzip',
|
|
19
|
+
OCTET_STREAM_MEDIA_TYPE,
|
|
20
|
+
'application/x-tar'
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
TEXT_MEDIA_TYPES = %w[application/yaml].freeze
|
|
24
|
+
|
|
25
|
+
TEXT_PREFIX = 'text/'
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
# `produces` accepts a bare String as well as an Array, and a declared
|
|
29
|
+
# type may carry parameters (`text/csv; charset=utf-8`) which are not
|
|
30
|
+
# part of an OpenAPI content key.
|
|
31
|
+
#
|
|
32
|
+
# Downcased because type and subtype are case-insensitive (RFC 6838),
|
|
33
|
+
# so the tables below can match on one spelling and the emitted content
|
|
34
|
+
# key stays canonical.
|
|
35
|
+
def normalize(declaration)
|
|
36
|
+
Array(declaration).filter_map do |media_type|
|
|
37
|
+
normalized = media_type.to_s.split(';').first&.strip&.downcase
|
|
38
|
+
normalized unless normalized.nil? || normalized.empty?
|
|
39
|
+
end.uniq
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# `application/json` is handled by the entity `$ref` path
|
|
43
|
+
# unrecognized types return nil rather than guessing the type
|
|
44
|
+
def schema_for(media_type)
|
|
45
|
+
return BINARY_SCHEMA if BINARY_MEDIA_TYPES.include?(media_type)
|
|
46
|
+
return TEXT_SCHEMA if TEXT_MEDIA_TYPES.include?(media_type) || media_type.start_with?(TEXT_PREFIX)
|
|
47
|
+
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -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,23 @@ 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_notes ||=
|
|
111
|
+
Converters::CrossFieldValidationResolver.notes_for(route, options[:params].keys.map(&:to_s))
|
|
112
|
+
end
|
|
113
|
+
|
|
97
114
|
def inject_missing_path_parameters(params)
|
|
98
115
|
declared_names = params.map(&:name).to_set
|
|
99
116
|
|
|
@@ -214,16 +231,11 @@ module Gitlab
|
|
|
214
231
|
end
|
|
215
232
|
|
|
216
233
|
def normalized_path
|
|
217
|
-
@normalized_path ||= path_override || normalize_path_pattern
|
|
234
|
+
@normalized_path ||= path_override || normalize_path_pattern
|
|
218
235
|
end
|
|
219
236
|
|
|
220
237
|
def normalize_path_pattern
|
|
221
|
-
|
|
222
|
-
path
|
|
223
|
-
.gsub(/\(\.:format\)$/, '')
|
|
224
|
-
.gsub(/[()\\]/, '')
|
|
225
|
-
.gsub(/:\w+/) { |match| "{#{match[1..]}}" }
|
|
226
|
-
.gsub('{version}', config.api_version)
|
|
238
|
+
NormalizedPath.new(pattern.origin).to_display_path(config.api_version)
|
|
227
239
|
end
|
|
228
240
|
|
|
229
241
|
def camelize(string)
|