gitlab-grape-openapi 0.3.0 → 0.4.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 +49 -0
- data/lib/gitlab/grape_openapi/concerns/constraint_applier.rb +83 -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 +2 -5
- data/lib/gitlab/grape_openapi/converters/parameter_converter.rb +33 -58
- data/lib/gitlab/grape_openapi/converters/path_converter.rb +40 -18
- 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 +16 -0
- data/lib/gitlab/grape_openapi/models/request_body/parameter_schema.rb +11 -16
- data/lib/gitlab/grape_openapi/models/request_body/parameters.rb +16 -4
- data/lib/gitlab/grape_openapi/normalized_path.rb +56 -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 +3 -0
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0abbc799f3af1d884ee9e543865658f3709007fe0cb09febea2792678c09d071
|
|
4
|
+
data.tar.gz: 815581f530dbedff8b9cd89135507640df91e58435f42a62c78bd1d213388d46
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2574c73242cbbb597331b2919a05ced49beba5dbe733683d88cf90d1ae71d2e55ecffbc2443b399ad5d52e9dc74e53cee7360f3e998b1bc0e8cf7ae8c4603138
|
|
7
|
+
data.tar.gz: 2bcf5665113a86d6aee987a543fb101ce582bda2801498765a89ca5c42432b8da9d6461bafeb4cfea11f522cbf75ea77497b8ce580329c088ab0ca485007b795
|
data/README.md
CHANGED
|
@@ -185,6 +185,7 @@ Generator
|
|
|
185
185
|
│ ├── ParameterConverter - Converts endpoint parameters
|
|
186
186
|
│ ├── ResponseConverter - Converts endpoint responses
|
|
187
187
|
│ └── RequestBodyConverter - Converts request bodies
|
|
188
|
+
├── MediaTypeResolver - Maps declared media types to response schemas
|
|
188
189
|
└── TypeResolver - Maps Ruby/Grape types to OpenAPI types
|
|
189
190
|
```
|
|
190
191
|
|
|
@@ -194,6 +195,54 @@ Generator
|
|
|
194
195
|
- **RequestBodyRegistry** - Tracks request body schemas
|
|
195
196
|
- **TagRegistry** - Tracks API tags
|
|
196
197
|
|
|
198
|
+
### Response media types
|
|
199
|
+
|
|
200
|
+
Responses default to `application/json`, described by the `$ref` of the entity
|
|
201
|
+
declared with `success` / `entity`. Endpoints that return a file or plain text
|
|
202
|
+
instead declare their media type with `produces` in the `desc` block:
|
|
203
|
+
|
|
204
|
+
```ruby
|
|
205
|
+
desc 'Download an export' do
|
|
206
|
+
produces %w[application/octet-stream]
|
|
207
|
+
success code: 200
|
|
208
|
+
end
|
|
209
|
+
get ':id/export/download' do
|
|
210
|
+
# ...
|
|
211
|
+
end
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
which becomes:
|
|
215
|
+
|
|
216
|
+
```yaml
|
|
217
|
+
'200':
|
|
218
|
+
description: OK
|
|
219
|
+
content:
|
|
220
|
+
application/octet-stream:
|
|
221
|
+
schema:
|
|
222
|
+
type: string
|
|
223
|
+
format: binary
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
`produces` accepts a bare String as well as an Array, and each declared type
|
|
227
|
+
gets its own entry in `content`. The mapping is:
|
|
228
|
+
|
|
229
|
+
| Declared media type | Emitted schema |
|
|
230
|
+
| ------------------- | -------------- |
|
|
231
|
+
| `application/octet-stream`, `application/gzip`, `application/x-tar` | `{ type: string, format: binary }` |
|
|
232
|
+
| `text/*`, `application/yaml` | `{ type: string }` |
|
|
233
|
+
| `application/json` | none — the entity `$ref` describes it |
|
|
234
|
+
|
|
235
|
+
A type outside this table is **skipped** rather than guessed at, since assuming
|
|
236
|
+
"binary" would misdescribe a structured payload such as `application/xml`. The
|
|
237
|
+
response keeps its bare shape, so a new media type needs adding to
|
|
238
|
+
`MediaTypeResolver` before it appears in the spec.
|
|
239
|
+
|
|
240
|
+
`produces` only affects the success response; failure responses stay JSON.
|
|
241
|
+
|
|
242
|
+
A route that declares `success File` (with no `produces`) is treated as
|
|
243
|
+
`application/octet-stream`, matching `grape-swagger`. An explicit `produces`
|
|
244
|
+
takes precedence, so it can override that inference.
|
|
245
|
+
|
|
197
246
|
### Optional path segments
|
|
198
247
|
|
|
199
248
|
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
|
|
@@ -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
|
|
@@ -214,15 +214,12 @@ module Gitlab
|
|
|
214
214
|
end
|
|
215
215
|
|
|
216
216
|
def normalized_path
|
|
217
|
-
@normalized_path ||= path_override || normalize_path_pattern
|
|
217
|
+
@normalized_path ||= path_override || normalize_path_pattern
|
|
218
218
|
end
|
|
219
219
|
|
|
220
220
|
def normalize_path_pattern
|
|
221
|
-
|
|
222
|
-
path
|
|
223
|
-
.gsub(/\(\.:format\)$/, '')
|
|
221
|
+
NormalizedPath.new(pattern.origin).to_s
|
|
224
222
|
.gsub(/[()\\]/, '')
|
|
225
|
-
.gsub(/:\w+/) { |match| "{#{match[1..]}}" }
|
|
226
223
|
.gsub('{version}', config.api_version)
|
|
227
224
|
end
|
|
228
225
|
|
|
@@ -5,7 +5,7 @@ module Gitlab
|
|
|
5
5
|
module Converters
|
|
6
6
|
class ParameterConverter
|
|
7
7
|
include CoercerResolver
|
|
8
|
-
include Concerns::
|
|
8
|
+
include Concerns::ConstraintApplier
|
|
9
9
|
include Concerns::LimitResolver
|
|
10
10
|
include Concerns::FailFastAnnotatable
|
|
11
11
|
include Concerns::RegexConverter
|
|
@@ -24,9 +24,7 @@ module Gitlab
|
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
def in_value
|
|
27
|
-
|
|
28
|
-
# then match the param name as a complete segment bounded by / . ( ) or end-of-string.
|
|
29
|
-
route.path.gsub('/:version/', '/').match?(%r{/:#{Regexp.escape(name)}([/.()]|$)}) ? 'path' : 'query'
|
|
27
|
+
path_parameter_names.include?(name.to_s) ? 'path' : 'query'
|
|
30
28
|
end
|
|
31
29
|
|
|
32
30
|
def example
|
|
@@ -61,6 +59,8 @@ module Gitlab
|
|
|
61
59
|
|
|
62
60
|
apply_allow_blank(built_schema)
|
|
63
61
|
apply_limit!(built_schema, validations)
|
|
62
|
+
apply_array_enum!(built_schema, options[:values])
|
|
63
|
+
apply_default!(built_schema, options[:default], example: example)
|
|
64
64
|
built_schema
|
|
65
65
|
end
|
|
66
66
|
|
|
@@ -72,9 +72,12 @@ module Gitlab
|
|
|
72
72
|
def build_union_schema(object_type)
|
|
73
73
|
types = object_type[1..-2].split(", ")
|
|
74
74
|
members = types.map { |type| TypeResolver.resolve_union_member(type) }
|
|
75
|
+
schema = TypeResolver.union_schema(members)
|
|
76
|
+
members = schema.values.first
|
|
77
|
+
# Unlike `default:`, enum stays inline: it needs per-member type dispatch
|
|
78
|
+
# (coercing values to each member's type) that a generic post-step cannot do.
|
|
75
79
|
apply_union_enum!(members)
|
|
76
|
-
|
|
77
|
-
{ oneOf: members }
|
|
80
|
+
schema
|
|
78
81
|
end
|
|
79
82
|
|
|
80
83
|
def build_range_schema(object_type)
|
|
@@ -83,14 +86,12 @@ module Gitlab
|
|
|
83
86
|
|
|
84
87
|
schema[:minimum] = range.begin if range.begin
|
|
85
88
|
schema[:maximum] = range.end if range.end
|
|
86
|
-
schema[:default] = options[:default] if options[:default] && serializable?(options[:default])
|
|
87
89
|
schema
|
|
88
90
|
end
|
|
89
91
|
|
|
90
92
|
def build_enum_schema(object_type)
|
|
91
93
|
schema = { type: object_type }
|
|
92
94
|
schema[:enum] = options[:values] unless options[:values].is_a?(Proc)
|
|
93
|
-
schema[:default] = options[:default] if options[:default] && serializable?(options[:default])
|
|
94
95
|
schema
|
|
95
96
|
end
|
|
96
97
|
|
|
@@ -102,15 +103,6 @@ module Gitlab
|
|
|
102
103
|
def build_basic_schema(object_type, object_format)
|
|
103
104
|
schema = { type: object_type }
|
|
104
105
|
schema[:format] = object_format if object_format
|
|
105
|
-
if options[:default] && serializable?(options[:default])
|
|
106
|
-
schema[:default] = options[:default]
|
|
107
|
-
elsif options[:default] &&
|
|
108
|
-
defined?(ActiveSupport::TimeWithZone) &&
|
|
109
|
-
options[:default].is_a?(ActiveSupport::TimeWithZone)
|
|
110
|
-
serialized_default = time_serializer.serialize(options[:default], example: example)
|
|
111
|
-
schema[:default] = serialized_default if serialized_default
|
|
112
|
-
end
|
|
113
|
-
|
|
114
106
|
add_regex_validations!(schema)
|
|
115
107
|
schema
|
|
116
108
|
end
|
|
@@ -140,12 +132,15 @@ module Gitlab
|
|
|
140
132
|
# GET and DELETE requests don't have request bodies, so all their parameters are included.
|
|
141
133
|
method = route.request_method
|
|
142
134
|
return nil if method != 'GET' && method != 'DELETE' && in_value != 'path'
|
|
135
|
+
return nil if options.dig(:documentation, :hidden)
|
|
143
136
|
|
|
144
137
|
annotated = options.dup
|
|
145
138
|
if options[:desc] && fail_fast_in_validations?(validations)
|
|
146
139
|
annotated[:desc] = annotate_fail_fast(options[:desc])
|
|
147
140
|
end
|
|
148
141
|
|
|
142
|
+
annotated[:desc] ||= route_param_desc
|
|
143
|
+
|
|
149
144
|
param = Gitlab::GrapeOpenapi::Models::Parameter.new(
|
|
150
145
|
name,
|
|
151
146
|
options: annotated,
|
|
@@ -163,6 +158,18 @@ module Gitlab
|
|
|
163
158
|
|
|
164
159
|
private
|
|
165
160
|
|
|
161
|
+
def path_parameter_names
|
|
162
|
+
@path_parameter_names ||= NormalizedPath.new(route.origin).path_parameter_names
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Only path parameters can come from a `route_param`, and a same-named query
|
|
166
|
+
# parameter must not pick its description up.
|
|
167
|
+
def route_param_desc
|
|
168
|
+
return unless in_value == 'path'
|
|
169
|
+
|
|
170
|
+
GrapeCompat.route_param_desc(route, name)
|
|
171
|
+
end
|
|
172
|
+
|
|
166
173
|
def time_serializer
|
|
167
174
|
@time_serializer ||= Serializers::Time.new
|
|
168
175
|
end
|
|
@@ -188,59 +195,27 @@ module Gitlab
|
|
|
188
195
|
end
|
|
189
196
|
end
|
|
190
197
|
|
|
191
|
-
#
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
# and attach to all array members.
|
|
196
|
-
def apply_union_default!(members)
|
|
197
|
-
default = options[:default]
|
|
198
|
-
return unless default && serializable?(default)
|
|
199
|
-
|
|
200
|
-
members.each do |member|
|
|
201
|
-
member[:default] = default if member_accepts_default?(member, default)
|
|
202
|
-
end
|
|
203
|
-
end
|
|
204
|
-
|
|
205
|
-
def member_accepts_default?(member, default)
|
|
206
|
-
case member[:type]
|
|
207
|
-
when 'integer' then default.is_a?(Integer)
|
|
208
|
-
when 'number' then default.is_a?(Numeric)
|
|
209
|
-
when 'boolean' then [true, false].include?(default)
|
|
210
|
-
when 'string' then default.is_a?(String) || default.is_a?(Symbol)
|
|
211
|
-
when 'array' then array_member_accepts?(member, default)
|
|
212
|
-
when 'object' then default.is_a?(Hash)
|
|
198
|
+
# Checked before super because serializable? returns false for TimeWithZone.
|
|
199
|
+
def resolve_default(default, example: nil)
|
|
200
|
+
if defined?(ActiveSupport::TimeWithZone) && default.is_a?(ActiveSupport::TimeWithZone)
|
|
201
|
+
return time_serializer.serialize(default, example: example)
|
|
213
202
|
end
|
|
214
|
-
end
|
|
215
203
|
|
|
216
|
-
|
|
217
|
-
return false unless default.is_a?(Array)
|
|
218
|
-
return true if default.empty?
|
|
219
|
-
|
|
220
|
-
item_type = member.dig(:items, :type)
|
|
221
|
-
default.all? { |element| openapi_type_accepts?(item_type, element) }
|
|
222
|
-
end
|
|
223
|
-
|
|
224
|
-
def openapi_type_accepts?(openapi_type, value)
|
|
225
|
-
case openapi_type
|
|
226
|
-
when 'integer' then value.is_a?(Integer)
|
|
227
|
-
when 'number' then value.is_a?(Numeric)
|
|
228
|
-
when 'boolean' then [true, false].include?(value)
|
|
229
|
-
when 'string' then value.is_a?(String) || value.is_a?(Symbol)
|
|
230
|
-
else true
|
|
231
|
-
end
|
|
204
|
+
super
|
|
232
205
|
end
|
|
233
206
|
|
|
234
207
|
# allow_blank defaults to true
|
|
235
208
|
# when `allow_blank: false` for a string type minLength should be set to 1
|
|
236
209
|
# when param is required and values option used, the param is not nullable
|
|
237
210
|
def apply_allow_blank(schema)
|
|
211
|
+
union_members = schema[:oneOf] || schema[:anyOf]
|
|
212
|
+
|
|
238
213
|
if options[:allow_blank] == false || (options[:required] && options[:values])
|
|
239
214
|
schema[:minLength] = 1 if schema[:type] == 'string'
|
|
240
215
|
elsif in_value != 'path'
|
|
241
216
|
# path parameters are never nullable because they are required URL segments
|
|
242
|
-
if
|
|
243
|
-
|
|
217
|
+
if union_members
|
|
218
|
+
union_members.each { |s| s[:nullable] = true }
|
|
244
219
|
else
|
|
245
220
|
schema[:nullable] = true
|
|
246
221
|
end
|
|
@@ -11,7 +11,14 @@ module Gitlab
|
|
|
11
11
|
RESPONSE_DECLARATIONS = %i[entity success].freeze
|
|
12
12
|
|
|
13
13
|
# A Grape optional path segment: a parenthesised group, e.g. `(/:id)`.
|
|
14
|
-
|
|
14
|
+
# A backslash-escaped paren is a literal character, not a group - OData
|
|
15
|
+
# routes use them, e.g. `nuget/v2/Packages\(Id='*package_name'\)`.
|
|
16
|
+
OPTIONAL_SEGMENT = /(?<!\\)\(([^()]*)(?<!\\)\)/
|
|
17
|
+
|
|
18
|
+
# The counterpart of OPTIONAL_SEGMENT: the parenthesis is a literal URL
|
|
19
|
+
# character and only the backslash is Grape pattern syntax, so the escape
|
|
20
|
+
# is dropped once optional segments have been resolved.
|
|
21
|
+
ESCAPED_PAREN = /\\([()])/
|
|
15
22
|
|
|
16
23
|
# Optional path segment variants are each represented in the OpenAPI spec as a unique path.
|
|
17
24
|
PathVariant = Struct.new(:key, :path_override, :removed_params)
|
|
@@ -77,20 +84,22 @@ module Gitlab
|
|
|
77
84
|
end
|
|
78
85
|
|
|
79
86
|
def skip_route?(route)
|
|
80
|
-
method = extract_method(route)
|
|
81
|
-
path = normalize_path(route)
|
|
82
|
-
|
|
83
87
|
# Hidden routes (declared with `hidden true`) must be skipped before
|
|
84
88
|
# OperationConverter runs. Otherwise it pollutes the shared schema and
|
|
85
89
|
# request-body registries with entries that no emitted operation references,
|
|
86
90
|
# surfacing as `no-unused-components` warnings under `components.schemas`.
|
|
87
91
|
return true if hidden?(route)
|
|
88
92
|
|
|
89
|
-
# Grape
|
|
90
|
-
#
|
|
91
|
-
#
|
|
92
|
-
#
|
|
93
|
-
|
|
93
|
+
# Grape's catch-all (`route :any, '*path'`) is an internal routing artifact,
|
|
94
|
+
# not an API endpoint, and * isn't a valid HTTP method. Grape itself
|
|
95
|
+
# discriminates it by request method - see `collect_route_config_per_pattern`
|
|
96
|
+
# in `grape/api/instance.rb` - so match on the method alone.
|
|
97
|
+
#
|
|
98
|
+
# Do NOT also skip paths containing `*`. Splat segments are declared by
|
|
99
|
+
# developers for path segments that may contain slashes (`*module_name`,
|
|
100
|
+
# `*package_name`), and they are real endpoints. NormalizedPath renders them
|
|
101
|
+
# as ordinary `{name}` placeholders. See issue #22.
|
|
102
|
+
extract_method(route) == '*'
|
|
94
103
|
end
|
|
95
104
|
|
|
96
105
|
def hidden?(route)
|
|
@@ -98,12 +107,7 @@ module Gitlab
|
|
|
98
107
|
end
|
|
99
108
|
|
|
100
109
|
def normalize_path(route)
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
path
|
|
104
|
-
.gsub(/\(\.:format\)$/, '')
|
|
105
|
-
.gsub(/:\w+/) { |match| "{#{match[1..]}}" }
|
|
106
|
-
.gsub('{version}', config.api_version)
|
|
110
|
+
NormalizedPath.new(route.pattern.origin).to_s.gsub('{version}', config.api_version)
|
|
107
111
|
end
|
|
108
112
|
|
|
109
113
|
def grouping_key(route)
|
|
@@ -136,18 +140,28 @@ module Gitlab
|
|
|
136
140
|
# keys: a collapsed variant (segment and param removed entirely) and an
|
|
137
141
|
# expanded variant (param present, rendered as a required path param).
|
|
138
142
|
# Routes without such a segment yield a single, unchanged variant.
|
|
143
|
+
#
|
|
144
|
+
# Both variants resolve *every* optional group, not just the
|
|
145
|
+
# param-bearing one, because a route can put the separator in a group of
|
|
146
|
+
# its own: `releases/permalink/latest(/)(*suffix_path)` needs the `(/)`
|
|
147
|
+
# dropped alongside the param and inlined alongside it, or the expanded
|
|
148
|
+
# key would read `latest(/){suffix_path}` and match no real URL.
|
|
149
|
+
#
|
|
150
|
+
# Every emitted key drops the backslash of an escaped parenthesis, which
|
|
151
|
+
# can only happen once optional segments are resolved: until then it is
|
|
152
|
+
# the escape that tells a literal parenthesis apart from a group.
|
|
139
153
|
def path_variants(path_key, route)
|
|
140
154
|
param_groups = path_key.scan(OPTIONAL_SEGMENT).flatten.select { |inner| inner.match?(/\{\w+\}/) }
|
|
141
155
|
|
|
142
156
|
raise MultipleOptionalSegmentsError, multi_segment_message(route, path_key) if param_groups.length > 1
|
|
143
157
|
|
|
144
158
|
inner = param_groups.first
|
|
145
|
-
return [PathVariant.new(path_key, nil, [])] if inner.nil?
|
|
159
|
+
return [PathVariant.new(unescape_parens(path_key), nil, [])] if inner.nil?
|
|
146
160
|
|
|
147
161
|
removed = inner.scan(/\{(\w+)\}/).flatten
|
|
148
|
-
collapsed =
|
|
162
|
+
collapsed = unescape_parens(squeeze_slashes(path_key.gsub(OPTIONAL_SEGMENT, '')).delete_suffix('/'))
|
|
149
163
|
collapsed = '/' if collapsed.empty?
|
|
150
|
-
expanded = path_key.
|
|
164
|
+
expanded = unescape_parens(squeeze_slashes(path_key.gsub(OPTIONAL_SEGMENT) { Regexp.last_match(1) }))
|
|
151
165
|
|
|
152
166
|
[
|
|
153
167
|
PathVariant.new(collapsed, collapsed, removed),
|
|
@@ -155,6 +169,14 @@ module Gitlab
|
|
|
155
169
|
]
|
|
156
170
|
end
|
|
157
171
|
|
|
172
|
+
def squeeze_slashes(path)
|
|
173
|
+
path.gsub(%r{//+}, '/')
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def unescape_parens(path)
|
|
177
|
+
path.gsub(ESCAPED_PAREN) { Regexp.last_match(1) }
|
|
178
|
+
end
|
|
179
|
+
|
|
158
180
|
def multi_segment_message(route, path_key)
|
|
159
181
|
"Route '#{extract_method(route)} #{path_key}' declares more than one optional path segment " \
|
|
160
182
|
"with a named parameter. gitlab-grape-openapi supports at most one optional segment per route " \
|
|
@@ -4,6 +4,10 @@ module Gitlab
|
|
|
4
4
|
module GrapeOpenapi
|
|
5
5
|
module Converters
|
|
6
6
|
class ResponseConverter
|
|
7
|
+
# RFC 9110 forbids a body on these, so a declared media type cannot
|
|
8
|
+
# describe one.
|
|
9
|
+
BODYLESS_STATUS_CODES = %w[204 304].freeze
|
|
10
|
+
|
|
7
11
|
def initialize(route, schema_registry)
|
|
8
12
|
@route = route
|
|
9
13
|
@schema_registry = schema_registry
|
|
@@ -19,14 +23,15 @@ module Gitlab
|
|
|
19
23
|
private
|
|
20
24
|
|
|
21
25
|
def extract_success_response
|
|
22
|
-
entity_definition =
|
|
26
|
+
entity_definition = success_definition
|
|
23
27
|
|
|
24
28
|
case entity_definition
|
|
25
29
|
when nil
|
|
26
|
-
success_code = infer_success_code
|
|
30
|
+
success_code = infer_success_code(with_body: declared_body?)
|
|
27
31
|
add_simple_response(
|
|
28
32
|
status_code: success_code,
|
|
29
|
-
description: http_status_text(success_code)
|
|
33
|
+
description: http_status_text(success_code),
|
|
34
|
+
content: declared_content
|
|
30
35
|
)
|
|
31
36
|
when Class
|
|
32
37
|
process_class_entity(entity_definition)
|
|
@@ -46,10 +51,11 @@ module Gitlab
|
|
|
46
51
|
entity_class: entity_class
|
|
47
52
|
)
|
|
48
53
|
else
|
|
49
|
-
success_code = infer_success_code
|
|
54
|
+
success_code = infer_success_code(with_body: declared_body?)
|
|
50
55
|
add_simple_response(
|
|
51
56
|
status_code: success_code,
|
|
52
|
-
description: http_status_text(success_code)
|
|
57
|
+
description: http_status_text(success_code),
|
|
58
|
+
content: declared_content
|
|
53
59
|
)
|
|
54
60
|
end
|
|
55
61
|
end
|
|
@@ -65,10 +71,11 @@ module Gitlab
|
|
|
65
71
|
examples: entity_hash[:examples]
|
|
66
72
|
)
|
|
67
73
|
else
|
|
68
|
-
status_code = entity_hash[:code] || infer_success_code
|
|
74
|
+
status_code = entity_hash[:code] || infer_success_code(with_body: declared_body?)
|
|
69
75
|
add_simple_response(
|
|
70
76
|
status_code: status_code,
|
|
71
|
-
description: entity_hash[:message] || http_status_text(status_code)
|
|
77
|
+
description: entity_hash[:message] || http_status_text(status_code),
|
|
78
|
+
content: declared_content
|
|
72
79
|
)
|
|
73
80
|
end
|
|
74
81
|
end
|
|
@@ -95,10 +102,11 @@ module Gitlab
|
|
|
95
102
|
examples: definition[:examples]
|
|
96
103
|
)
|
|
97
104
|
else
|
|
98
|
-
status_code = definition[:code] || infer_success_code
|
|
105
|
+
status_code = definition[:code] || infer_success_code(with_body: declared_body?)
|
|
99
106
|
add_simple_response(
|
|
100
107
|
status_code: status_code,
|
|
101
|
-
description: definition[:message] || http_status_text(status_code)
|
|
108
|
+
description: definition[:message] || http_status_text(status_code),
|
|
109
|
+
content: declared_content
|
|
102
110
|
)
|
|
103
111
|
end
|
|
104
112
|
end
|
|
@@ -136,7 +144,9 @@ module Gitlab
|
|
|
136
144
|
@responses[response.status_code] = response.to_h(@schema_registry)
|
|
137
145
|
end
|
|
138
146
|
|
|
139
|
-
|
|
147
|
+
# `content` is only ever passed for the success response, so failure
|
|
148
|
+
# responses stay JSON.
|
|
149
|
+
def add_simple_response(status_code:, description:, content: nil)
|
|
140
150
|
key = status_code.to_s
|
|
141
151
|
|
|
142
152
|
# `http_codes` (processed by `extract_failure_responses`) may include
|
|
@@ -148,7 +158,57 @@ module Gitlab
|
|
|
148
158
|
@responses[key][:description] = description
|
|
149
159
|
else
|
|
150
160
|
@responses[key] = { description: description }
|
|
161
|
+
@responses[key][:content] = content if content && BODYLESS_STATUS_CODES.exclude?(key)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Whether the route describes a body, which decides between 200 and 204
|
|
166
|
+
# for a DELETE.
|
|
167
|
+
def declared_body?
|
|
168
|
+
!declared_content.nil?
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# The content map for a response is described with `produces` (or
|
|
172
|
+
# `success File`) rather than an entity. `nil` when the route declares
|
|
173
|
+
# nothing the gem can describe.
|
|
174
|
+
def declared_content
|
|
175
|
+
return @declared_content if defined?(@declared_content)
|
|
176
|
+
|
|
177
|
+
@declared_content = build_declared_content
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# If there is no schema for the declared type it is dropped as guessing
|
|
181
|
+
# would misdescribe it. See MediaTypeResolver for the mapping.
|
|
182
|
+
def build_declared_content
|
|
183
|
+
content = declared_media_types.each_with_object({}) do |media_type, acc|
|
|
184
|
+
schema = MediaTypeResolver.schema_for(media_type)
|
|
185
|
+
acc[media_type] = { schema: schema } if schema
|
|
151
186
|
end
|
|
187
|
+
|
|
188
|
+
content unless content.empty?
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# An explicit `produces` overrides `success File`, matching grape-swagger existing behaviour.
|
|
192
|
+
def declared_media_types
|
|
193
|
+
produces = MediaTypeResolver.normalize(@route.settings.dig(:description, :produces))
|
|
194
|
+
return produces if produces.any?
|
|
195
|
+
return [] unless file_response?(success_definition)
|
|
196
|
+
|
|
197
|
+
[MediaTypeResolver::OCTET_STREAM_MEDIA_TYPE]
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Recognizes `success File` in any of its shapes. Compared by name so if `File`
|
|
201
|
+
# is passed as a String it matches. Aligns with grape-swagger's `file_response?`
|
|
202
|
+
def file_response?(definition)
|
|
203
|
+
case definition
|
|
204
|
+
when Array then definition.any? { |item| file_response?(item) }
|
|
205
|
+
when Hash then file_response?(definition[:model])
|
|
206
|
+
else definition.to_s == 'File'
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def success_definition
|
|
211
|
+
@route.options[:entity] || @route.options[:success]
|
|
152
212
|
end
|
|
153
213
|
|
|
154
214
|
# 204 means "No Content", so it only applies when the response has no
|
|
@@ -174,10 +234,9 @@ module Gitlab
|
|
|
174
234
|
end
|
|
175
235
|
|
|
176
236
|
def path_has_resource_parameters?
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
path.include?(':')
|
|
237
|
+
# Splat segments count: `/packages/npm/*package_name` addresses a resource
|
|
238
|
+
# just as `/packages/:id` does, so it earns the same inferred 404.
|
|
239
|
+
NormalizedPath.new(@route.origin).path_parameter_names.any?
|
|
181
240
|
end
|
|
182
241
|
|
|
183
242
|
def http_status_text(code)
|
|
@@ -66,9 +66,25 @@ module Gitlab
|
|
|
66
66
|
item_type = type[1..-2]
|
|
67
67
|
{ type: 'array', items: { type: resolve_type(item_type) || 'string' } }
|
|
68
68
|
else
|
|
69
|
-
{ type: resolve_type(type) || 'string' }
|
|
69
|
+
member = { type: resolve_type(type) || 'string' }
|
|
70
|
+
format = resolve_format(nil, type)
|
|
71
|
+
member[:format] = format if format
|
|
72
|
+
member
|
|
70
73
|
end
|
|
71
74
|
end
|
|
75
|
+
|
|
76
|
+
# `oneOf` means "valid against exactly one". Members that differ only by `format`
|
|
77
|
+
# are indistinguishable to a validator, because `format` annotates rather than
|
|
78
|
+
# constrains, so nothing could satisfy it and `anyOf` ("at least one") is the
|
|
79
|
+
# accurate keyword. Keep `oneOf` everywhere else: it is the stricter of the two
|
|
80
|
+
# and says more about the parameter.
|
|
81
|
+
def self.union_schema(members)
|
|
82
|
+
members = members.uniq
|
|
83
|
+
asserted = members.map { |member| member.except(:format) }
|
|
84
|
+
keyword = asserted.uniq.size == members.size ? :oneOf : :anyOf
|
|
85
|
+
|
|
86
|
+
{ keyword => members }
|
|
87
|
+
end
|
|
72
88
|
end
|
|
73
89
|
end
|
|
74
90
|
end
|
|
@@ -25,6 +25,22 @@ module Gitlab
|
|
|
25
25
|
end
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
+
# The `desc:` an author writes on `route_param`. Grape forwards only `type:` into
|
|
29
|
+
# the `requires` it declares internally and discards the rest, so the description
|
|
30
|
+
# never reaches the route's params. It survives on the namespace whose space is
|
|
31
|
+
# the `:placeholder` segment. Searched innermost first.
|
|
32
|
+
def route_param_desc(route, name)
|
|
33
|
+
return unless route.app.respond_to?(:inheritable_setting)
|
|
34
|
+
|
|
35
|
+
namespaces = route.app.inheritable_setting&.namespace_stackable&.[](:namespace) || []
|
|
36
|
+
namespace = namespaces.reverse.find { |candidate| candidate.space.to_s == ":#{name}" }
|
|
37
|
+
options = namespace&.options
|
|
38
|
+
return unless options.is_a?(Hash)
|
|
39
|
+
|
|
40
|
+
desc = options[:desc]
|
|
41
|
+
desc if desc.is_a?(String)
|
|
42
|
+
end
|
|
43
|
+
|
|
28
44
|
private
|
|
29
45
|
|
|
30
46
|
def normalize(validation)
|
|
@@ -6,7 +6,7 @@ module Gitlab
|
|
|
6
6
|
module RequestBody
|
|
7
7
|
class ParameterSchema
|
|
8
8
|
include Converters::CoercerResolver
|
|
9
|
-
include Concerns::
|
|
9
|
+
include Concerns::ConstraintApplier
|
|
10
10
|
include Concerns::LimitResolver
|
|
11
11
|
include Concerns::FailFastAnnotatable
|
|
12
12
|
include Concerns::RegexConverter
|
|
@@ -31,6 +31,8 @@ module Gitlab
|
|
|
31
31
|
|
|
32
32
|
apply_allow_blank(built_schema)
|
|
33
33
|
apply_limit!(built_schema, validations)
|
|
34
|
+
apply_array_enum!(built_schema, param_options[:values])
|
|
35
|
+
apply_default!(built_schema, param_options[:default])
|
|
34
36
|
built_schema
|
|
35
37
|
end
|
|
36
38
|
|
|
@@ -109,7 +111,10 @@ module Gitlab
|
|
|
109
111
|
|
|
110
112
|
def build_union_type_schema
|
|
111
113
|
types = param_options[:type][1..-2].split(", ")
|
|
112
|
-
|
|
114
|
+
members = types.map { |type| Converters::TypeResolver.resolve_union_member(type) }
|
|
115
|
+
schema = Converters::TypeResolver.union_schema(members)
|
|
116
|
+
schema[:description] = annotated_description if param_options[:desc]
|
|
117
|
+
schema
|
|
113
118
|
end
|
|
114
119
|
|
|
115
120
|
def build_range_schema(object_type)
|
|
@@ -117,10 +122,6 @@ module Gitlab
|
|
|
117
122
|
schema = { type: object_type }
|
|
118
123
|
schema[:minimum] = range.begin if range.begin
|
|
119
124
|
schema[:maximum] = range.end if range.end
|
|
120
|
-
if param_options[:default] && serializable?(param_options[:default])
|
|
121
|
-
schema[:default] = param_options[:default]
|
|
122
|
-
end
|
|
123
|
-
|
|
124
125
|
schema[:description] = annotated_description if param_options[:desc]
|
|
125
126
|
schema
|
|
126
127
|
end
|
|
@@ -128,10 +129,6 @@ module Gitlab
|
|
|
128
129
|
def build_enum_schema(object_type)
|
|
129
130
|
schema = { type: object_type }
|
|
130
131
|
schema[:enum] = param_options[:values] unless param_options[:values].is_a?(Proc)
|
|
131
|
-
if param_options[:default] && serializable?(param_options[:default])
|
|
132
|
-
schema[:default] = param_options[:default]
|
|
133
|
-
end
|
|
134
|
-
|
|
135
132
|
schema[:description] = annotated_description if param_options[:desc]
|
|
136
133
|
schema
|
|
137
134
|
end
|
|
@@ -202,10 +199,6 @@ module Gitlab
|
|
|
202
199
|
def build_basic_schema(object_type, object_format)
|
|
203
200
|
schema = { type: object_type }
|
|
204
201
|
schema[:format] = object_format if object_format
|
|
205
|
-
if param_options[:default] && serializable?(param_options[:default])
|
|
206
|
-
schema[:default] = param_options[:default]
|
|
207
|
-
end
|
|
208
|
-
|
|
209
202
|
schema[:description] = annotated_description if param_options[:desc]
|
|
210
203
|
|
|
211
204
|
if param_options.dig(:documentation, :example)
|
|
@@ -230,10 +223,12 @@ module Gitlab
|
|
|
230
223
|
end
|
|
231
224
|
|
|
232
225
|
def apply_allow_blank(schema)
|
|
226
|
+
union_members = schema[:oneOf] || schema[:anyOf]
|
|
227
|
+
|
|
233
228
|
if param_options[:allow_blank] == false || (param_options[:required] && param_options[:values])
|
|
234
229
|
schema[:minLength] = 1 if schema[:type] == 'string'
|
|
235
|
-
elsif
|
|
236
|
-
|
|
230
|
+
elsif union_members
|
|
231
|
+
union_members.each { |s| s[:nullable] = true }
|
|
237
232
|
else
|
|
238
233
|
schema[:nullable] = true
|
|
239
234
|
end
|
|
@@ -14,7 +14,7 @@ module Gitlab
|
|
|
14
14
|
|
|
15
15
|
def extract
|
|
16
16
|
body_params = params.reject do |key, _|
|
|
17
|
-
path_with_params.include?("{#{key}}")
|
|
17
|
+
path_with_params.include?("{#{key}}") || hidden?(key)
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
restructure_nested_params(body_params)
|
|
@@ -22,10 +22,22 @@ module Gitlab
|
|
|
22
22
|
|
|
23
23
|
private
|
|
24
24
|
|
|
25
|
+
# `documentation: { hidden: true }` marks a param the author does not want
|
|
26
|
+
# documented. Params nested below it, in bracket notation, go with it.
|
|
27
|
+
def hidden?(key)
|
|
28
|
+
key_str = key.to_s
|
|
29
|
+
|
|
30
|
+
hidden_keys.any? { |hidden| key_str == hidden || key_str.start_with?("#{hidden}[") }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def hidden_keys
|
|
34
|
+
@hidden_keys ||= params.filter_map do |key, param_options|
|
|
35
|
+
key.to_s if param_options.is_a?(Hash) && param_options.dig(:documentation, :hidden)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
25
39
|
def path_with_params
|
|
26
|
-
@path_with_params ||= route.origin
|
|
27
|
-
.gsub(/\(\.:format\)$/, '')
|
|
28
|
-
.gsub(/:\w+/) { |match| "{#{match[1..]}}" }
|
|
40
|
+
@path_with_params ||= NormalizedPath.new(route.origin).to_s
|
|
29
41
|
end
|
|
30
42
|
|
|
31
43
|
def restructure_nested_params(body_params)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gitlab
|
|
4
|
+
module GrapeOpenapi
|
|
5
|
+
# A Grape route pattern rendered as an OpenAPI path template.
|
|
6
|
+
#
|
|
7
|
+
# Grape spells path placeholders two ways: `:name` matches a single segment,
|
|
8
|
+
# and `*name` (a splat) matches one or more segments, so its value may contain
|
|
9
|
+
# slashes. OpenAPI 3.0 has no splat syntax, so both collapse to `{name}`.
|
|
10
|
+
#
|
|
11
|
+
# The splat mapping is lossy - `{name}` implies a single segment - but the
|
|
12
|
+
# endpoint is documented, which is strictly better than omitting it. Do not
|
|
13
|
+
# "fix" this by dropping splat routes: that is the bug in issue #22, which
|
|
14
|
+
# silently removed the entire package registry surface from the spec.
|
|
15
|
+
class NormalizedPath
|
|
16
|
+
# Grape appends the format suffix to `route.path`, not to `route.pattern.origin`,
|
|
17
|
+
# so this only strips a suffix an author wrote into the pattern by hand. Without
|
|
18
|
+
# it, a declared `format` param would be mistaken for a path parameter.
|
|
19
|
+
FORMAT_SUFFIX = /\(\.:format\)$/
|
|
20
|
+
|
|
21
|
+
PLACEHOLDER = /[:*](\w+)/
|
|
22
|
+
NORMALIZED_PLACEHOLDER = /\{(\w+)\}/
|
|
23
|
+
|
|
24
|
+
# The API version is substituted away with the configured value before a path
|
|
25
|
+
# is emitted, so it never surfaces as a path parameter.
|
|
26
|
+
API_VERSION_PLACEHOLDER = 'version'
|
|
27
|
+
|
|
28
|
+
attr_reader :origin
|
|
29
|
+
|
|
30
|
+
# Takes a `route.pattern.origin` - the pattern as the author declared it.
|
|
31
|
+
# Do not pass `route.path`: Grape rewrites that one, appending the format
|
|
32
|
+
# suffix and turning a trailing `*path` into `?*path`.
|
|
33
|
+
def initialize(origin)
|
|
34
|
+
@origin = origin
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def to_s
|
|
38
|
+
@to_s ||= origin
|
|
39
|
+
.sub(FORMAT_SUFFIX, '')
|
|
40
|
+
.gsub(PLACEHOLDER) { "{#{Regexp.last_match(1)}}" }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def placeholder_names
|
|
44
|
+
# Scanning whole `{name}` placeholders sidesteps the boundary problem a regex
|
|
45
|
+
# over the raw pattern has: real routes introduce a placeholder after `/`,
|
|
46
|
+
# `(`, `)` and `'` - `(*path`, `):file_name`, `Id='*package_name'` - so there
|
|
47
|
+
# is no single delimiter to anchor on.
|
|
48
|
+
@placeholder_names ||= to_s.scan(NORMALIZED_PLACEHOLDER).flatten
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def path_parameter_names
|
|
52
|
+
placeholder_names - [API_VERSION_PLACEHOLDER]
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
data/lib/gitlab-grape-openapi.rb
CHANGED
|
@@ -7,9 +7,11 @@ require_relative "gitlab/grape_openapi/generator"
|
|
|
7
7
|
require_relative "gitlab/grape_openapi/schema_registry"
|
|
8
8
|
require_relative "gitlab/grape_openapi/request_body_registry"
|
|
9
9
|
require_relative "gitlab/grape_openapi/tag_registry"
|
|
10
|
+
require_relative "gitlab/grape_openapi/normalized_path"
|
|
10
11
|
|
|
11
12
|
# Concerns
|
|
12
13
|
require_relative "gitlab/grape_openapi/concerns/serializable"
|
|
14
|
+
require_relative "gitlab/grape_openapi/concerns/constraint_applier"
|
|
13
15
|
require_relative "gitlab/grape_openapi/concerns/limit_resolver"
|
|
14
16
|
require_relative "gitlab/grape_openapi/concerns/fail_fast_annotatable"
|
|
15
17
|
require_relative "gitlab/grape_openapi/concerns/regex_converter"
|
|
@@ -20,6 +22,7 @@ require_relative "gitlab/grape_openapi/serializers/time"
|
|
|
20
22
|
# Converters
|
|
21
23
|
require_relative "gitlab/grape_openapi/converters/coercer_resolver"
|
|
22
24
|
require_relative "gitlab/grape_openapi/converters/entity_converter"
|
|
25
|
+
require_relative "gitlab/grape_openapi/converters/media_type_resolver"
|
|
23
26
|
require_relative "gitlab/grape_openapi/converters/type_resolver"
|
|
24
27
|
require_relative "gitlab/grape_openapi/converters/tag_converter"
|
|
25
28
|
require_relative "gitlab/grape_openapi/converters/operation_converter"
|
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.4.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-08-
|
|
11
|
+
date: 2026-08-19 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: grape
|
|
@@ -139,6 +139,7 @@ files:
|
|
|
139
139
|
- LICENSE
|
|
140
140
|
- README.md
|
|
141
141
|
- lib/gitlab-grape-openapi.rb
|
|
142
|
+
- lib/gitlab/grape_openapi/concerns/constraint_applier.rb
|
|
142
143
|
- lib/gitlab/grape_openapi/concerns/fail_fast_annotatable.rb
|
|
143
144
|
- lib/gitlab/grape_openapi/concerns/limit_resolver.rb
|
|
144
145
|
- lib/gitlab/grape_openapi/concerns/regex_converter.rb
|
|
@@ -146,6 +147,7 @@ files:
|
|
|
146
147
|
- lib/gitlab/grape_openapi/configuration.rb
|
|
147
148
|
- lib/gitlab/grape_openapi/converters/coercer_resolver.rb
|
|
148
149
|
- lib/gitlab/grape_openapi/converters/entity_converter.rb
|
|
150
|
+
- lib/gitlab/grape_openapi/converters/media_type_resolver.rb
|
|
149
151
|
- lib/gitlab/grape_openapi/converters/operation_converter.rb
|
|
150
152
|
- lib/gitlab/grape_openapi/converters/parameter_converter.rb
|
|
151
153
|
- lib/gitlab/grape_openapi/converters/path_converter.rb
|
|
@@ -167,6 +169,7 @@ files:
|
|
|
167
169
|
- lib/gitlab/grape_openapi/models/server.rb
|
|
168
170
|
- lib/gitlab/grape_openapi/models/server_variable.rb
|
|
169
171
|
- lib/gitlab/grape_openapi/models/tag.rb
|
|
172
|
+
- lib/gitlab/grape_openapi/normalized_path.rb
|
|
170
173
|
- lib/gitlab/grape_openapi/request_body_registry.rb
|
|
171
174
|
- lib/gitlab/grape_openapi/schema_registry.rb
|
|
172
175
|
- lib/gitlab/grape_openapi/serializers/time.rb
|