gitlab-grape-openapi 0.3.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: 79aec4f1d4657df18a7ccbcf6b689c9f00f8f0b5c940d508da34b05112aa8432
4
- data.tar.gz: 275e4524ae0d61929af41ca0540ff080f49790f0a2cbb1308f4a0e6d6b79f75f
3
+ metadata.gz: 3c0d95f2bff47a6ef1c1c9d251b48b4e12748f8348b112a408cf94c97d62b6d3
4
+ data.tar.gz: 166affd62ad738cdd13b2c414bb131fd9065506c18b3dee7e32dc861ad23e3fd
5
5
  SHA512:
6
- metadata.gz: cd0dfaa149fb09c6a61bd2b8c9256d506e6bc62da716faed13e9b0c2f48932efed53b69315c330d1f91f8bf550bb5728fc284865748966b39f15b4a7d7598c67
7
- data.tar.gz: c937af8a3e5f1a4f80ae412e7d444f1b11c7d30268b5c64dd9441c3e2b6125db1b325387effa1ec917821211ee12ca7e140dd1f9e659bcab039c86d99c3e3db4
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
 
@@ -185,15 +185,87 @@ Generator
185
185
  │ ├── ParameterConverter - Converts endpoint parameters
186
186
  │ ├── ResponseConverter - Converts endpoint responses
187
187
  │ └── RequestBodyConverter - Converts request bodies
188
- └── TypeResolver - Maps Ruby/Grape types to OpenAPI types
188
+ ├── MediaTypeResolver - Maps declared media types to response schemas
189
+ ├── TypeResolver - Maps Ruby/Grape types to OpenAPI types
190
+ └── CrossFieldValidationResolver - Documents Grape cross-field validations in descriptions
189
191
  ```
190
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
+
191
215
  ### Registries
192
216
 
193
217
  - **SchemaRegistry** - Tracks converted entity schemas
194
218
  - **RequestBodyRegistry** - Tracks request body schemas
195
219
  - **TagRegistry** - Tracks API tags
196
220
 
221
+ ### Response media types
222
+
223
+ Responses default to `application/json`, described by the `$ref` of the entity
224
+ declared with `success` / `entity`. Endpoints that return a file or plain text
225
+ instead declare their media type with `produces` in the `desc` block:
226
+
227
+ ```ruby
228
+ desc 'Download an export' do
229
+ produces %w[application/octet-stream]
230
+ success code: 200
231
+ end
232
+ get ':id/export/download' do
233
+ # ...
234
+ end
235
+ ```
236
+
237
+ which becomes:
238
+
239
+ ```yaml
240
+ '200':
241
+ description: OK
242
+ content:
243
+ application/octet-stream:
244
+ schema:
245
+ type: string
246
+ format: binary
247
+ ```
248
+
249
+ `produces` accepts a bare String as well as an Array, and each declared type
250
+ gets its own entry in `content`. The mapping is:
251
+
252
+ | Declared media type | Emitted schema |
253
+ | ------------------- | -------------- |
254
+ | `application/octet-stream`, `application/gzip`, `application/x-tar` | `{ type: string, format: binary }` |
255
+ | `text/*`, `application/yaml` | `{ type: string }` |
256
+ | `application/json` | none — the entity `$ref` describes it |
257
+
258
+ A type outside this table is **skipped** rather than guessed at, since assuming
259
+ "binary" would misdescribe a structured payload such as `application/xml`. The
260
+ response keeps its bare shape, so a new media type needs adding to
261
+ `MediaTypeResolver` before it appears in the spec.
262
+
263
+ `produces` only affects the success response; failure responses stay JSON.
264
+
265
+ A route that declares `success File` (with no `produces`) is treated as
266
+ `application/octet-stream`, matching `grape-swagger`. An explicit `produces`
267
+ takes precedence, so it can override that inference.
268
+
197
269
  ### Optional path segments
198
270
 
199
271
  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
@@ -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
@@ -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
@@ -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
 
@@ -214,16 +231,11 @@ module Gitlab
214
231
  end
215
232
 
216
233
  def normalized_path
217
- @normalized_path ||= path_override || normalize_path_pattern.gsub('{version}', config.api_version)
234
+ @normalized_path ||= path_override || normalize_path_pattern
218
235
  end
219
236
 
220
237
  def normalize_path_pattern
221
- path = pattern.origin
222
- path
223
- .gsub(/\(\.:format\)$/, '')
224
- .gsub(/[()\\]/, '')
225
- .gsub(/:\w+/) { |match| "{#{match[1..]}}" }
226
- .gsub('{version}', config.api_version)
238
+ NormalizedPath.new(pattern.origin).to_display_path(config.api_version)
227
239
  end
228
240
 
229
241
  def camelize(string)