gitlab-grape-openapi 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0abbc799f3af1d884ee9e543865658f3709007fe0cb09febea2792678c09d071
4
- data.tar.gz: 815581f530dbedff8b9cd89135507640df91e58435f42a62c78bd1d213388d46
3
+ metadata.gz: 3c0d95f2bff47a6ef1c1c9d251b48b4e12748f8348b112a408cf94c97d62b6d3
4
+ data.tar.gz: 166affd62ad738cdd13b2c414bb131fd9065506c18b3dee7e32dc861ad23e3fd
5
5
  SHA512:
6
- metadata.gz: 2574c73242cbbb597331b2919a05ced49beba5dbe733683d88cf90d1ae71d2e55ecffbc2443b399ad5d52e9dc74e53cee7360f3e998b1bc0e8cf7ae8c4603138
7
- data.tar.gz: 2bcf5665113a86d6aee987a543fb101ce582bda2801498765a89ca5c42432b8da9d6461bafeb4cfea11f522cbf75ea77497b8ce580329c088ab0ca485007b795
6
+ metadata.gz: a8695acf55cddb13dc6ad5078a2ea7fca4e251abe152f9c45b9457018bc47eda0708ff7a9e018713cf6ba6ac77532d65368a31112cc4cb1d4b61a9be9d1c9f80
7
+ data.tar.gz: 7de023af2bf2810e32513a09b2ea6c2520ea1b1d4a46be61f5ff6acf84637dfe6d1ab922bb51a0b223186f0baa3530ad9b19b229eca8c535e63f3e25c1936ee0
data/README.md CHANGED
@@ -93,17 +93,17 @@ end
93
93
 
94
94
  ### Configuration Options
95
95
 
96
- | Option | Type | Default | Description |
97
- | ---------------------- | ------------------------------- | ------- | ------------------------------------------------------------ |
98
- | `info` | `Models::Info` | `nil` | API metadata (title, description, version, terms of service) |
99
- | `api_prefix` | `String` | `"api"` | URL prefix for API routes |
100
- | `api_version` | `String` | `"v1"` | API version string |
101
- | `servers` | `Array<Models::Server>` | `[]` | Server definitions for the API |
102
- | `security_schemes` | `Array<Models::SecurityScheme>` | `[]` | Authentication/authorization schemes |
103
- | `excluded_api_classes` | `Array<String>` | `[]` | API class names to exclude from generation |
104
- | `tag_overrides` | `Hash` | `{}` | Map of tag names to their display overrides |
105
- | `annotations` | `Hash` | `{}` | Map of Grape route settings to OpenAPI extension names |
106
- | `warnings` | `Boolean` | `false` | Emit stderr warnings for synthesized (undeclared) path params |
96
+ | Option | Type | Default | Description |
97
+ | ---------------------- | ------------------------------- | ------- | ---------------------------------------------------------------- |
98
+ | `info` | `Models::Info` | `nil` | API metadata (title, description, version, terms of service) |
99
+ | `api_prefix` | `String` | `"api"` | URL prefix for API routes |
100
+ | `api_version` | `String` | `"v1"` | API version string |
101
+ | `servers` | `Array<Models::Server>` | `[]` | Server definitions for the API |
102
+ | `security_schemes` | `Array<Models::SecurityScheme>` | `[]` | Authentication/authorization schemes |
103
+ | `excluded_api_classes` | `Array<String>` | `[]` | API class names to exclude from generation |
104
+ | `tag_overrides` | `Hash` | `{}` | Map of tag names to their display overrides |
105
+ | `annotations` | `Hash` | `{}` | Map of Grape route settings to OpenAPI extension names |
106
+ | `warnings` | `Boolean` | `false` | Emit stderr warnings for synthesized params, skipped constraints |
107
107
 
108
108
  ### Annotations
109
109
 
@@ -186,9 +186,32 @@ Generator
186
186
  │ ├── ResponseConverter - Converts endpoint responses
187
187
  │ └── RequestBodyConverter - Converts request bodies
188
188
  ├── MediaTypeResolver - Maps declared media types to response schemas
