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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: edcdc6aad3dda3c3aee4a30f682dd547c87d5606b8f6a29bbc132230c8e51176
4
- data.tar.gz: 5d51d13a6a07d747535f51b1a5adcb5dbf4ce9da20085ddc3eafb6721cdd4ec9
3
+ metadata.gz: 6b3783b6b78454e5f8a76c0f2da60864f1f890272e8d9604cac6c0152becea17
4
+ data.tar.gz: 21b5cec8a2792e118c480334b358aa62d3fc4d85553fbbe7ace7871a7fc91709
5
5
  SHA512:
6
- metadata.gz: 92c6f934a4207b4156b8cb5a7c4db3bb502ef2c774246baefa9e33f69429671ddfcc427a0c45a9b34898beba1fa9f5df203d7fda6a26dd2fd860ba82c7d81502
7
- data.tar.gz: ba53cc8fd5235fe8ffc9d43d9d5133855bd4384aed3c928b1b4146125da9e54829d7302051dc7cd7bc0fd16040973c091e307a5338a6b503ad9e2fdbc8b5c801
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 = build_properties
56
+ schema.properties = build_exposures_properties(root_exposures)
57
57
  end
58
58
  end
59
59
 
60
- def build_properties
61
- root_exposures.each_with_object({}) do |exposure, properties|
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(route, schema_registry, request_body_registry, inherited_path_params: {})
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(route, schema_registry, request_body_registry, inherited_path_params: {})
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 ||= begin
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 = grouped_routes.transform_values do |routes_for_path|
29
- build_path_item(routes_for_path)
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: entity_hash[:code] || success_code,
62
- description: entity_hash[:message] || http_status_text(entity_hash[:code] || success_code),
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: entity_hash[:code] || success_code,
70
- description: entity_hash[:message] || http_status_text(entity_hash[:code] || success_code)
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: definition[:code] || infer_success_code,
90
- description: definition[:message] || http_status_text(definition[:code] || infer_success_code),
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: definition[:code] || infer_success_code,
98
- description: definition[:message] || http_status_text(definition[:code] || infer_success_code)
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
- def infer_success_code
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Gitlab
4
4
  module GrapeOpenapi
5
- VERSION = "0.2.1"
5
+ VERSION = "0.2.3"
6
6
  end
7
7
  end
@@ -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.1
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-06-30 00:00:00.000000000 Z
11
+ date: 2026-07-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: grape