gitlab-grape-openapi 0.0.0 → 0.1.1

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 +276 -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 +25 -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 +255 -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 +7 -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,255 @@
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 warning identifying the route and API
120
+ # class responsible for a `schema: {}` entry in the generated spec.
121
+ # Disabled by default and gated behind `config.warnings` so it does
122
+ # not pollute stderr in callers that treat any warning output as a
123
+ # failure (e.g. GitLab's `fail_on_warnings` static-analysis wrapper).
124
+ # The output is grep-friendly: each line starts with the literal
125
+ # `[gitlab-grape-openapi]` tag and includes the HTTP method, the
126
+ # full normalized path, the placeholder name, and the API class
127
+ # name. From the class name, the source file follows GitLab's
128
+ # autoload conventions (`API::Foo::Bar` -> `lib/api/foo/bar.rb`).
129
+ def report_synthesized_parameter(placeholder)
130
+ return unless config.warnings
131
+
132
+ api_class = route.app.options[:for] if route.app.respond_to?(:options)
133
+ warn "[gitlab-grape-openapi] synthesized path param: " \
134
+ "#{http_method} #{normalized_path} placeholder=:#{placeholder} " \
135
+ "class=#{api_class || 'unknown'}"
136
+ end
137
+
138
+ # Look up a path-prefix-keyed declaration so mounted child routes
139
+ # can inherit `requires :id` (etc.) from the parent that mounted them.
140
+ def lookup_inherited_param(path_segments, placeholder)
141
+ idx = path_segments.index("{#{placeholder}}")
142
+ return unless idx
143
+
144
+ prefix = path_segments[0..idx].join('/')
145
+ inherited_path_params[[prefix, placeholder]]
146
+ end
147
+
148
+ def operation_id
149
+ method = http_method.downcase
150
+ normalized = normalized_path
151
+ segments = normalized.split('/').reject(&:empty?)
152
+
153
+ parts = segments.filter_map do |seg|
154
+ next DASH_SEGMENT if seg == '-'
155
+
156
+ if seg.include?('{')
157
+ camelize(seg.gsub(/\{[^}]+\}/) { |m| m[1..-2] })
158
+ else
159
+ camelize(seg)
160
+ end
161
+ end
162
+
163
+ "#{method}#{parts.join}"
164
+ end
165
+
166
+ def extract_description
167
+ description_from_options = options[:description]
168
+ return description_from_options[:description] if description_from_options.is_a?(Hash)
169
+ return description_from_options if description_from_options.is_a?(String)
170
+
171
+ return unless endpoint
172
+
173
+ description_hash = endpoint.inheritable_setting&.namespace
174
+ description_hash[:description] if description_hash.is_a?(Hash)
175
+ end
176
+
177
+ def extract_detail
178
+ options.dig(:settings, :description, :detail)
179
+ end
180
+
181
+ def extract_tags
182
+ Array(@route.settings.dig(:description, :tags)).map do |tag|
183
+ Gitlab::GrapeOpenapi::Models::Tag.normalize_tag_name(tag)
184
+ end
185
+ end
186
+
187
+ def extract_deprecated
188
+ !!options.dig(:settings, :description, :deprecated)
189
+ end
190
+
191
+ def extract_hidden
192
+ !!options.dig(:settings, :description, :hidden)
193
+ end
194
+
195
+ def path_segments
196
+ segments = normalized_path.split('/').reject do |segment|
197
+ segment.empty? || segment.start_with?('{')
198
+ end
199
+
200
+ segments.reject { |seg| seg == config.api_prefix || seg == config.api_version || seg == '-' }
201
+ end
202
+
203
+ def normalized_path
204
+ @normalized_path ||= begin
205
+ path = normalize_path_pattern
206
+ path.gsub('{version}', config.api_version)
207
+ end
208
+ end
209
+
210
+ def normalize_path_pattern
211
+ path = pattern.origin
212
+ path
213
+ .gsub(/\(\.:format\)$/, '')
214
+ .gsub(/[()\\]/, '')
215
+ .gsub(/:\w+/) { |match| "{#{match[1..]}}" }
216
+ .gsub('{version}', config.api_version)
217
+ end
218
+
219
+ def camelize(string)
220
+ string.gsub(/[^a-zA-Z0-9_]/, '_').split('_').reject(&:empty?).map(&:capitalize).join
221
+ end
222
+
223
+ def http_method
224
+ @route.request_method
225
+ end
226
+
227
+ # Get all validations for a single attribute
228
+ # Looks something like:
229
+ # [{:attributes=>[:version_prefix],
230
+ # :options=>/^[\d+.]+/,
231
+ # :required=>false,
232
+ # :params_scope=>#<Grape::Validations::ParamsScope:0x000000016dc35820
233
+ # :opts=>{:allow_blank=>nil, :fail_fast=>false},
234
+ # :validator_class=>Grape::Validations::Validators::RegexpValidator}]
235
+ def validations_for(attribute)
236
+ route
237
+ .app
238
+ .inheritable_setting
239
+ .namespace_stackable
240
+ .new_values[:validations]
241
+ &.select { |v| v[:attributes].include?(attribute) }
242
+ end
243
+
244
+ def extract_request_body
245
+ RequestBodyConverter.convert(
246
+ route: route,
247
+ options: options,
248
+ params: options[:params],
249
+ request_body_registry: request_body_registry
250
+ )
251
+ end
252
+ end
253
+ end
254
+ end
255
+ end