189
- └── TypeResolver - Maps Ruby/Grape types to OpenAPI types
189
+ ├── TypeResolver - Maps Ruby/Grape types to OpenAPI types
190
+ └── CrossFieldValidationResolver - Documents Grape cross-field validations in descriptions
190
191
  ```
191
192
 
193
+ ### Parameter constraints
194
+
195
+ The gem documents Grape's `mutually_exclusive` param constraint by appending a
196
+ note such as ``Mutually exclusive with `author_username`.`` to each affected
197
+ parameter's (or request-body property's) `description` — for both query/path
198
+ params (`GET`/`DELETE`) and request-body params (`POST`/`PUT`/`PATCH`).
199
+
200
+ OpenAPI 3.0 has no cross-parameter constraint keyword, and the target renderer
201
+ (Scalar) does not render JSON Schema `not`/`allOf`, so the constraint is stated
202
+ in prose; Grape enforces it at runtime (HTTP 400). A param appearing in several
203
+ declarations lists every partner in its note.
204
+
205
+ The gem reads the validation stack through `grape_compat`, so this works on both
206
+ Grape 2.4 and 3.2. Each group is keyed by the full bracketed param path
207
+ (`not[author_id]`) rather than the bare name. Because Grape flattens every nested
208
+ param into the route's query params, a constraint nested inside a `Hash` *query*
209
+ filter is documented the same as a top-level one. A constraint nested inside a
210
+ request-body object property has no top-level property to attach to and is
211
+ skipped; when `warnings` is enabled the gem logs one line per skipped group so it
212
+ is visible in source. The sibling constraints (`exactly_one_of`,
213
+ `at_least_one_of`, `all_or_none_of`) are not yet supported.
214
+
192
215
  ### Registries
193
216
 
194
217
  - **SchemaRegistry** - Tracks converted entity schemas
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Determines whether a param is nullable.
4
+ #
5
+ # Grape default behaviour sets nullability true for plain params.
6
+ # Two declarations remove it: a `default:`, which Grape's
7
+ # DefaultValidator substitutes for the null before the endpoint runs, and a path
8
+ # parameter, which is a required URL segment.
9
+ #
10
+ # The `minLength: 1` branch is mutually exclusive with nullability and stays coupled
11
+ # here deliberately: applying the two independently would mark every required enum
12
+ # nullable.
13
+ #
14
+ # NOTE: `param_options[:allow_blank]` is always nil. Grape omits allow_blank from
15
+ # route.params, so that clause is dead and only `required && values` fires.
16
+
17
+ module Gitlab
18
+ module GrapeOpenapi
19
+ module Concerns
20
+ module Nullability
21
+ private
22
+
23
+ def apply_nullability!(schema, param_options, in_value: nil)
24
+ if blank_rejected?(param_options)
25
+ schema[:minLength] = 1 if schema[:type] == 'string'
26
+ elsif nullable?(param_options, in_value)
27
+ mark_nullable!(schema)
28
+ end
29
+ end
30
+
31
+ def blank_rejected?(param_options)
32
+ param_options[:allow_blank] == false ||
33
+ (param_options[:required] && param_options[:values])
34
+ end
35
+
36
+ def nullable?(param_options, in_value)
37
+ return false if in_value == 'path'
38
+
39
+ param_options[:default].nil?
40
+ end
41
+
42
+ # Grape substitutes the default regardless of which union member matched, so
43
+ # nullability applies to every member or none.
44
+ def mark_nullable!(schema)
45
+ members = schema[:oneOf] || schema[:anyOf]
46
+ return members.each { |member| member[:nullable] = true } if members
47
+
48
+ schema[:nullable] = true
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Documents Grape cross-field param validations by appending a note to each
4
+ # affected parameter's / request-body property's description.
5
+ #
6
+ # OpenAPI 3.0 has no cross-parameter keyword and Scalar (the renderer) does not
7
+ # render JSON Schema `not`/`allOf`, so these constraints are stated in prose;
8
+ # Grape enforces them at runtime (HTTP 400).
9
+ #
10
+ # Each cross-field validator is a CONSTRAINTS entry pairing the validator class
11
+ # names it carries across supported Grape versions (Grape 3.2 renamed several)
12
+ # with a note strategy. A strategy receives every group of its kind found on the
13
+ # route (each group an array of top-level attribute-name strings) and returns
14
+ # `{ param => note }`.
15
+ #
16
+ # The validations read and the Grape 2.x/3.2 shape difference are isolated in
17
+ # GrapeCompat.
18
+
19
+ module Gitlab
20
+ module GrapeOpenapi
21
+ module Converters
22
+ class CrossFieldValidationResolver
23
+ MUTUALLY_EXCLUSIVE = {
24
+ classes: [
25
+ 'Grape::Validations::Validators::MutualExclusionValidator', # Grape < 3.2
26
+ 'Grape::Validations::Validators::MutuallyExclusiveValidator' # Grape >= 3.2
27
+ ].freeze,
28
+ notes: lambda do |groups|
29
+ partners = Hash.new { |hash, key| hash[key] = [] }
30
+
31
+ groups.each do |group|
32
+ group.each do |name|
33
+ (group - [name]).each do |other|
34
+ partners[name] << other unless partners[name].include?(other)
35
+ end
36
+ end
37
+ end
38
+
39
+ partners.transform_values do |others|
40
+ "Mutually exclusive with #{others.map { |name| "`#{name}`" }.join(', ')}."
41
+ end
42
+ end
43
+ }.freeze
44
+
45
+ CONSTRAINTS = [MUTUALLY_EXCLUSIVE].freeze
46
+
47
+ # For callers that need notes alone, which is every caller that has no
48
+ # nested scope to skip - see `#notes`.
49
+ def self.notes_for(route, attributes)
50
+ new(route, attributes).notes
51
+ end
52
+
53
+ # Appends a note to an existing description so the two read as separate
54
+ # sentences. Returns the note alone when there is no
55
+ # existing text.
56
+ def self.append_note(description, note)
57
+ text = description.to_s.strip
58
+ return note if text.empty?
59
+
60
+ text += '.' unless text.end_with?('.', '!', '?')
61
+ "#{text} #{note}"
62
+ end
63
+
64
+ # `attributes` are the param names the caller is able to annotate: a
65
+ # route's query params, or a request body's top-level properties. Both
66
+ # readers below share one walk of the route's validations, so a caller
67
+ # that needs both pays for it once.
68
+ def initialize(route, attributes)
69
+ @route = route
70
+ @attributes = attributes
71
+ end
72
+
73
+ # Per-param description notes for every cross-field constraint on the
74
+ # route, e.g. { 'files' => 'Mutually exclusive with `content`.' }.
75
+ # Notes from different constraints on the same param are
76
+ # joined. Returns {} when there are none.
77
+ def notes
78
+ @notes ||= matched_entries.each_with_object({}) do |(constraint, entries), result|
79
+ groups = annotatable_groups(entries)
80
+ next if groups.empty?
81
+
82
+ constraint[:notes].call(groups).each do |param, note|
83
+ result[param] = self.class.append_note(result[param], note)
84
+ end
85
+ end
86
+ end
87
+
88
+ # The groups `notes` could NOT annotate (a member isn't one of `attributes`).
89
+ def skipped
90
+ @skipped ||= matched_entries.flat_map do |_constraint, entries|
91
+ constrained_groups(entries).reject { |group| fully_known?(group) }
92
+ end.uniq
93
+ end
94
+
95
+ private
96
+
97
+ attr_reader :route, :attributes
98
+
99
+ def known_names
100
+ @known_names ||= Array(attributes).map(&:to_s)
101
+ end
102
+
103
+ # Groups all validations on the route
104
+ # Returns only validations with classes in CONSTRAINTS:
105
+ # [[MUTUALLY_EXCLUSIVE, [
106
+ # { validator_class: Grape::Validations::Validators::MutualExclusionValidator,
107
+ # full_names: ["content", "files"] }
108
+ # ]]]
109
+ def matched_entries
110
+ @matched_entries ||= begin
111
+ by_validator = GrapeCompat.all_validations(route).group_by { |entry| validator_name(entry) }
112
+
113
+ CONSTRAINTS.map do |constraint|
114
+ [constraint, by_validator.values_at(*constraint[:classes]).compact.flatten]
115
+ end
116
+ end
117
+ end
118
+
119
+ # Groups whose members are all in the known set. The exact complement of
120
+ # what `skipped` keeps, so both derive from `fully_known?` rather than
121
+ # restating the condition and risking drift.
122
+ def annotatable_groups(entries)
123
+ constrained_groups(entries).select { |group| fully_known?(group) }
124
+ end
125
+
126
+ # Every distinct group a constraint declares. A single-member group
127
+ # constrains nothing - Grape accepts `mutually_exclusive :a` - so it is
128
+ # neither annotated nor reported as skipped.
129
+ def constrained_groups(entries)
130
+ entries.map { |entry| group_members(entry) }.uniq.select { |group| group.length >= 2 }
131
+ end
132
+
133
+ # Whether the caller can annotate every member of the group.
134
+ def fully_known?(group)
135
+ (group - known_names).empty?
136
+ end
137
+
138
+ # eg. => 'Grape::Validations::Validators::MutuallyExclusiveValidator'
139
+ def validator_name(entry)
140
+ entry[:validator_class]&.name
141
+ end
142
+
143
+ # Full bracketed paths
144
+ # top level eg. ["author_id", "author_username"]
145
+ # nested(nested under `not:`) eg. ["not[author_id]", "not[author_username]"]
146
+ def group_members(entry)
147
+ Array(entry[:full_names]).map(&:to_s)
148
+ end
149
+ end
150
+ end
151
+ end
152
+ end
@@ -77,10 +77,10 @@ module Gitlab
77
77
  params = if options[:params].empty?
