gitlab-grape-openapi 0.0.0 → 0.1.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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +1 -1
  3. data/README.md +222 -4
  4. data/lib/gitlab/grape_openapi/concerns/fail_fast_annotatable.rb +23 -0
  5. data/lib/gitlab/grape_openapi/concerns/limit_resolver.rb +31 -0
  6. data/lib/gitlab/grape_openapi/concerns/regex_converter.rb +58 -0
  7. data/lib/gitlab/grape_openapi/concerns/serializable.rb +19 -0
  8. data/lib/gitlab/grape_openapi/configuration.rb +24 -0
  9. data/lib/gitlab/grape_openapi/converters/coercer_resolver.rb +74 -0
  10. data/lib/gitlab/grape_openapi/converters/entity_converter.rb +267 -0
  11. data/lib/gitlab/grape_openapi/converters/operation_converter.rb +250 -0
  12. data/lib/gitlab/grape_openapi/converters/parameter_converter.rb +252 -0
  13. data/lib/gitlab/grape_openapi/converters/path_converter.rb +152 -0
  14. data/lib/gitlab/grape_openapi/converters/request_body_converter.rb +97 -0
  15. data/lib/gitlab/grape_openapi/converters/response_converter.rb +185 -0
  16. data/lib/gitlab/grape_openapi/converters/tag_converter.rb +36 -0
  17. data/lib/gitlab/grape_openapi/converters/type_resolver.rb +75 -0
  18. data/lib/gitlab/grape_openapi/generator.rb +60 -0
  19. data/lib/gitlab/grape_openapi/models/info.rb +29 -0
  20. data/lib/gitlab/grape_openapi/models/operation.rb +47 -0
  21. data/lib/gitlab/grape_openapi/models/parameter.rb +43 -0
  22. data/lib/gitlab/grape_openapi/models/path_item.rb +26 -0
  23. data/lib/gitlab/grape_openapi/models/request_body/parameter_schema.rb +250 -0
  24. data/lib/gitlab/grape_openapi/models/request_body/parameters.rb +87 -0
  25. data/lib/gitlab/grape_openapi/models/response.rb +48 -0
  26. data/lib/gitlab/grape_openapi/models/schema.rb +61 -0
  27. data/lib/gitlab/grape_openapi/models/security_scheme.rb +130 -0
  28. data/lib/gitlab/grape_openapi/models/server.rb +31 -0
  29. data/lib/gitlab/grape_openapi/models/server_variable.rb +25 -0
  30. data/lib/gitlab/grape_openapi/models/tag.rb +44 -0
  31. data/lib/gitlab/grape_openapi/request_body_registry.rb +57 -0
  32. data/lib/gitlab/grape_openapi/schema_registry.rb +26 -0
  33. data/lib/gitlab/grape_openapi/serializers/time.rb +19 -0
  34. data/lib/gitlab/grape_openapi/tag_registry.rb +29 -0
  35. data/lib/gitlab/grape_openapi/version.rb +8 -0
  36. data/lib/gitlab-grape-openapi.rb +64 -0
  37. metadata +162 -12
  38. data/lib/gitlab/grape/openapi/version.rb +0 -9
  39. data/lib/gitlab/grape/openapi.rb +0 -21
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f1f4ea6abfdf3c4f4df8d30e3c25801d2e52b6f750ee1e8663b7e6ba84fed19d
4
- data.tar.gz: 484cd5bb383b8936c966bb720dfc6c0be2febb0dd839f9ab2535124050d72b79
3
+ metadata.gz: 5548e70ecc3368b56d965a6bbbc6ae8d448a8a230f0ff453c05d91fc3dfc4714
4
+ data.tar.gz: f535c2530fa0eed45b85b92805973d62dc294b3c7475fd6464229dea5113f1b2
5
5
  SHA512:
6
- metadata.gz: f20d63c31feaa71202b4dd517b5000097475102343ff4ab6b808958f795a42f9a73ad068c3b83f65ee3bf2bb658941f17e2a1b16c9a5eb9a61a55eb7ed5e3b23
7
- data.tar.gz: 0c306ec014c6f0646ae25156107c5cbdf74033385cf5084313ee4285eb601b1eae1a3d679c7179b5af2bd5f0e36ac366f789424f930c7f24e8569ad0c0fdac57
6
+ metadata.gz: 8410aab600a941dca435dd2da070dc1c68141cc5fea001452081216fd3b8f0714a1a87d39811ae9b482c55fc2350953c5bbe6979189631ad96a9d8641b7f42e8
7
+ data.tar.gz: d1ed6e54fc2819e34b8b2acfdd2ef9077647f5c11b4b3bf7de59dad8466a480f4240edb8e30b22fb1a6e8c747e088ed8d2632f3e92e92aa75555f69f7760895a
data/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  The MIT License (MIT)
2
2
 
3
- Copyright (c) 2025 GitLab, Inc.
3
+ Copyright (c) 2026 GitLab, Inc.
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
data/README.md CHANGED
@@ -1,6 +1,224 @@
1
- # Gitlab::Grape::Openapi
1
+ # GitLab Grape OpenAPI
2
2
 
