gitlab-grape-openapi 0.4.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 +35 -12
- 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/operation_converter.rb +20 -5
- data/lib/gitlab/grape_openapi/converters/parameter_converter.rb +2 -19
- data/lib/gitlab/grape_openapi/converters/request_body_converter.rb +39 -0
- data/lib/gitlab/grape_openapi/grape_compat.rb +43 -7
- data/lib/gitlab/grape_openapi/models/request_body/parameter_schema.rb +2 -13
- 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: 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
|
|
|
@@ -186,9 +186,32 @@ Generator
|
|
|
186
186
|
│ ├── ResponseConverter - Converts endpoint responses
|
|
187
187
|
│ └── RequestBodyConverter - Converts request bodies
|
|
188
188
|
├── MediaTypeResolver - Maps declared media types to response schemas
|
|
189
|
-
|
|
189
|
+
├── TypeResolver - Maps Ruby/Grape types to OpenAPI types
|
|
190
|
+
└── CrossFieldValidationResolver - Documents Grape cross-field validations in descriptions
|
|
190
191
|
```
|
|
191
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
|
+
|
|
192
215
|
### Registries
|
|
193
216
|
|
|
194
217
|
- **SchemaRegistry** - Tracks converted entity schemas
|
|
@@ -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
|
|
@@ -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
|
|
|
@@ -218,9 +235,7 @@ module Gitlab
|
|
|
218
235
|
end
|
|
219
236
|
|
|
220
237
|
def normalize_path_pattern
|
|
221
|
-
NormalizedPath.new(pattern.origin).
|
|
222
|
-
.gsub(/[()\\]/, '')
|
|
223
|
-
.gsub('{version}', config.api_version)
|
|
238
|
+
NormalizedPath.new(pattern.origin).to_display_path(config.api_version)
|
|
224
239
|
end
|
|
225
240
|
|
|
226
241
|
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)
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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])
|
|
@@ -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
|
|
@@ -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.5.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-03 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
|