78
78
  []
79
79
  else
80
- options[:params].filter_map do |key, options|
80
+ options[:params].filter_map do |key, param_options|
81
81
  Converters::ParameterConverter.convert(
82
82
  key,
83
- options: options,
83
+ options: annotate_constraint(key, param_options),
84
84
  validations: validations_for(key.to_sym),
85
85
  route: route
86
86
  )
@@ -94,6 +94,23 @@ module Gitlab
94
94
  params.reject { |param| removed.include?(param.name.to_s) }
95
95
  end
96
96
 
97
+ def annotate_constraint(key, param_options)
98
+ note = cross_field_notes[key.to_s]
99
+ return param_options unless note
100
+
101
+ param_options.merge(desc: Converters::CrossFieldValidationResolver.append_note(param_options[:desc], note))
102
+ end
103
+
104
+ # Per-param cross-field constraint notes to fold into descriptions, e.g.
105
+ # { 'author_id' => 'Mutually exclusive with `author_username`.' }.
106
+ # Keyed by the full bracketed param name, so a nested-Hash query filter
107
+ # (`not[author_id]`, which Grape flattens into `options[:params]`) is
108
+ # annotated the same way a top-level param is.
109
+ def cross_field_notes
110
+ @cross_field_notes ||=
111
+ Converters::CrossFieldValidationResolver.notes_for(route, options[:params].keys.map(&:to_s))
112
+ end
113
+
97
114
  def inject_missing_path_parameters(params)
