gitlab-grape-openapi 0.2.1 → 0.2.3
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 +23 -0
- data/lib/gitlab/grape_openapi/converters/entity_converter.rb +35 -3
- data/lib/gitlab/grape_openapi/converters/operation_converter.rb +19 -9
- data/lib/gitlab/grape_openapi/converters/parameter_converter.rb +2 -2
- data/lib/gitlab/grape_openapi/converters/path_converter.rb +48 -4
- data/lib/gitlab/grape_openapi/converters/response_converter.rb +19 -13
- data/lib/gitlab/grape_openapi/version.rb +1 -1
- data/lib/gitlab-grape-openapi.rb +4 -0
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6b3783b6b78454e5f8a76c0f2da60864f1f890272e8d9604cac6c0152becea17
|
|
4
|
+
data.tar.gz: 21b5cec8a2792e118c480334b358aa62d3fc4d85553fbbe7ace7871a7fc91709
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 36ca996cdcbb0a74ac4853fe174491ffbdb38176952155630b956d9dcef42da63a7e1403693e54b3db27689590e20acdc6989cf0af5d288e96c5e692e5c110c8
|
|
7
|
+
data.tar.gz: 461ddbe6f58280b0678e394f20f722bb981e0cdb2c0684456597b5deff382e18ed96df69753df729e6e1e1cea73107eeaad9c4cff37a79aa2a92bb5b189948eb
|
data/README.md
CHANGED
|
@@ -194,6 +194,29 @@ Generator
|
|
|
194
194
|
- **RequestBodyRegistry** - Tracks request body schemas
|
|
195
195
|
- **TagRegistry** - Tracks API tags
|
|
196
196
|
|
|
197
|
+
### Optional path segments
|
|
198
|
+
|
|
199
|
+
Grape lets a route mark a path segment as optional with parentheses, e.g.
|
|
200
|
+
`resource '/settings_sync(/:settings_context_hash)'`. Such a route genuinely
|
|
201
|
+
accepts requests both with and without the segment, but a single OpenAPI path
|
|
202
|
+
key cannot express that optionality — an OpenAPI path parameter is always
|
|
203
|
+
`required: true`.
|
|
204
|
+
|
|
205
|
+
When a route contains **one** optional segment that holds a named parameter,
|
|
206
|
+
`PathConverter` fans it out into two OpenAPI paths:
|
|
207
|
+
|
|
208
|
+
- a **collapsed** path with the segment and its parameter removed entirely
|
|
209
|
+
(the parameter is absent from `parameters`, not marked optional), and
|
|
210
|
+
- an **expanded** path where the parameter is present as a required
|
|
211
|
+
`in: path` parameter.
|
|
212
|
+
|
|
213
|
+
Each variant gets its own `operationId`, so both remain unique. Optional
|
|
214
|
+
segments without a named parameter (e.g. `(-/)`) are left unchanged.
|
|
215
|
+
|
|
216
|
+
Generating the combinatorial set of paths for routes with more than one
|
|
217
|
+
param-bearing optional segment is **not** supported: such a route raises
|
|
218
|
+
`Gitlab::GrapeOpenapi::MultipleOptionalSegmentsError`.
|
|
219
|
+
|
|
197
220
|
## Development
|
|
198
221
|
|
|
199
222
|
```bash
|
|
@@ -53,23 +53,55 @@ module Gitlab
|
|
|
53
53
|
def build_schema
|
|
54
54
|
Models::Schema.new.tap do |schema|
|
|
55
55
|
schema.type = OBJECT_TYPE
|
|
56
|
-
schema.properties =
|
|
56
|
+
schema.properties = build_exposures_properties(root_exposures)
|
|
57
57
|
end
|
|
58
58
|
end
|
|
59
59
|
|
|
60
|
-
def
|
|
61
|
-
|
|
60
|
+
def build_exposures_properties(exposures)
|
|
61
|
+
exposures.each_with_object({}) do |exposure, properties|
|
|
62
62
|
if inlineable_merge_exposure?(exposure)
|
|
63
63
|
# `merge: true` flattens the nested entity's exposures into the
|
|
64
64
|
# parent at runtime. Inline its properties instead of emitting a
|
|
65
65
|
# `$ref`, so the generated schema reflects the actual response.
|
|
66
66
|
inline_merged_properties!(properties, exposure)
|
|
67
|
+
elsif block_exposure?(exposure)
|
|
68
|
+
add_block_exposure_properties!(properties, exposure)
|
|
67
69
|
else
|
|
68
70
|
properties[exposure.key] = build_property(exposure)
|
|
69
71
|
end
|
|
70
72
|
end
|
|
71
73
|
end
|
|
72
74
|
|
|
75
|
+
# Grouping blocks (`expose :foo do ... end`) are NestingExposure
|
|
76
|
+
# instances. Blocks that compute a value (`expose(:foo) { |obj, _| }`)
|
|
77
|
+
# are BlockExposure and fall through to the default handling.
|
|
78
|
+
def block_exposure?(exposure)
|
|
79
|
+
exposure.is_a?(Grape::Entity::Exposure::NestingExposure)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def add_block_exposure_properties!(properties, exposure)
|
|
83
|
+
if exposure.for_merge
|
|
84
|
+
# A `merge: true` block merges its nested hash into the parent at
|
|
85
|
+
# runtime, so its key never appears in the response.
|
|
86
|
+
properties.merge!(build_exposures_properties(exposure.nested_exposures))
|
|
87
|
+
else
|
|
88
|
+
properties[exposure.key] = build_block_property(exposure)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def build_block_property(exposure)
|
|
93
|
+
documentation = exposure_documentation(exposure)
|
|
94
|
+
nested_properties = build_exposures_properties(exposure.nested_exposures)
|
|
95
|
+
object_schema = { type: OBJECT_TYPE }
|
|
96
|
+
object_schema[:properties] = nested_properties if nested_properties.any?
|
|
97
|
+
|
|
98
|
+
if documentation[:is_array]
|
|
99
|
+
{ type: ARRAY_TYPE, description: documentation[:desc], items: object_schema }.compact
|
|
100
|
+
else
|
|
101
|
+
object_schema.merge(description: documentation[:desc]).compact
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
73
105
|
def inlineable_merge_exposure?(exposure)
|
|
74
106
|
return false unless exposure.for_merge
|
|
75
107
|
|
|
@@ -8,20 +8,29 @@ module Gitlab
|
|
|
8
8
|
|
|
9
9
|
DASH_SEGMENT = 'Dash'
|
|
10
10
|
|
|
11
|
-
def self.convert(
|
|
11
|
+
def self.convert(
|
|
12
|
+
route, schema_registry, request_body_registry, inherited_path_params: {},
|
|
13
|
+
path_override: nil, removed_params: [])
|
|
12
14
|
new(
|
|
13
15
|
route,
|
|
14
16
|
schema_registry,
|
|
15
17
|
request_body_registry,
|
|
16
|
-
inherited_path_params: inherited_path_params
|
|
18
|
+
inherited_path_params: inherited_path_params,
|
|
19
|
+
path_override: path_override,
|
|
20
|
+
removed_params: removed_params
|
|
17
21
|
).convert
|
|
18
22
|
end
|
|
19
23
|
|
|
20
|
-
def initialize(
|
|
24
|
+
def initialize(
|
|
25
|
+
route, schema_registry, request_body_registry, inherited_path_params: {},
|
|
26
|
+
path_override: nil, removed_params: [])
|
|
21
27
|
@route = route
|
|
22
28
|
@schema_registry = schema_registry
|
|
23
29
|
@request_body_registry = request_body_registry
|
|
24
30
|
@inherited_path_params = inherited_path_params
|
|
31
|
+
# @path_override and @removed_params are set together when optional-path-segment variants exist
|
|
32
|
+
@path_override = path_override
|
|
33
|
+
@removed_params = removed_params
|
|
25
34
|
@config = Gitlab::GrapeOpenapi.configuration
|
|
26
35
|
@options = route.options
|
|
27
36
|
@pattern = route.pattern
|
|
@@ -46,7 +55,7 @@ module Gitlab
|
|
|
46
55
|
private
|
|
47
56
|
|
|
48
57
|
attr_reader :config, :route, :options, :pattern, :endpoint, :schema_registry, :request_body_registry,
|
|
49
|
-
:inherited_path_params
|
|
58
|
+
:inherited_path_params, :path_override, :removed_params
|
|
50
59
|
|
|
51
60
|
def route_method
|
|
52
61
|
@route.request_method
|
|
@@ -78,7 +87,11 @@ module Gitlab
|
|
|
78
87
|
end
|
|
79
88
|
end
|
|
80
89
|
|
|
81
|
-
inject_missing_path_parameters(params)
|
|
90
|
+
params = inject_missing_path_parameters(params)
|
|
91
|
+
return params if removed_params.empty?
|
|
92
|
+
|
|
93
|
+
removed = removed_params.map(&:to_s)
|
|
94
|
+
params.reject { |param| removed.include?(param.name.to_s) }
|
|
82
95
|
end
|
|
83
96
|
|
|
84
97
|
def inject_missing_path_parameters(params)
|
|
@@ -201,10 +214,7 @@ module Gitlab
|
|
|
201
214
|
end
|
|
202
215
|
|
|
203
216
|
def normalized_path
|
|
204
|
-
@normalized_path ||=
|
|
205
|
-
path = normalize_path_pattern
|
|
206
|
-
path.gsub('{version}', config.api_version)
|
|
207
|
-
end
|
|
217
|
+
@normalized_path ||= path_override || normalize_path_pattern.gsub('{version}', config.api_version)
|
|
208
218
|
end
|
|
209
219
|
|
|
210
220
|
def normalize_path_pattern
|
|
@@ -25,8 +25,8 @@ module Gitlab
|
|
|
25
25
|
|
|
26
26
|
def in_value
|
|
27
27
|
# Strip only the :version path segment (not substrings like :version_id or :package_version),
|
|
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'
|
|
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'
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
def example
|
|
@@ -10,6 +10,12 @@ module Gitlab
|
|
|
10
10
|
# handles all three shapes (Class, Hash with :model, Array of those).
|
|
11
11
|
RESPONSE_DECLARATIONS = %i[entity success].freeze
|
|
12
12
|
|
|
13
|
+
# A Grape optional path segment: a parenthesised group, e.g. `(/:id)`.
|
|
14
|
+
OPTIONAL_SEGMENT = /\(([^()]*)\)/
|
|
15
|
+
|
|
16
|
+
# Optional path segment variants are each represented in the OpenAPI spec as a unique path.
|
|
17
|
+
PathVariant = Struct.new(:key, :path_override, :removed_params)
|
|
18
|
+
|
|
13
19
|
def self.convert(routes, schema_registry, request_body_registry)
|
|
14
20
|
new(routes, schema_registry, request_body_registry).convert
|
|
15
21
|
end
|
|
@@ -25,8 +31,12 @@ module Gitlab
|
|
|
25
31
|
def convert
|
|
26
32
|
register_inherited_path_params
|
|
27
33
|
|
|
28
|
-
paths =
|
|
29
|
-
|
|
34
|
+
paths = {}
|
|
35
|
+
grouped_routes.each do |path_key, routes_for_path|
|
|
36
|
+
path_variants(path_key, routes_for_path.first).each do |variant|
|
|
37
|
+
paths[variant.key] ||= {}
|
|
38
|
+
paths[variant.key].merge!(build_path_item(routes_for_path, variant))
|
|
39
|
+
end
|
|
30
40
|
end
|
|
31
41
|
|
|
32
42
|
paths.reject { |_path, operations| operations.empty? }
|
|
@@ -100,7 +110,7 @@ module Gitlab
|
|
|
100
110
|
normalize_path(route).gsub(/\{[^}]+\}/, '{param}')
|
|
101
111
|
end
|
|
102
112
|
|
|
103
|
-
def build_path_item(routes_for_path)
|
|
113
|
+
def build_path_item(routes_for_path, variant)
|
|
104
114
|
path_item = Models::PathItem.new
|
|
105
115
|
|
|
106
116
|
routes_for_path.each do |route|
|
|
@@ -108,7 +118,9 @@ module Gitlab
|
|
|
108
118
|
route,
|
|
109
119
|
schema_registry,
|
|
110
120
|
request_body_registry,
|
|
111
|
-
inherited_path_params: inherited_path_params
|
|
121
|
+
inherited_path_params: inherited_path_params,
|
|
122
|
+
path_override: variant.path_override,
|
|
123
|
+
removed_params: variant.removed_params
|
|
112
124
|
)
|
|
113
125
|
method = extract_method(route)
|
|
114
126
|
path_item.add_operation(method, operation)
|
|
@@ -117,6 +129,38 @@ module Gitlab
|
|
|
117
129
|
path_item.to_h
|
|
118
130
|
end
|
|
119
131
|
|
|
132
|
+
# Expand a grouped path key into the OpenAPI paths it maps to.
|
|
133
|
+
#
|
|
134
|
+
# A single OpenAPI path key cannot express Grape's optional segments,
|
|
135
|
+
# so a route with one param-bearing optional segment fans out into two
|
|
136
|
+
# keys: a collapsed variant (segment and param removed entirely) and an
|
|
137
|
+
# expanded variant (param present, rendered as a required path param).
|
|
138
|
+
# Routes without such a segment yield a single, unchanged variant.
|
|
139
|
+
def path_variants(path_key, route)
|
|
140
|
+
param_groups = path_key.scan(OPTIONAL_SEGMENT).flatten.select { |inner| inner.match?(/\{\w+\}/) }
|
|
141
|
+
|
|
142
|
+
raise MultipleOptionalSegmentsError, multi_segment_message(route, path_key) if param_groups.length > 1
|
|
143
|
+
|
|
144
|
+
inner = param_groups.first
|
|
145
|
+
return [PathVariant.new(path_key, nil, [])] if inner.nil?
|
|
146
|
+
|
|
147
|
+
removed = inner.scan(/\{(\w+)\}/).flatten
|
|
148
|
+
collapsed = path_key.sub("(#{inner})", '').gsub(%r{//+}, '/').delete_suffix('/')
|
|
149
|
+
collapsed = '/' if collapsed.empty?
|
|
150
|
+
expanded = path_key.sub("(#{inner})", inner).gsub(%r{//+}, '/')
|
|
151
|
+
|
|
152
|
+
[
|
|
153
|
+
PathVariant.new(collapsed, collapsed, removed),
|
|
154
|
+
PathVariant.new(expanded, expanded, [])
|
|
155
|
+
]
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def multi_segment_message(route, path_key)
|
|
159
|
+
"Route '#{extract_method(route)} #{path_key}' declares more than one optional path segment " \
|
|
160
|
+
"with a named parameter. gitlab-grape-openapi supports at most one optional segment per route " \
|
|
161
|
+
"and does not generate the combinatorial set of path variants."
|
|
162
|
+
end
|
|
163
|
+
|
|
120
164
|
def extract_method(route)
|
|
121
165
|
route.request_method
|
|
122
166
|
end
|
|
@@ -38,14 +38,15 @@ module Gitlab
|
|
|
38
38
|
end
|
|
39
39
|
|
|
40
40
|
def process_class_entity(entity_class)
|
|
41
|
-
success_code = infer_success_code
|
|
42
41
|
if EntityConverter.grape_entity?(entity_class)
|
|
42
|
+
success_code = infer_success_code(with_body: true)
|
|
43
43
|
add_response_with_entity(
|
|
44
44
|
status_code: success_code,
|
|
45
45
|
description: http_status_text(success_code),
|
|
46
46
|
entity_class: entity_class
|
|
47
47
|
)
|
|
48
48
|
else
|
|
49
|
+
success_code = infer_success_code
|
|
49
50
|
add_simple_response(
|
|
50
51
|
status_code: success_code,
|
|
51
52
|
description: http_status_text(success_code)
|
|
@@ -54,20 +55,20 @@ module Gitlab
|
|
|
54
55
|
end
|
|
55
56
|
|
|
56
57
|
def process_hash_entity(entity_hash)
|
|
57
|
-
success_code = infer_success_code
|
|
58
|
-
|
|
59
58
|
if entity_hash[:model] && EntityConverter.grape_entity?(entity_hash[:model])
|
|
59
|
+
status_code = entity_hash[:code] || infer_success_code(with_body: true)
|
|
60
60
|
add_response_with_entity(
|
|
61
|
-
status_code:
|
|
62
|
-
description: entity_hash[:message] || http_status_text(
|
|
61
|
+
status_code: status_code,
|
|
62
|
+
description: entity_hash[:message] || http_status_text(status_code),
|
|
63
63
|
entity_class: entity_hash[:model],
|
|
64
64
|
example: entity_hash[:example],
|
|
65
65
|
examples: entity_hash[:examples]
|
|
66
66
|
)
|
|
67
67
|
else
|
|
68
|
+
status_code = entity_hash[:code] || infer_success_code
|
|
68
69
|
add_simple_response(
|
|
69
|
-
status_code:
|
|
70
|
-
description: entity_hash[:message] || http_status_text(
|
|
70
|
+
status_code: status_code,
|
|
71
|
+
description: entity_hash[:message] || http_status_text(status_code)
|
|
71
72
|
)
|
|
72
73
|
end
|
|
73
74
|
end
|
|
@@ -85,17 +86,19 @@ module Gitlab
|
|
|
85
86
|
|
|
86
87
|
def process_array_hash_item(definition)
|
|
87
88
|
if definition[:model] && EntityConverter.grape_entity?(definition[:model])
|
|
89
|
+
status_code = definition[:code] || infer_success_code(with_body: true)
|
|
88
90
|
add_response_with_entity(
|
|
89
|
-
status_code:
|
|
90
|
-
description: definition[:message] || http_status_text(
|
|
91
|
+
status_code: status_code,
|
|
92
|
+
description: definition[:message] || http_status_text(status_code),
|
|
91
93
|
entity_class: definition[:model],
|
|
92
94
|
example: definition[:example],
|
|
93
95
|
examples: definition[:examples]
|
|
94
96
|
)
|
|
95
97
|
else
|
|
98
|
+
status_code = definition[:code] || infer_success_code
|
|
96
99
|
add_simple_response(
|
|
97
|
-
status_code:
|
|
98
|
-
description: definition[:message] || http_status_text(
|
|
100
|
+
status_code: status_code,
|
|
101
|
+
description: definition[:message] || http_status_text(status_code)
|
|
99
102
|
)
|
|
100
103
|
end
|
|
101
104
|
end
|
|
@@ -148,10 +151,13 @@ module Gitlab
|
|
|
148
151
|
end
|
|
149
152
|
end
|
|
150
153
|
|
|
151
|
-
|
|
154
|
+
# 204 means "No Content", so it only applies when the response has no
|
|
155
|
+
# body to document. A DELETE route that renders an entity returns 200
|
|
156
|
+
# at runtime (Grape's default for a rendered body).
|
|
157
|
+
def infer_success_code(with_body: false)
|
|
152
158
|
case http_method
|
|
153
159
|
when 'POST' then 201
|
|
154
|
-
when 'DELETE' then 204
|
|
160
|
+
when 'DELETE' then with_body ? 200 : 204
|
|
155
161
|
else 200
|
|
156
162
|
end
|
|
157
163
|
end
|
data/lib/gitlab-grape-openapi.rb
CHANGED
|
@@ -45,6 +45,10 @@ module Gitlab
|
|
|
45
45
|
module GrapeOpenapi
|
|
46
46
|
GenerationError = Class.new(StandardError)
|
|
47
47
|
|
|
48
|
+
# Raised when a route declares more than one optional path segment
|
|
49
|
+
# containing a named parameter (e.g. `/foo(/:a)/bar(/:b)`).
|
|
50
|
+
MultipleOptionalSegmentsError = Class.new(GenerationError)
|
|
51
|
+
|
|
48
52
|
class << self
|
|
49
53
|
attr_writer :configuration
|
|
50
54
|
|
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.2.
|
|
4
|
+
version: 0.2.3
|
|
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-07-20 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: grape
|