gitlab-grape-openapi 0.6.0 → 0.7.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: 10a34008541dedbab4063d199bbdb94f2d669d94101be45af1023e362bf8c72f
4
- data.tar.gz: ba640eb0b0553149b7776bd53afef9eee0c2cc5316dbb4917c44c2099b138d0e
3
+ metadata.gz: b864030f21b5380efa37ccb02b953f5c8d07b2f4d2fe90eb66334e155674e2bc
4
+ data.tar.gz: 37b0f04c277c0b1f1027bdb5f0839f06641a3c6e11fd2e9498cb9a192899914b
5
5
  SHA512:
6
- metadata.gz: fd55dfbd2de53ec38abfd182f89d2dcbf2f03b916fbde38525dd8566c5984d134b21a9ff112123a7d2d9dbec0fc8077c8bf4a04d40117c902215e4a4cb6eb52b
7
- data.tar.gz: 4ece03be3b48a4f4082539446d72ab4ac68b8cd0a022ee125f777c7196dd2aac73b154bc98f1518ac078da6b13b26a80c0a0a4dfabada8f220d461b507202e2c
6
+ metadata.gz: d7e270e89449c2f1dbdfa2f6acb4bb841abf7bbb25adf064a3f41fbf2a61eb8e9f86f698d8c826cdb111cf19aa6f21269984453913fc53554ce6f7f8eb502ba8
7
+ data.tar.gz: a6e772a0f79d6d1e010ddddf7a6504a96da9d94c2ad34db08bbf58d80e7bffd8df0364dc09b361c0da5cce3d2b163a2dc24cb5d57604a543926a2c1ddc4ed5fa
data/README.md CHANGED
@@ -17,6 +17,28 @@
17
17
 