98
115
  declared_names = params.map(&:name).to_set
99
116
 
@@ -218,9 +235,7 @@ module Gitlab
218
235
  end
219
236
 
220
237
  def normalize_path_pattern
221
- NormalizedPath.new(pattern.origin).to_s
222
- .gsub(/[()\\]/, '')
223
- .gsub('{version}', config.api_version)
238
+ NormalizedPath.new(pattern.origin).to_display_path(config.api_version)
224
239
  end
225
240
 
226
241
  def camelize(string)
@@ -9,6 +9,7 @@ module Gitlab
9
9
  include Concerns::LimitResolver
10
10
  include Concerns::FailFastAnnotatable
11
11
  include Concerns::RegexConverter
12
+ include Concerns::Nullability
12
13
 
13
14
  attr_reader :name, :options, :validations, :route
14
15
 
@@ -57,7 +58,7 @@ module Gitlab
57
58
  build_basic_schema(object_type, object_format)
58
59
  end
59
60
 
60
- apply_allow_blank(built_schema)
61
+ apply_nullability!(built_schema, options, in_value: in_value)
61
62
  apply_limit!(built_schema, validations)
62
63
  apply_array_enum!(built_schema, options[:values])
63
64
  apply_default!(built_schema, options[:default], example: example)
@@ -203,24 +204,6 @@ module Gitlab
203
204
 