3
- > [!warning] Reserved gem name
4
- > This gem is a placeholder to [reserve a gem name](https://docs.gitlab.com/development/gems/#reserve-a-gem-name)
3
+ > [!IMPORTANT]
4
+ > **Internal use only.** This gem exists to generate the OpenAPI 3.0 spec for
5
+ > the [GitLab Rails monorepo](https://gitlab.com/gitlab-org/gitlab) and is not
6
+ > intended for use outside of GitLab.
7
+ >
8
+ > - It is published to rubygems.org only so the monorepo's `Gemfile` can
9
+ > depend on it — not as a general-purpose Grape → OpenAPI tool.
10
+ > - The public API, configuration DSL, and generated output may change in
11
+ > **any** release, including patch releases. There is no semantic-versioning
12
+ > contract.
13
+ > - No external support is provided. Issues and merge requests from outside
14
+ > GitLab may be closed without review.
15
+ > - Feature work is driven by the needs of `gitlab-org/gitlab`; capabilities
16
+ > that aren't needed there will not be added.
5
17
 
6
- Refer to [`gems/`](https://gitlab.com/gitlab-org/gitlab/-/tree/master/gems?ref_type=heads) directory for actual implementation.
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
+
20
+ ## Installation
21
+
22
+ Add to your Gemfile:
23
+
24
+ ```ruby
25
+ gem 'gitlab-grape-openapi'
26
+ ```
27
+
28
+ Then run:
29
+
30
+ ```bash
31
+ bundle install
32
+ ```
33
+
34
+ ## Configuration
35
+
36
+ Configure the gem using the `Gitlab::GrapeOpenapi.configure` block, typically in an initializer:
37
+
38
+ ```ruby
39
+ Gitlab::GrapeOpenapi.configure do |config|
40
+ # Required: API metadata
41
+ config.info = Gitlab::GrapeOpenapi::Models::Info.new(
42
+ title: 'My API',
43
+ description: 'API description',
44
+ version: 'v1',
45
+ terms_of_service: 'https://example.com/terms'
46
+ )
47
+
48
+ # API path configuration
49
+ config.api_prefix = "api" # Default: "api"
50
+ config.api_version = "v1" # Default: "v1"
51
+
52
+ # Server definitions
53
+ config.servers = [
54
+ Gitlab::GrapeOpenapi::Models::Server.new(
55
+ url: 'https://{hostname}',
56
+ description: "Production API",
57
+ variables: {
58
+ hostname: Gitlab::GrapeOpenapi::Models::ServerVariable.new(
59
+ default: 'api.example.com',
60
+ description: 'API hostname'
61
+ )
62
+ }
63
+ )
64
+ ]
65
+
66
+ # Security schemes
67
+ config.security_schemes = [
68
+ Gitlab::GrapeOpenapi::Models::SecurityScheme.new(
69
+ name: "bearerAuth",
70
+ type: "http",
71
+ scheme: "bearer"
72
+ )
73
+ ]
74
+
75
+ # Exclude specific API classes from generation
76
+ config.excluded_api_classes = [
77
+ 'API::Internal::Base',
78
+ 'API::Internal::Admin'
79
+ ]
80
+
81
+ # Override tag names for better display
82
+ config.tag_overrides = {
83
+ 'Ci' => 'CI',
84
+ 'Oauth' => 'OAuth'
85
+ }
86
+
87
+ # Map Grape route settings to OpenAPI extensions
88
+ config.annotations = {
89
+ lifecycle: 'x-gitlab-lifecycle'
90
+ }
91
+ end
92
+ ```
93
+
94
+ ### Configuration Options
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
+
107
+ ### Annotations
108
+
109
+ The `annotations` configuration maps Grape route settings to OpenAPI vendor extensions. For example:
110
+
111
+ ```ruby
112
+ config.annotations = {
113
+ lifecycle: 'x-gitlab-lifecycle'
114
+ }
115
+
116
+ When a Grape endpoint has:
117
+
118
+ ```ruby
119
+ route_setting :lifecycle, 'mature'
120
+ ```
121
+
122
+ The generated OpenAPI spec will include:
123
+
124
+ ```yaml
125
+ x-gitlab-lifecycle: mature
126
+ ```
127
+
128
+ ## Usage
129
+
130
+ ### Generating an OpenAPI Specification
131
+
132
+ ```ruby
133
+ # Load all API and entity classes
134
+ Rails.application.eager_load!
135
+
136
+ api_classes = API::Base.descendants
137
+ entity_classes = Grape::Entity.descendants
138
+
139
+ # Generate the specification
140
+ spec = Gitlab::GrapeOpenapi.generate(
141
+ api_classes: api_classes,
142
+ entity_classes: entity_classes
143
+ )
144
+
145
+ # Output as JSON
146
+ File.write('openapi.json', JSON.pretty_generate(spec))
147
+
148
+ # Or as YAML
149
+ require 'yaml'
150
+ File.write('openapi.yaml', spec.to_yaml)
151
+ ```
152
+
153
+ ### Usage with `gitlab-org/gitlab`
154
+
155
+ 1. Start a Rails console in your GDK:
156
+
157
+ ```bash
158
+ cd ~/gdk/gitlab
159
+ rails console
160
+ ```
161
+
162
+ 2. Generate the OpenAPI specification:
163
+
164
+ ```ruby
165
+ Rails.application.eager_load!
166
+ api_classes = API::Base.descendants
167
+ entity_classes = Grape::Entity.descendants
168
+ spec = Gitlab::GrapeOpenapi.generate(api_classes: api_classes, entity_classes: entity_classes)
169
+ File.write(Rails.root.join('tmp', 'openapi.json'), JSON.pretty_generate(spec))
170
+ ```
171
+
172
+ 3. The spec will be saved to `tmp/openapi.json` in your GitLab directory.
173
+
174
+ ## Architecture
175
+
176
+ The gem follows a converter-based architecture:
177
+
178
+ ```
179
+ Generator
180
+ ├── TagConverter - Extracts tags from API classes
181
+ ├── EntityConverter - Converts Grape::Entity to OpenAPI schemas
182
+ ├── PathConverter - Converts routes to OpenAPI paths
183
+ │ ├── OperationConverter - Converts individual endpoints
184
+ │ ├── ParameterConverter - Converts endpoint parameters
185
+ │ ├── ResponseConverter - Converts endpoint responses
186
+ │ └── RequestBodyConverter - Converts request bodies
187
+ └── TypeResolver - Maps Ruby/Grape types to OpenAPI types
188
+ ```
189
+
190
+ ### Registries
191
+
192
+ - **SchemaRegistry** - Tracks converted entity schemas
193
+ - **RequestBodyRegistry** - Tracks request body schemas
194
+ - **TagRegistry** - Tracks API tags
195
+
196
+ ## Development
197
+
198
+ ```bash
199
+ bundle install
200
+ bundle exec rspec
201
+ ```
202
+
203
+ ### Running Tests
204
+
205
+ ```bash
206
+ bundle exec rspec
207
+ ```
208
+
209
+ ### Linting
210
+
211
+ ```bash
212
+ bundle exec rubocop
213
+ ```
214
+
215
+ ## Contributing
216
+
217
+ This gem is maintained by GitLab's API Platform team for internal use.
218
+ External contributions are not actively solicited; issues and merge
219
+ requests opened by non-GitLab contributors may be closed without review.
220
+ GitLab team members should follow the [standard contribution guidelines](https://docs.gitlab.com/ee/development/contributing/) — see the [project page](https://gitlab.com/gitlab-org/ruby/gems/gitlab-grape-openapi).
221
+
222
+ ## License
223
+
224
+ Released under the [MIT License](LICENSE).
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ module GrapeOpenapi
5
+ module Concerns
6
+ module FailFastAnnotatable
7
+ FAIL_FAST_ANNOTATION = '(validation stops on first error)'
8
+
9
+ private
10
+
11
+ def fail_fast_in_validations?(validations)
12
+ validations&.any? { |v| v.dig(:opts, :fail_fast) }
13
+ end
14
+
15
+ def annotate_fail_fast(desc)
16
+ return desc.to_s if desc.to_s.include?(FAIL_FAST_ANNOTATION)
17
+
18
+ "#{desc} #{FAIL_FAST_ANNOTATION}"
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ # API::Validations::Validators::Limit is a custom validator unique to GitLab.
4
+ # This resolver compares by class name string rather than constant reference so that this gem
5
+ # does not blow up in environments where the validator is not defined.
6
+
7
+ module Gitlab
8
+ module GrapeOpenapi
9
+ module Concerns
10
+ module LimitResolver
11
+ private
12
+
13
+ def limit_for(validations)
14
+ validation = validations&.find do |v|
15
+ v[:validator_class].name == 'API::Validations::Validators::Limit'
16
+ rescue NoMethodError
17
+ false
18
+ end
19
+ validation && validation[:options]
20
+ end
21
+
22
+ def apply_limit!(schema, validations)
23
+ return unless schema[:type] == 'string'
24
+
25
+ limit = limit_for(validations)
26
+ schema[:maxLength] = limit if limit.is_a?(Integer) && limit.positive?
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "js_regex"
4
+
5
+ module Gitlab
6
+ module GrapeOpenapi
7
+ module Concerns
8
+ # Converts Ruby Regexp objects into ECMA-262 (JavaScript) compatible
9
+ # pattern strings for use in OpenAPI `pattern` schema fields.
10
+ #
11
+ # OpenAPI requires `pattern` values to be valid ECMA-262 regular
12
+ # expressions, but Ruby RegExes routinely contain constructs that ECMA-262
13
+ # cannot parse: \A / \z anchors, inline option groups like (?i-mx:...),
14
+ # POSIX classes, etc. js_regex translates these into JS-compatible equivalents.
15
+ #
16
+ # The Ruby /i flag is folded into the pattern itself (via character class
17
+ # expansion) so the resulting pattern carries no flags, which OpenAPI's
18
+ # flag-less `pattern` field requires
19
+ module RegexConverter
20
+ # js_regex's `target:` controls which ECMAScript version's regex
21
+ # features it will emit. ES2018 is the newest target the gem
22
+ # supports (as of 3.14.0); earlier targets like the default (ES5)
23
+ # silently drop lookbehinds, changing the semantics of any Ruby
24
+ # regex that relies on them
25
+ JS_REGEX_TARGET = 'ES2018'
26
+
27
+ def regexp_to_pattern(value)
28
+ regexp = extract_regexp(value)
29
+ return unless regexp
30
+
31
+ JsRegex.new(inline_case_insensitivity(regexp), target: JS_REGEX_TARGET).source
32
+ end
33
+
34
+ private
35
+
36
+ # Grape stores the validation's `:options` as the Regexp itself for
37
+ # `regexp: /.../`, or as a Hash `{ value: /.../, message: '...' }` for the
38
+ # long form. Pull the Regexp out of either shape
39
+ def extract_regexp(value)
40
+ return value if value.is_a?(Regexp)
41
+
42
+ value[:value] if value.is_a?(Hash) && value[:value].is_a?(Regexp)
43
+ end
44
+
45
+ # js_regex bakes case-insensitivity into character classes only when /i
46
+ # appears as an inline group; an outer /i flag survives as a JS-level
47
+ # option that we cannot represent in OpenAPI. Wrap the source in (?i:...)
48
+ # and drop the outer flag so js_regex expands the character classes
49
+ def inline_case_insensitivity(regexp)
50
+ return regexp if (regexp.options & Regexp::IGNORECASE).zero?
51
+
52
+ remaining_options = regexp.options & ~Regexp::IGNORECASE
53
+ Regexp.new("(?i:#{regexp.source})", remaining_options)
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ module GrapeOpenapi
5
+ module Concerns
6
+ module Serializable
7
+ private
8
+
9
+ def serializable?(value)
10
+ return false if value.is_a?(Proc)
11
+ return false if defined?(ActiveSupport::TimeWithZone) && value.is_a?(ActiveSupport::TimeWithZone)
12
+ return false if value.is_a?(Time)
13
+
14
+ true
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ module GrapeOpenapi
5
+ class Configuration
6
+ attr_accessor :api_version, :api_prefix, :excluded_api_classes, :servers, :security_schemes, :info,
7
+ :tag_overrides, :annotations, :coercer_mappings
8
+
9
+ def initialize
10
+ @api_prefix = "api"
11
+ @api_version = "v1"
12
+ @excluded_api_classes = []
13
+ @info = nil
14
+
15
+ @servers = []
16
+ @security_schemes = []
17
+
18
+ @tag_overrides = {}
19
+ @annotations = {}
20
+ @coercer_mappings = {}
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ module GrapeOpenapi
5
+ module Converters
6
+ module CoercerResolver
7
+ def coercer_mapping_for(validations)
8
+ coercer_method = extract_coercer_method(validations)
9
+ return unless coercer_method # Validation doesn't use `coerce_with`
10
+ return if inline_proc?(coercer_method) # Inline Procs shouldn't need a coercer method
11
+
12
+ coercer_name = resolve_coercer_name(coercer_method)
13
+ config = Gitlab::GrapeOpenapi.configuration
14
+ mapping = config.coercer_mappings.find { |pattern, _mapping| coercer_name == pattern }&.last
15
+ return mapping if mapping
16
+
17
+ raise GenerationError,
18
+ "No OpenAPI schema mapping found for coercer '#{coercer_name}'. " \
19
+ "Add an entry for '#{coercer_name}' to coercer_mappings in " \
20
+ "config/initializers/gitlab_grape_openapi.rb, or use an inline lambda instead."
21
+ end
22
+
23
+ def build_coerced_schema(mapping)
24
+ schema = {}
25
+ schema[:type] = mapping[:type] if mapping[:type]
26
+ schema[:items] = { type: mapping[:items_type] } if mapping[:items_type]
27
+ schema[:format] = mapping[:format] if mapping[:format]
28
+ schema[:additionalProperties] = mapping[:additional_properties] if mapping[:additional_properties]
29
+
30
+ schema
31
+ end
32
+
33
+ private
34
+
35
+ def extract_coercer_method(validations)
36
+ return unless validations
37
+
38
+ coerce_validation = validations.find do |v|
39
+ v[:validator_class] == Grape::Validations::Validators::CoerceValidator
40
+ end
41
+ return unless coerce_validation
42
+
43
+ coerce_validation.dig(:options, :method)
44
+ end
45
+
46
+ def inline_proc?(coercer_method)
47
+ return false unless coercer_method.is_a?(Proc)
48
+
49
+ source_file, _line = coercer_method.source_location
50
+ return true unless source_file
51
+
52
+ source_file.exclude?('/validations/types/')
53
+ end
54
+
55
+ def resolve_coercer_name(coercer_method)
56
+ return coercer_name_from_source_location(coercer_method) if coercer_method.is_a?(Proc)
57
+
58
+ coercer_method.name.to_s
59
+ end
60
+
61
+ def coercer_name_from_source_location(coercer_method)
62
+ source_file, _line = coercer_method.source_location
63
+ return coercer_method.to_s unless source_file
64
+
65
+ camelize(File.basename(source_file, '.rb'))
66
+ end
67
+
68
+ def camelize(str)
69
+ str.split('_').map(&:capitalize).join
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end