18
18
  Internal gem for generating [OpenAPI 3.0](https://spec.openapis.org/oas/v3.0.0) specifications from [Grape](https://github.com/ruby-grape/grape) API definitions, used by [`gitlab-org/gitlab`](https://gitlab.com/gitlab-org/gitlab) to publish its REST API reference.
19
19
 
20
+ ## Table of contents
21
+
22
+ - [Installation](#installation)
23
+ - [Configuration](#configuration)
24
+ - [Configuration Options](#configuration-options)
25
+ - [Annotations](#annotations)
26
+ - [Usage](#usage)
27
+ - [Generating an OpenAPI Specification](#generating-an-openapi-specification)
28
+ - [Usage with `gitlab-org/gitlab`](#usage-with-gitlab-orggitlab)
29
+ - [Architecture](#architecture)
30
+ - [Parameter constraints](#parameter-constraints)
31
+ - [Registries](#registries)
32
+ - [Response media types](#response-media-types)
33
+ - [Optional path segments](#optional-path-segments)
34
+ - [Operation IDs and request body schemas](#operation-ids-and-request-body-schemas)
35
+ - [Development](#development)
36
+ - [Running Tests](#running-tests)
37
+ - [Linting](#linting)
38
+ - [Releasing](#releasing)
39
+ - [Contributing](#contributing)
40
+ - [License](#license)
41
+
20
42
  ## Installation
21
43
 
22
44
  Add to your Gemfile:
@@ -44,6 +66,8 @@ Gitlab::GrapeOpenapi.configure do |config|
44
66
  version: 'v1',
45
67
  terms_of_service: 'https://example.com/terms'
46
68
  )
69
+ # A plain Hash (symbol or string keys) is also accepted and wrapped in Models::Info:
70
+ # config.info = { title: 'My API', version: 'v1' }
47
71
 
48
72
  # API path configuration
49
73
  config.api_prefix = "api" # Default: "api"
@@ -103,7 +127,7 @@ end
103
127
 
104
128
  | Option | Type | Default | Description |
105
129
  | ---------------------- | ------------------------------- | ------- | ---------------------------------------------------------------- |
106
- | `info` | `Models::Info` | `nil` | API metadata (title, description, version, terms of service) |
130
+ | `info` | `Models::Info` or `Hash` | `nil` | **Required.** API metadata (title, description, version, terms of service). `title` and `version` must be present and non-blank (not empty or whitespace-only), otherwise generation raises `Gitlab::GrapeOpenapi::ConfigurationError`. A `Hash` is wrapped in `Models::Info`; any other type also raises `ConfigurationError`. |
107
131
  | `api_prefix` | `String` | `"api"` | URL prefix for API routes |
108
132
  | `api_version` | `String` | `"v1"` | API version string |
109
133
  | `servers` | `Array<Models::Server>` | `[]` | Server definitions for the API |
@@ -123,6 +147,7 @@ The `annotations` configuration maps Grape route settings to OpenAPI vendor exte
123
147
  config.annotations = {
124
148
  lifecycle: 'x-gitlab-lifecycle'
125
149
  }
150
+ ```
126
151
 
127
152
  When a Grape endpoint has:
128
153
 
@@ -205,13 +230,15 @@ Generator
205
230
  The gem documents Grape's cross-field param constraints by appending a note to
206
231
  each affected parameter's (or request-body property's) `description` — for both
207
232
  query/path params (`GET`/`DELETE`) and request-body params
208
- (`POST`/`PUT`/`PATCH`). Three constraints are supported:
233
+ (`POST`/`PUT`/`PATCH`). All four of Grape's cross-field constraints are
234
+ supported:
209
235
 
210
236
  | Grape declaration | Note appended to every member |
211
237
  |-------------------|-------------------------------|
212
238
  | `mutually_exclusive :author_id, :author_username` | ``Mutually exclusive with `author_username`.`` |
213
239
  | `exactly_one_of :project_id, :project_path` | ``Exactly one of `project_id`, `project_path` must be provided.`` |
214
240
  | `at_least_one_of :assignee_id, :reviewer_id` | ``At least one of `assignee_id`, `reviewer_id` must be provided.`` |
241
+ | `all_or_none_of :extern_uid, :provider` | ``All or none of `extern_uid`, `provider` must be provided.`` |
215
242
 
216
243
  OpenAPI 3.0 has no cross-parameter constraint keyword, and the target renderer
217
244
  (Scalar) does not render JSON Schema `not`/`allOf`, so the constraint is stated
@@ -241,8 +268,7 @@ param into the route's query params, a constraint nested inside a `Hash` *query*
241
268
  filter is documented the same as a top-level one. A constraint nested inside a
242
269
  request-body object property has no top-level property to attach to and is
243
270
  skipped; when `warnings` is enabled the gem logs one line per skipped group so it
244
- is visible in source. The remaining sibling constraint (`all_or_none_of`) is not
245
- yet supported.
271
+ is visible in source.
246
272
 
247
273
  ### Registries
248
274
 
@@ -336,6 +362,17 @@ Generating the combinatorial set of paths for routes with more than one
336
362
  param-bearing optional segment is **not** supported: such a route raises
337
363
  `Gitlab::GrapeOpenapi::MultipleOptionalSegmentsError`.
338
364
 
365
+ ### Operation IDs and request body schemas
366
+
367
+ Operation IDs are derived from the HTTP method and normalized route path. If
368
+ two routes produce the same ID, the later operation receives a numeric suffix
369
+ such as `_2`, keeping IDs unique without renaming unaffected operations.
370
+
371
+ Inline request bodies are emitted as component schemas named
372
+ `<operationId>Request`. The name is independent of the body shape, so adding or
373
+ changing a field does not rename the generated schema; identical bodies on
374
+ different operations retain their own operation-specific names.
375
+
339
376
  ## Development
340
377
 
341
378
  ```bash
@@ -69,7 +69,15 @@ module Gitlab
69
69
  notes: GROUP_SENTENCE.call('At least one of')
70
70
  }.freeze
71
71
 
72
- CONSTRAINTS = [MUTUALLY_EXCLUSIVE, EXACTLY_ONE_OF, AT_LEAST_ONE_OF].freeze
72
+ ALL_OR_NONE_OF = {
73
+ classes: [
74
+ 'Grape::Validations::Validators::AllOrNoneOfValidator' # Grape all versions
75
+ ].freeze,
76
+ summary_format: '%d related parameters must be provided together, or not at all.',
77
+ notes: GROUP_SENTENCE.call('All or none of')
78
+ }.freeze
79
+
80
+ CONSTRAINTS = [MUTUALLY_EXCLUSIVE, EXACTLY_ONE_OF, AT_LEAST_ONE_OF, ALL_OR_NONE_OF].freeze
73
81
 
74
82
  # For callers that need notes alone, which is every caller that has no
75
83
  # nested scope to skip - see `#notes`.
@@ -10,23 +10,25 @@ module Gitlab
10
10
 
11
11
  def self.convert(
12
12
  route, schema_registry, request_body_registry, inherited_path_params: {},
13
- path_override: nil, removed_params: [])
13
+ path_override: nil, removed_params: [], operation_id_registry: nil)
14
14
  new(
15
15
  route,
16
16
  schema_registry,
17
17
  request_body_registry,
18
18
  inherited_path_params: inherited_path_params,
19
19
  path_override: path_override,
20
- removed_params: removed_params
20
+ removed_params: removed_params,
21
+ operation_id_registry: operation_id_registry
21
22
  ).convert
22
23
  end
23
24
 
24
25
  def initialize(
25
26
  route, schema_registry, request_body_registry, inherited_path_params: {},
26
- path_override: nil, removed_params: [])
27
+ path_override: nil, removed_params: [], operation_id_registry: nil)
27
28
  @route = route
28
29
  @schema_registry = schema_registry
29
30
  @request_body_registry = request_body_registry
31
+ @operation_id_registry = operation_id_registry
30
32
  @inherited_path_params = inherited_path_params
31
33
  # @path_override and @removed_params are set together when optional-path-segment variants exist
32
34
  @path_override = path_override
@@ -38,8 +40,10 @@ module Gitlab
38
40
  end
39
41
 
40
42
  def convert
43
+ id = operation_id_registry ? operation_id_registry.register(operation_id) : operation_id
44
+
41
45
  Models::Operation.new.tap do |operation|
42
- operation.operation_id = operation_id
46
+ operation.operation_id = id
43
47
  operation.summary = extract_description
44
48
  operation.description = annotate_oversized_constraints(extract_detail)
45
49
  operation.tags = extract_tags
@@ -47,7 +51,7 @@ module Gitlab
47
51
  operation.hidden = extract_hidden
48
52
  operation.parameters = extract_parameters
49
53
  operation.responses = ResponseConverter.new(@route, @schema_registry).convert
50
- operation.request_body = extract_request_body || {}
54
+ operation.request_body = extract_request_body(id) || {}
51
55
  operation.annotations = extract_annotations
52
56
  end
53
57
  end
@@ -55,7 +59,7 @@ module Gitlab
55
59
  private
56
60
 
57
61
  attr_reader :config, :route, :options, :pattern, :endpoint, :schema_registry, :request_body_registry,
58
- :inherited_path_params, :path_override, :removed_params
62
+ :inherited_path_params, :path_override, :removed_params, :operation_id_registry
59
63
 
60
64
  def route_method
61
65
  @route.request_method
@@ -301,12 +305,13 @@ module Gitlab
301
305
  GrapeCompat.validations_for(route, attribute)
302
306
  end
303
307
 
304
- def extract_request_body
308
+ def extract_request_body(id)
305
309
  RequestBodyConverter.convert(
306
310
  route: route,
307
311
  options: options,
308
312
  params: options[:params],
309
- request_body_registry: request_body_registry
313
+ request_body_registry: request_body_registry,
314
+ operation_id: id
310
315
  )
311
316
  end
312
317
  end
@@ -31,6 +31,7 @@ module Gitlab
31
31
  @routes = routes
32
32
  @schema_registry = schema_registry
33
33
  @request_body_registry = request_body_registry
34
+ @operation_id_registry = OperationIdRegistry.new
34
35
  @config = Gitlab::GrapeOpenapi.configuration
35
36
  @inherited_path_params = {}
36
37
  end
@@ -116,15 +117,18 @@ module Gitlab
116
117
 
117
118
  def build_path_item(routes_for_path, variant)
118
119
  path_item = Models::PathItem.new
120
+ routes_by_method = routes_for_path.group_by { |route| extract_method(route) }
121
+ .transform_values(&:last)
119
122
 
120
- routes_for_path.each do |route|
123
+ routes_by_method.each_value do |route|
121
124
  operation = OperationConverter.convert(
122
125
  route,
123
126
  schema_registry,
124
127
  request_body_registry,
125
128
  inherited_path_params: inherited_path_params,
126
129
  path_override: variant.path_override,
127
- removed_params: variant.removed_params
130
+ removed_params: variant.removed_params,
131
+ operation_id_registry: @operation_id_registry
128
132
  )
129
133
  method = extract_method(route)
130
134
  path_item.add_operation(method, operation)
@@ -9,17 +9,19 @@ module Gitlab
9
9
  GET_METHOD = 'GET'
10
10
  DELETE_METHOD = 'DELETE'
11
11
 
12
- attr_reader :route, :options, :params, :request_body_registry
12
+ attr_reader :route, :options, :params, :request_body_registry, :operation_id
13
13
 
14
- def self.convert(route:, options:, params:, request_body_registry:)
15
- new(route: route, options: options, params: params, request_body_registry: request_body_registry).convert
14
+ def self.convert(route:, options:, params:, request_body_registry:, operation_id:)
15
+ new(route: route, options: options, params: params, request_body_registry: request_body_registry,
16
+ operation_id: operation_id).convert
16
17
  end
17
18
 
18
- def initialize(route:, options:, params:, request_body_registry:)
19
+ def initialize(route:, options:, params:, request_body_registry:, operation_id:)
19
20
  @route = route
20
21
  @options = options
21
22
  @params = params
22
23
  @request_body_registry = request_body_registry
24
+ @operation_id = operation_id
23
25
  end
24
26
 
25
27
  def convert
@@ -58,7 +60,7 @@ module Gitlab
58
60
  }
59
61
  schema[:required] = required_params unless required_params.empty?
60
62
 
61
- schema_ref = request_body_registry.register(schema)
63
+ schema_ref = request_body_registry.register(schema, name: "#{operation_id}Request")
62
64
 
63
65
  {
64
66
  required: required_params.any?,
@@ -15,11 +15,13 @@ module Gitlab
15
15
  end
16
16
 
17
17
  def generate
18
+ # Resolved before initialize_tags so an invalid config.info fails before any conversion runs.
19
+ info = openapi_info
18
20
  initialize_tags
19
21
 
20
22
  {
21
23
  openapi: '3.0.0',
22
- info: Gitlab::GrapeOpenapi.configuration.info.to_h,
24
+ info: info,
23
25
  tags: tag_registry.tags.sort_by { |t| t.fetch(:name, '') },
24
26
  servers: Gitlab::GrapeOpenapi.configuration.servers.map(&:to_h),
25
27
  paths: paths,
@@ -50,9 +52,37 @@ module Gitlab
50
52
 
51
53
  private
52
54
 
55
+ def openapi_info
56
+ hash = configured_info.to_h
57
+ return hash unless blank?(hash[:title]) || blank?(hash[:version])
58
+
59
+ raise ConfigurationError, "config.info must be set with at least a title and version"
60
+ end
61
+
62
+ def configured_info
63
+ info = Gitlab::GrapeOpenapi.configuration.info
64
+
65
+ case info
66
+ when Models::Info then info
67
+ when Hash then Models::Info.new(**info.transform_keys(&:to_sym))
68
+ when nil then Models::Info.new
69
+ else
70
+ raise ConfigurationError,
71
+ "config.info must be a Gitlab::GrapeOpenapi::Models::Info or Hash, got #{info.class}"
72
+ end
73
+ end
74
+
75
+ def blank?(value)
76
+ value.to_s.strip.empty?
77
+ end
78
+
53
79
  def schemas
54
80
  entity_schemas = @schema_registry.schemas.transform_values(&:to_h)
55
81
  request_body_schemas = @request_body_registry.schemas
82
+
83
+ collisions = entity_schemas.keys & request_body_schemas.keys
84
+ raise ArgumentError, "Schema component name collision: #{collisions.join(', ')}" unless collisions.empty?
85
+
56
86
  entity_schemas.merge(request_body_schemas)
57
87
  end
58
88
  end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ module GrapeOpenapi
5
+ class OperationIdRegistry
6
+ def initialize
7
+ @operation_ids = {}
8
+ end
9
+
10
+ def register(candidate)
11
+ operation_id = candidate
12
+ suffix = 2
13
+
14
+ while @operation_ids.key?(operation_id)
15
+ operation_id = "#{candidate}_#{suffix}"
16
+ suffix += 1
17
+ end
18
+
19
+ @operation_ids[operation_id] = true
20
+ operation_id
21
+ end
22
+ end
23
+ end
24
+ end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'openssl'
4
-
5
3
  module Gitlab
6
4
  module GrapeOpenapi
7
5
  class RequestBodyRegistry
@@ -11,47 +9,19 @@ module Gitlab
11
9
 
12
10
  def initialize
13
11
  @schemas = {}
14
- @schema_hashes = {}
15
12
  end
16
13
 
17
- def register(schema)
14
+ def register(schema, name:)
18
15
  return nil if schema.blank?
19
16
 
20
- schema_hash = compute_hash(schema)
21
-
22
- if @schema_hashes.key?(schema_hash)
23
- existing_name = @schema_hashes[schema_hash]
24
- return { '$ref' => "#{SCHEMA_PATH_PREFIX}#{existing_name}" }
17
+ if @schemas.key?(name) && @schemas[name] != schema
18
+ raise ArgumentError, "Request body schema name '#{name}' is already registered"
25
19
  end
26
20
 
27
- name = "RequestBody_#{short_hash(schema_hash)}"
28
21
  @schemas[name] = schema
29
- @schema_hashes[schema_hash] = name
30
22
 
31
23
  { '$ref' => "#{SCHEMA_PATH_PREFIX}#{name}" }
32
24
  end
33
-
34
- private
35
-
36
- def compute_hash(schema)
37
- normalized = normalize_for_hash(schema)
38
- OpenSSL::Digest::SHA256.hexdigest(normalized.to_s)
39
- end
40
-
41
- def short_hash(full_hash)
42
- full_hash[0, 12]
43
- end
44
-
45
- def normalize_for_hash(obj)
46
- case obj
47
- when Hash
48
- obj.sort.map { |k, v| [k.to_s, normalize_for_hash(v)] }
49
- when Array
50
- obj.map { |v| normalize_for_hash(v) }
51
- else
52
- obj
53
- end
54
- end
55
25
  end
56
26
  end
57
27
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Gitlab
4
4
  module GrapeOpenapi
5
- VERSION = "0.6.0"
5
+ VERSION = "0.7.0"
6
6
  end
7
7
  end
@@ -6,6 +6,7 @@ require_relative "gitlab/grape_openapi/configuration"
6
6
  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
+ require_relative "gitlab/grape_openapi/operation_id_registry"
9
10
  require_relative "gitlab/grape_openapi/tag_registry"
10
11
  require_relative "gitlab/grape_openapi/normalized_path"
11
12
 
@@ -51,6 +52,9 @@ module Gitlab
51
52
  module GrapeOpenapi
52
53
  GenerationError = Class.new(StandardError)
53
54
 
55
+ # Raised when `config.info` is missing a title or version.
56
+ ConfigurationError = Class.new(GenerationError)
57
+
54
58
  # Raised when a route declares more than one optional path segment
55
59
  # containing a named parameter (e.g. `/foo(/:a)/bar(/:b)`).
56
60
  MultipleOptionalSegmentsError = Class.new(GenerationError)
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.6.0
4
+ version: 0.7.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-09-30 00:00:00.000000000 Z
11
+ date: 2026-10-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: grape
@@ -172,6 +172,7 @@ files:
172
172
  - lib/gitlab/grape_openapi/models/server_variable.rb
173
173
  - lib/gitlab/grape_openapi/models/tag.rb
174
174
  - lib/gitlab/grape_openapi/normalized_path.rb
175
+ - lib/gitlab/grape_openapi/operation_id_registry.rb
175
176
  - lib/gitlab/grape_openapi/request_body_registry.rb
176
177
  - lib/gitlab/grape_openapi/schema_registry.rb
177
178
  - lib/gitlab/grape_openapi/serializers/time.rb