204
205
  super
205
206
  end
206
-
207
- # allow_blank defaults to true
208
- # when `allow_blank: false` for a string type minLength should be set to 1
209
- # when param is required and values option used, the param is not nullable
210
- def apply_allow_blank(schema)
211
- union_members = schema[:oneOf] || schema[:anyOf]
212
-
213
- if options[:allow_blank] == false || (options[:required] && options[:values])
214
- schema[:minLength] = 1 if schema[:type] == 'string'
215
- elsif in_value != 'path'
216
- # path parameters are never nullable because they are required URL segments
217
- if union_members
218
- union_members.each { |s| s[:nullable] = true }
219
- else
220
- schema[:nullable] = true
221
- end
222
- end
223
- end
224
207
  end
225
208
  end
226
209
  end
@@ -50,6 +50,8 @@ module Gitlab
50
50
  required_params << key.to_s if param_options[:required]
51
51
  end
52
52
 
53
+ annotate_cross_field_constraints!(properties)
54
+
53
55
  schema = {
54
56
  type: 'object',
55
57
  properties: properties
@@ -68,6 +70,43 @@ module Gitlab
68
70
  }
69
71
  end
70
72
 
73
+ # Documents cross-field constraints (mutually_exclusive, ...) in each
74
+ # affected property's description. OpenAPI has no cross-property keyword
75
+ # and Scalar does not render JSON Schema `not` so the validation constraint
76
+ # is expressed in prose.
77
+ def annotate_cross_field_constraints!(properties)
78
+ resolver = CrossFieldValidationResolver.new(route, properties.keys)
79
+
80
+ resolver.notes.each do |name, note|
81
+ property = properties[name]
82
+ next unless property
83
+
84
+ property[:description] = CrossFieldValidationResolver.append_note(property[:description], note)
85
+ end
86
+
87
+ warn_skipped_cross_field_constraints(resolver.skipped)
88
+ end
89
+
90
+ # Warns about a constraint we cannot document yet, because a member of the
91
+ # group is not a property of this body. Usually the group is nested inside
92
+ # an object property - GET/DELETE flatten such params into the query and
93
+ # document them, POST/PUT/PATCH bodies keep the nesting - but a member
94
+ # dropped from the body for another reason (a path param, or one hidden
95
+ # with `documentation: { hidden: true }`) lands here too, hence the wording.
96
+ def warn_skipped_cross_field_constraints(skipped)
97
+ return unless config.warnings
98
+
99
+ path = NormalizedPath.new(route.pattern.origin).to_display_path(config.api_version)
100
+ skipped.each do |group|
101
+ warn "[gitlab-grape-openapi] skipped cross-field constraint: " \
102
+ "#{route_method} #{path} params=#{group.join(',')} (not a top-level body property)"
103
+ end
104
+ end
105
+
106
+ def config
107
+ @config ||= Gitlab::GrapeOpenapi.configuration
108
+ end
109
+
71
110
  def content_type(body_params)
72
111
  custom_content_type = extract_consumes_content_type
73
112
  return custom_content_type if custom_content_type
@@ -11,12 +11,8 @@ module Gitlab
11
11
  module GrapeCompat
12
12
  class << self
13
13
  # Declared validations for a single attribute, newest scope only.
14
- #
15
- # Reads `new_values` rather than `[]` or `route[:saved_validations]` on
16
- # purpose: those also include validations inherited from parent scopes,
17
- # which would change the generated output.
18
14
  def validations_for(route, attribute)
19
- validations = route.app.inheritable_setting.namespace_stackable.new_values[:validations]
15
+ validations = declared_validations(route)
20
16
  return unless validations
21
17
 
22
18
  validations.filter_map do |validation|
@@ -25,6 +21,16 @@ module Gitlab
25
21
  end
26
22
  end
27
23
 
