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
@@ -0,0 +1,267 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ module GrapeOpenapi
5
+ module Converters
6
+ class EntityConverter
7
+ attr_reader :entity_class, :schema_registry
8
+
9
+ OBJECT_TYPE = 'object'
10
+ ARRAY_TYPE = 'array'
11
+ DEFAULT_TYPE = 'string'
12
+ REF_KEY = '$ref'
13
+ SCHEMA_PATH_PREFIX = '#/components/schemas/'
14
+
15
+ def self.register(entity, schema_registry)
16
+ case entity
17
+ when Class
18
+ return unless grape_entity?(entity)
19
+
20
+ new(entity, schema_registry).convert
21
+ when Hash
22
+ return unless entity[:model] && grape_entity?(entity[:model])
23
+
24
+ new(entity[:model], schema_registry).convert
25
+ when Array
26
+ # Array elements may be Class (`success [Entities::Foo]`) or
27
+ # Hash-with-:model (`success [{ code:, model: Foo }]`). Recurse so
28
+ # both are registered. Mirrors `ResponseConverter`'s handling.
29
+ entity.each { |item| register(item, schema_registry) }
30
+ end
31
+ end
32
+
33
+ def self.grape_entity?(klass)
34
+ klass.is_a?(Class) && klass.ancestors.include?(Grape::Entity)
35
+ end
36
+
37
+ def initialize(entity_class, schema_registry)
38
+ @entity_class = entity_class
39
+ @schema_registry = schema_registry
40
+ end
41
+
42
+ def convert(register: true)
43
+ normalized_name = schema_registry.normalize_entity_class(entity_class)
44
+ return schema_registry.schemas[normalized_name] if schema_registry.schemas.key?(normalized_name)
45
+
46
+ schema = build_schema
47
+ schema_registry.register(entity_class, schema) if register
48
+ schema
49
+ end
50
+
51
+ private
52
+
53
+ def build_schema
54
+ Models::Schema.new.tap do |schema|
55
+ schema.type = OBJECT_TYPE
56
+ schema.properties = build_properties
57
+ end
58
+ end
59
+
60
+ def build_properties
61
+ root_exposures.each_with_object({}) do |exposure, properties|
62
+ if inlineable_merge_exposure?(exposure)
63
+ # `merge: true` flattens the nested entity's exposures into the
64
+ # parent at runtime. Inline its properties instead of emitting a
65
+ # `$ref`, so the generated schema reflects the actual response.
66
+ inline_merged_properties!(properties, exposure)
67
+ else
68
+ properties[exposure.key] = build_property(exposure)
69
+ end
70
+ end
71
+ end
72
+
73
+ def inlineable_merge_exposure?(exposure)
74
+ return false unless exposure.for_merge
75
+
76
+ nested = nested_entity_class(exposure)
77
+ nested.is_a?(Class) && self.class.grape_entity?(nested)
78
+ end
79
+
80
+ def inline_merged_properties!(properties, exposure)
81
+ nested_schema = build_or_fetch_nested_schema(nested_entity_class(exposure))
82
+ return unless nested_schema&.properties
83
+
84
+ # Grape Entity's `merge: true` lets later exposures override earlier
85
+ # ones with the same key, so merging into `properties` mirrors that.
86
+ properties.merge!(nested_schema.properties)
87
+ end
88
+
89
+ # Build the merged entity's schema without registering it as a
90
+ # standalone component. Entities only reached through `merge: true` are
91
+ # inlined into their parents and do not need their own component entry.
92
+ # `convert` still returns an already-registered schema when one exists
93
+ # (for entities also reached via a regular `using:` elsewhere), and
94
+ # `build_schema` still walks nested `using:` exposures, so any schema
95
+ # actually referenced by `$ref` remains registered.
96
+ def build_or_fetch_nested_schema(nested_class)
97
+ self.class.new(nested_class, schema_registry).convert(register: false)
98
+ end
99
+
100
+ def build_property(exposure)
101
+ property = extract_basic_attributes(exposure)
102
+ apply_type_specific_attributes!(property, exposure)
103
+ property.compact
104
+ end
105
+
106
+ def extract_basic_attributes(exposure)
107
+ documentation = exposure_documentation(exposure)
108
+ default_value = exposure_default(exposure)
109
+
110
+ type = documentation[:type]
111
+
112
+ if multiple_types?(type)
113
+ build_one_of_property(type, documentation, default_value)
114
+ else
115
+ build_single_type_property(type, documentation, default_value)
116
+ end
117
+ end
118
+
119
+ def multiple_types?(type)
120
+ type.is_a?(Array) && type.length > 1
121
+ end
122
+
123
+ def build_one_of_property(types, documentation, default_value)
124
+ {
125
+ oneOf: types.map { |type| build_type_schema(type, documentation) },
126
+ description: documentation[:desc],
127
+ default: default_value,
128
+ example: documentation[:example]
129
+ }
130
+ end
131
+
132
+ def build_single_type_property(type, documentation, default_value)
133
+ # Handle single element arrays
134
+ actual_type = type.is_a?(Array) ? type.first : type
135
+
136
+ {
137
+ type: TypeResolver.resolve_type(actual_type) || DEFAULT_TYPE,
138
+ description: documentation[:desc],
139
+ format: TypeResolver.resolve_format(documentation[:format], actual_type),
140
+ default: default_value,
141
+ example: documentation[:example]
142
+ }
143
+ end
144
+
145
+ def build_type_schema(type, documentation)
146
+ schema = { type: TypeResolver.resolve_type(type) || DEFAULT_TYPE }
147
+
148
+ format = TypeResolver.resolve_format(documentation[:format], type)
149
+ schema[:format] = format if format
150
+
151
+ schema
152
+ end
153
+
154
+ def apply_type_specific_attributes!(property, exposure)
155
+ # Skip type-specific handling for oneOf properties
156
+ return if property[:oneOf]
157
+
158
+ if array_exposure?(exposure)
159
+ handle_array_property!(property, exposure)
160
+ elsif nested_entity?(exposure)
161
+ handle_entity_reference!(property, exposure)
162
+ end
163
+ end
164
+
165
+ def handle_array_property!(property, exposure)
166
+ if nested_entity?(exposure)
167
+ register_nested_entity(exposure)
168
+ reference = build_reference(exposure)
169
+ set_array_property!(property, reference)
170
+ else
171
+ set_array_primitive_property!(property)
172
+ end
173
+ end
174
+
175
+ def handle_entity_reference!(property, exposure)
176
+ register_nested_entity(exposure)
177
+ reference = build_reference(exposure)
178
+ set_reference_property!(property, reference)
179
+ end
180
+
181
+ # Ensure schemas reachable from a route via nested `using:` exposures are
182
+ # registered too, so dropping the over-broad `Grape::Entity.descendants`
183
+ # seed in the generator does not leave dangling `$ref`s.
184
+ def register_nested_entity(exposure)
185
+ nested = nested_entity_class(exposure)
186
+ return unless nested.is_a?(Class)
187
+
188
+ self.class.register(nested, schema_registry)
189
+ end
190
+
191
+ def set_array_primitive_property!(property)
192
+ item_type = property[:type] || DEFAULT_TYPE
193
+ property[:type] = ARRAY_TYPE
194
+ property[:items] = build_primitive_items(property, item_type)
195
+ end
196
+
197
+ def build_primitive_items(property, item_type)
198
+ items = { type: item_type }
199
+
200
+ # Move format to items if present
201
+ if property[:format]
202
+ items[:format] = property[:format]
203
+ property[:format] = nil
204
+ end
205
+
206
+ items
207
+ end
208
+
209
+ def set_array_property!(property, reference)
210
+ property[:type] = ARRAY_TYPE
211
+ property[:items] = { REF_KEY => reference }
212
+ end
213
+
214
+ def set_reference_property!(property, reference)
215
+ property[:type] = nil
216
+ property[REF_KEY] = reference
217
+ end
218
+
219
+ def build_reference(exposure)
220
+ entity_name = nested_entity_class(exposure)
221
+ "#{SCHEMA_PATH_PREFIX}#{normalize_entity_name(entity_name)}"
222
+ end
223
+
224
+ def normalize_entity_name(entity_name)
225
+ if entity_name.is_a?(Class)
226
+ entity_name.name.delete(':')
227
+ else
228
+ entity_name.delete(':')
229
+ end
230
+ end
231
+
232
+ def root_exposures
233
+ entity_class.root_exposure.nested_exposures
234
+ end
235
+
236
+ def exposure_documentation(exposure)
237
+ exposure.documentation || {}
238
+ end
239
+
240
+ # Grape Entity stores the `:default` option in `@default_value` but
241
+ # does not expose a public reader for it. Reaching for the ivar is the
242
+ # only way to read it without going through `send(:options)`, which the
243
+ # `GitlabSecurity/PublicSend` rule disallows.
244
+ def exposure_default(exposure)
245
+ exposure.instance_variable_get(:@default_value)
246
+ end
247
+
248
+ def nested_entity?(exposure)
249
+ !nested_entity_class(exposure).nil?
250
+ end
251
+
252
+ # Only `RepresentExposure` instances (i.e. exposures declared with
253
+ # `using:`) expose a `using_class_name` reader; for any other exposure
254
+ # type there is no nested entity to reference.
255
+ def nested_entity_class(exposure)
256
+ return unless exposure.respond_to?(:using_class_name)
257
+
258
+ exposure.using_class_name
259
+ end
260
+
261
+ def array_exposure?(exposure)
262
+ exposure_documentation(exposure)[:is_array]
263
+ end
264
+ end
265
+ end
266
+ end
267
+ end
@@ -0,0 +1,250 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ module GrapeOpenapi
5
+ module Converters
6
+ class OperationConverter
7
+ # https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.0.md#operation-object
8
+
9
+ DASH_SEGMENT = 'Dash'
10
+
11
+ def self.convert(route, schema_registry, request_body_registry, inherited_path_params: {})
12
+ new(
13
+ route,
14
+ schema_registry,
15
+ request_body_registry,
16
+ inherited_path_params: inherited_path_params
17
+ ).convert
18
+ end
19
+
20
+ def initialize(route, schema_registry, request_body_registry, inherited_path_params: {})
21
+ @route = route
22
+ @schema_registry = schema_registry
23
+ @request_body_registry = request_body_registry
24
+ @inherited_path_params = inherited_path_params
25
+ @config = Gitlab::GrapeOpenapi.configuration
26
+ @options = route.options
27
+ @pattern = route.pattern
28
+ @endpoint = route.app
29
+ end
30
+
31
+ def convert
32
+ Models::Operation.new.tap do |operation|
33
+ operation.operation_id = operation_id
34
+ operation.summary = extract_description
35
+ operation.description = extract_detail
36
+ operation.tags = extract_tags
37
+ operation.deprecated = extract_deprecated
38
+ operation.hidden = extract_hidden
39
+ operation.parameters = extract_parameters
40
+ operation.responses = ResponseConverter.new(@route, @schema_registry).convert
41
+ operation.request_body = extract_request_body || {}
42
+ operation.annotations = extract_annotations
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ attr_reader :config, :route, :options, :pattern, :endpoint, :schema_registry, :request_body_registry,
49
+ :inherited_path_params
50
+
51
+ def route_method
52
+ @route.request_method
53
+ end
54
+
55
+ def extract_annotations
56
+ return {} unless options[:settings]
57
+
58
+ selected_keys = options[:settings].keys.select do |k|
59
+ config.annotations.key?(k)
60
+ end
61
+
62
+ selected_keys.to_h do |key|
63
+ [config.annotations[key], options[:settings][key].to_s]
64
+ end
65
+ end
66
+
67
+ def extract_parameters
68
+ params = if options[:params].empty?
69
+ []
70
+ else
71
+ options[:params].filter_map do |key, options|
72
+ Converters::ParameterConverter.convert(
73
+ key,
74
+ options: options,
75
+ validations: validations_for(key.to_sym),
76
+ route: route
77
+ )
78
+ end
79
+ end
80
+
81
+ inject_missing_path_parameters(params)
82
+ end
83
+
84
+ def inject_missing_path_parameters(params)
85
+ declared_names = params.map(&:name).to_set
86
+
87
+ path_placeholders = normalized_path.scan(/\{(\w+)\}/).flatten
88
+ path_segments = normalized_path.split('/')
89
+
90
+ path_placeholders.each do |placeholder|
91
+ next if declared_names.include?(placeholder)
92
+
93
+ inherited = lookup_inherited_param(path_segments, placeholder)
94
+
95
+ params << if inherited
96
+ ParameterConverter.convert(
97
+ placeholder,
98
+ options: inherited,
99
+ route: route
100
+ )
101
+ else
102
+ # Empty schema because no declaration was found locally or in any
103
+ # sibling route's path prefix. Valid OpenAPI 3.0 ("any type") but
104
+ # incomplete; report the offender to stderr so callers can fix
105
+ # it in source.
106
+ report_synthesized_parameter(placeholder)
107
+ Models::Parameter.new(
108
+ placeholder,
109
+ options: { required: true },
110
+ schema: {},
111
+ in_value: 'path'
112
+ )
113
+ end
114
+ end
115
+
116
+ params
117
+ end
118
+
119
+ # Emit a one-line stderr diagnostic identifying the route and API
120
+ # class responsible for a `schema: {}` entry in the generated spec.
121
+ # The output is grep-friendly: each line starts with the literal
122
+ # `[gitlab-grape-openapi]` tag and includes the HTTP method, the
123
+ # full normalized path, the placeholder name, and the API class
124
+ # name. From the class name, the source file follows GitLab's
125
+ # autoload conventions (`API::Foo::Bar` -> `lib/api/foo/bar.rb`).
126
+ def report_synthesized_parameter(placeholder)
127
+ api_class = route.app.options[:for] if route.app.respond_to?(:options)
128
+ warn "[gitlab-grape-openapi] synthesized path param: " \
129
+ "#{http_method} #{normalized_path} placeholder=:#{placeholder} " \
130
+ "class=#{api_class || 'unknown'}"
131
+ end
132
+
133
+ # Look up a path-prefix-keyed declaration so mounted child routes
134
+ # can inherit `requires :id` (etc.) from the parent that mounted them.
135
+ def lookup_inherited_param(path_segments, placeholder)
136
+ idx = path_segments.index("{#{placeholder}}")
137
+ return unless idx
138
+
139
+ prefix = path_segments[0..idx].join('/')
140
+ inherited_path_params[[prefix, placeholder]]
141
+ end
142
+
143
+ def operation_id
144
+ method = http_method.downcase
145
+ normalized = normalized_path
146
+ segments = normalized.split('/').reject(&:empty?)
147
+
148
+ parts = segments.filter_map do |seg|
149
+ next DASH_SEGMENT if seg == '-'
150
+
151
+ if seg.include?('{')
152
+ camelize(seg.gsub(/\{[^}]+\}/) { |m| m[1..-2] })
153
+ else
154
+ camelize(seg)
155
+ end
156
+ end
157
+
158
+ "#{method}#{parts.join}"
159
+ end
160
+
161
+ def extract_description
162
+ description_from_options = options[:description]
163
+ return description_from_options[:description] if description_from_options.is_a?(Hash)
164
+ return description_from_options if description_from_options.is_a?(String)
165
+
166
+ return unless endpoint
167
+
168
+ description_hash = endpoint.inheritable_setting&.namespace
169
+ description_hash[:description] if description_hash.is_a?(Hash)
170
+ end
171
+
172
+ def extract_detail
173
+ options.dig(:settings, :description, :detail)
174
+ end
175
+
176
+ def extract_tags
177
+ Array(@route.settings.dig(:description, :tags)).map do |tag|
178
+ Gitlab::GrapeOpenapi::Models::Tag.normalize_tag_name(tag)
179
+ end
180
+ end
181
+
182
+ def extract_deprecated
183
+ !!options.dig(:settings, :description, :deprecated)
184
+ end
185
+
186
+ def extract_hidden
187
+ !!options.dig(:settings, :description, :hidden)
188
+ end
189
+
190
+ def path_segments
191
+ segments = normalized_path.split('/').reject do |segment|
192
+ segment.empty? || segment.start_with?('{')
193
+ end
194
+
195
+ segments.reject { |seg| seg == config.api_prefix || seg == config.api_version || seg == '-' }
196
+ end
197
+
198
+ def normalized_path
199
+ @normalized_path ||= begin
200
+ path = normalize_path_pattern
201
+ path.gsub('{version}', config.api_version)
202
+ end
203
+ end
204
+
205
+ def normalize_path_pattern
206
+ path = pattern.origin
207
+ path
208
+ .gsub(/\(\.:format\)$/, '')
209
+ .gsub(/[()\\]/, '')
210
+ .gsub(/:\w+/) { |match| "{#{match[1..]}}" }
211
+ .gsub('{version}', config.api_version)
212
+ end
213
+
214
+ def camelize(string)
215
+ string.gsub(/[^a-zA-Z0-9_]/, '_').split('_').reject(&:empty?).map(&:capitalize).join
216
+ end
217
+
218
+ def http_method
219
+ @route.request_method
220
+ end
221
+
222
+ # Get all validations for a single attribute
223
+ # Looks something like:
224
+ # [{:attributes=>[:version_prefix],
225
+ # :options=>/^[\d+.]+/,
226
+ # :required=>false,
227
+ # :params_scope=>#<Grape::Validations::ParamsScope:0x000000016dc35820
228
+ # :opts=>{:allow_blank=>nil, :fail_fast=>false},
229
+ # :validator_class=>Grape::Validations::Validators::RegexpValidator}]
230
+ def validations_for(attribute)
231
+ route
232
+ .app
233
+ .inheritable_setting
234
+ .namespace_stackable
235
+ .new_values[:validations]
236
+ &.select { |v| v[:attributes].include?(attribute) }
237
+ end
238
+
239
+ def extract_request_body
240
+ RequestBodyConverter.convert(
241
+ route: route,
242
+ options: options,
243
+ params: options[:params],
244
+ request_body_registry: request_body_registry
245
+ )
246
+ end
247
+ end
248
+ end
249
+ end
250
+ end