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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 79aec4f1d4657df18a7ccbcf6b689c9f00f8f0b5c940d508da34b05112aa8432
4
- data.tar.gz: 275e4524ae0d61929af41ca0540ff080f49790f0a2cbb1308f4a0e6d6b79f75f
3
+ metadata.gz: 0abbc799f3af1d884ee9e543865658f3709007fe0cb09febea2792678c09d071
4
+ data.tar.gz: 815581f530dbedff8b9cd89135507640df91e58435f42a62c78bd1d213388d46
5
5
  SHA512:
6
- metadata.gz: cd0dfaa149fb09c6a61bd2b8c9256d506e6bc62da716faed13e9b0c2f48932efed53b69315c330d1f91f8bf550bb5728fc284865748966b39f15b4a7d7598c67
7
- data.tar.gz: c937af8a3e5f1a4f80ae412e7d444f1b11c7d30268b5c64dd9441c3e2b6125db1b325387effa1ec917821211ee12ca7e140dd1f9e659bcab039c86d99c3e3db4
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
- oneOf: types.map { |type| build_type_schema(type, documentation) },
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.gsub('{version}', config.api_version)
217
+ @normalized_path ||= path_override || normalize_path_pattern
218
218
  end
219
219
 
220
220
  def normalize_path_pattern
221
- path = pattern.origin
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::Serializable
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
- # 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'
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
- apply_union_default!(members)
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
- # When a union (oneOf) schema has a `default:`, attach it to every member
192
- # whose schema can accept the default value. For arrays this includes
193
- # checking the items type so `[1, 2]` lands on `items: { type: integer }`
194
- # but not on `items: { type: string }`. Empty arrays are type-agnostic
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
- def array_member_accepts?(member, default)
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 schema[:oneOf]
243
- schema[:oneOf].each { |s| s[:nullable] = true }
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
- OPTIONAL_SEGMENT = /\(([^()]*)\)/
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 registers catch-all routes with HTTP method * (matches any method) and
90
- # paths containing *path (wildcard segments). Neither is valid OpenAPI: * isn't
91
- # an HTTP method, and *path isn't a valid path segment. These are internal
92
- # Grape routing artifacts, not actual API endpoints.
93
- method == '*' || path.include?('*')
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
- path = route.pattern.origin
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 = path_key.sub("(#{inner})", '').gsub(%r{//+}, '/').delete_suffix('/')
162
+ collapsed = unescape_parens(squeeze_slashes(path_key.gsub(OPTIONAL_SEGMENT, '')).delete_suffix('/'))
149
163
  collapsed = '/' if collapsed.empty?
150
- expanded = path_key.sub("(#{inner})", inner).gsub(%r{//+}, '/')
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 = @route.options[:entity] || @route.options[:success]
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
- def add_simple_response(status_code:, description:)
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
- path = @route.path
178
- .gsub('.:format', '')
179
- .gsub(':version', '')
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::Serializable
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
- { oneOf: types.map { |type| Converters::TypeResolver.resolve_union_member(type) } }
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 schema[:oneOf]
236
- schema[:oneOf].each { |s| s[:nullable] = true }
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
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'digest'
3
+ require 'openssl'
4
4
 
5
5
  module Gitlab
6
6
  module GrapeOpenapi
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Gitlab
4
4
  module GrapeOpenapi
5
- VERSION = "0.3.0"
5
+ VERSION = "0.4.0"
6
6
  end
7
7
  end
@@ -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.3.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-10 00:00:00.000000000 Z
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