24
+ # Every declared validation for a route, normalized, newest scope only.
25
+ # Unlike `validations_for` this keeps group validators (mutually_exclusive
26
+ # and friends) whose `:attributes` span several params.
27
+ def all_validations(route)
28
+ validations = declared_validations(route)
29
+ return [] unless validations
30
+
31
+ validations.filter_map { |validation| normalize(validation) }
32
+ end
33
+
28
34
  # The `desc:` an author writes on `route_param`. Grape forwards only `type:` into
29
35
  # the `requires` it declares internally and discards the rest, so the description
30
36
  # never reaches the route's params. It survives on the namespace whose space is
@@ -43,8 +49,21 @@ module Gitlab
43
49
 
44
50
  private
45
51
 
52
+ # The route's raw, un-normalized validation stack, and the only place this
53
+ # gem reaches into Grape for it.
54
+ #
55
+ # Reads `new_values` rather than `[]` or `route[:saved_validations]` on
56
+ # purpose: those also include validations inherited from parent scopes,
57
+ # which would change the generated output.
58
+ def declared_validations(route)
59
+ route.app.inheritable_setting.namespace_stackable.new_values[:validations]
60
+ end
61
+
46
62
  def normalize(validation)
47
- return validation if validation.is_a?(Hash)
63
+ if validation.is_a?(Hash) # Grape < 3.2
64
+ # Do not mutate the author's own Hash; add the derived key on a copy.
65
+ return validation.merge(full_names: full_names_for(validation[:params_scope], validation[:attributes]))
66
+ end
48
67
 
49
68
  # Grape 3.2's ContractScopeValidator declares no attributes, so it maps
50
69
  # to no parameter.
@@ -57,9 +76,26 @@ module Gitlab
57
76
  # carry regexp patterns and limits. Switch to the reader if one is
58
77
  # added upstream.
59
78
  options: validation.instance_variable_get(:@options),
60
- opts: { fail_fast: validation.fail_fast? }
79
+ opts: { fail_fast: validation.fail_fast? },
80
+ # Full bracketed paths (e.g. "filter[x]") for cross-field constraints
81
+ # in nested scopes; equal to the bare name at the root. No public
82
+ # reader for the scope on 3.2, hence the ivar.
83
+ full_names: full_names_for(validation.instance_variable_get(:@scope), validation.attrs)
61
84
  }
62
85
  end
86
+
87
+ # Maps each attribute to its full bracketed path via the param scope, so a
88
+ # nested `x` becomes "filter[x]" while a top-level `x` stays "x". Falls
89
+ # back to the bare name if the scope cannot resolve it.
90
+ def full_names_for(scope, attributes)
91
+ Array(attributes).map do |attribute|
92
+ if scope.respond_to?(:full_name)
93
+ scope.full_name(attribute).to_s
94
+ else
95
+ attribute.to_s
96
+ end
97
+ end
98
+ end
63
99
  end
64
100
  end
65
101
  end
@@ -10,6 +10,7 @@ module Gitlab
10
10
  include Concerns::LimitResolver
11
11
  include Concerns::FailFastAnnotatable
12
12
  include Concerns::RegexConverter
13
+ include Concerns::Nullability
13
14
 
14
15
  def initialize(route:, key:, param_options:)
15
16
  @route = route
@@ -29,7 +30,7 @@ module Gitlab
29
30
  built_schema = build_resolved_schema(object_type, object_format)
30
31
  end
31
32
 
32
- apply_allow_blank(built_schema)
33
+ apply_nullability!(built_schema, param_options)
33
34
  apply_limit!(built_schema, validations)
34
35
  apply_array_enum!(built_schema, param_options[:values])
35
36
  apply_default!(built_schema, param_options[:default])
@@ -221,18 +222,6 @@ module Gitlab
221
222
  def validations_for(attribute)
222
223
  GrapeCompat.validations_for(route, attribute)
223
224
  end
224
-
225
- def apply_allow_blank(schema)
226
- union_members = schema[:oneOf] || schema[:anyOf]
227
-
228
- if param_options[:allow_blank] == false || (param_options[:required] && param_options[:values])
229
- schema[:minLength] = 1 if schema[:type] == 'string'
230
- elsif union_members
231
- union_members.each { |s| s[:nullable] = true }
232
- else
233
- schema[:nullable] = true
234
- end
235
- end
236
225
  end
237
226
  end
238
227
  end
@@ -21,6 +21,11 @@ module Gitlab
21
21
  PLACEHOLDER = /[:*](\w+)/
22
22
  NORMALIZED_PLACEHOLDER = /\{(\w+)\}/
23
23
 
24
+ # Grape's optional-segment markup: the parentheses grouping an optional
25
+ # segment, and the backslashes escaping a literal parenthesis. Both are
26
+ # noise once a path is rendered for a human.
27
+ OPTIONAL_SEGMENT_MARKUP = /[()\\]/
28
+
24
29
  # The API version is substituted away with the configured value before a path
25
30
  # is emitted, so it never surfaces as a path parameter.
26
31
  API_VERSION_PLACEHOLDER = 'version'
@@ -40,6 +45,13 @@ module Gitlab
40
45
  .gsub(PLACEHOLDER) { "{#{Regexp.last_match(1)}}" }
41
46
  end
42
47
 
48
+ # The path as shown to humans in emitted paths and warnings: placeholders
49
+ # collapsed (via `to_s`), optional-segment markup removed, and the API
50
+ # version substituted in for `{version}`.
51
+ def to_display_path(api_version)
52
+ to_s.gsub(OPTIONAL_SEGMENT_MARKUP, '').gsub("{#{API_VERSION_PLACEHOLDER}}", api_version)
53
+ end
54
+
43
55
  def placeholder_names
44
56
  # Scanning whole `{name}` placeholders sidesteps the boundary problem a regex
45
57
  # over the raw pattern has: real routes introduce a placeholder after `/`,
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Gitlab
4
4
  module GrapeOpenapi
5
- VERSION = "0.4.0"
5
+ VERSION = "0.5.0"
6
6
  end
7
7
  end
@@ -15,12 +15,14 @@ require_relative "gitlab/grape_openapi/concerns/constraint_applier"
15
15
  require_relative "gitlab/grape_openapi/concerns/limit_resolver"
16
16
  require_relative "gitlab/grape_openapi/concerns/fail_fast_annotatable"
17
17
  require_relative "gitlab/grape_openapi/concerns/regex_converter"
18
+ require_relative "gitlab/grape_openapi/concerns/nullability"
18
19
 
19
20
  # Serializers
20
21
  require_relative "gitlab/grape_openapi/serializers/time"
21
22
 
22
23
  # Converters
23
24
  require_relative "gitlab/grape_openapi/converters/coercer_resolver"
25
+ require_relative "gitlab/grape_openapi/converters/cross_field_validation_resolver"
24
26
  require_relative "gitlab/grape_openapi/converters/entity_converter"
25
27
  require_relative "gitlab/grape_openapi/converters/media_type_resolver"
26
28
  require_relative "gitlab/grape_openapi/converters/type_resolver"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gitlab-grape-openapi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - group::api
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-19 00:00:00.000000000 Z
11
+ date: 2026-09-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: grape
@@ -142,10 +142,12 @@ files:
142
142
  - lib/gitlab/grape_openapi/concerns/constraint_applier.rb
143
143
  - lib/gitlab/grape_openapi/concerns/fail_fast_annotatable.rb
144
144
  - lib/gitlab/grape_openapi/concerns/limit_resolver.rb
145
+ - lib/gitlab/grape_openapi/concerns/nullability.rb
145
146
  - lib/gitlab/grape_openapi/concerns/regex_converter.rb
146
147
  - lib/gitlab/grape_openapi/concerns/serializable.rb
147
148
  - lib/gitlab/grape_openapi/configuration.rb
148
149
  - lib/gitlab/grape_openapi/converters/coercer_resolver.rb
150
+ - lib/gitlab/grape_openapi/converters/cross_field_validation_resolver.rb
149
151
  - lib/gitlab/grape_openapi/converters/entity_converter.rb
150
152
  - lib/gitlab/grape_openapi/converters/media_type_resolver.rb
151
153
  - lib/gitlab/grape_openapi/converters/operation_converter.rb