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.
- checksums.yaml +4 -4
- data/LICENSE +1 -1
- data/README.md +222 -4
- data/lib/gitlab/grape_openapi/concerns/fail_fast_annotatable.rb +23 -0
- data/lib/gitlab/grape_openapi/concerns/limit_resolver.rb +31 -0
- data/lib/gitlab/grape_openapi/concerns/regex_converter.rb +58 -0
- data/lib/gitlab/grape_openapi/concerns/serializable.rb +19 -0
- data/lib/gitlab/grape_openapi/configuration.rb +24 -0
- data/lib/gitlab/grape_openapi/converters/coercer_resolver.rb +74 -0
- data/lib/gitlab/grape_openapi/converters/entity_converter.rb +267 -0
- data/lib/gitlab/grape_openapi/converters/operation_converter.rb +250 -0
- data/lib/gitlab/grape_openapi/converters/parameter_converter.rb +252 -0
- data/lib/gitlab/grape_openapi/converters/path_converter.rb +152 -0
- data/lib/gitlab/grape_openapi/converters/request_body_converter.rb +97 -0
- data/lib/gitlab/grape_openapi/converters/response_converter.rb +185 -0
- data/lib/gitlab/grape_openapi/converters/tag_converter.rb +36 -0
- data/lib/gitlab/grape_openapi/converters/type_resolver.rb +75 -0
- data/lib/gitlab/grape_openapi/generator.rb +60 -0
- data/lib/gitlab/grape_openapi/models/info.rb +29 -0
- data/lib/gitlab/grape_openapi/models/operation.rb +47 -0
- data/lib/gitlab/grape_openapi/models/parameter.rb +43 -0
- data/lib/gitlab/grape_openapi/models/path_item.rb +26 -0
- data/lib/gitlab/grape_openapi/models/request_body/parameter_schema.rb +250 -0
- data/lib/gitlab/grape_openapi/models/request_body/parameters.rb +87 -0
- data/lib/gitlab/grape_openapi/models/response.rb +48 -0
- data/lib/gitlab/grape_openapi/models/schema.rb +61 -0
- data/lib/gitlab/grape_openapi/models/security_scheme.rb +130 -0
- data/lib/gitlab/grape_openapi/models/server.rb +31 -0
- data/lib/gitlab/grape_openapi/models/server_variable.rb +25 -0
- data/lib/gitlab/grape_openapi/models/tag.rb +44 -0
- data/lib/gitlab/grape_openapi/request_body_registry.rb +57 -0
- data/lib/gitlab/grape_openapi/schema_registry.rb +26 -0
- data/lib/gitlab/grape_openapi/serializers/time.rb +19 -0
- data/lib/gitlab/grape_openapi/tag_registry.rb +29 -0
- data/lib/gitlab/grape_openapi/version.rb +8 -0
- data/lib/gitlab-grape-openapi.rb +64 -0
- metadata +162 -12
- data/lib/gitlab/grape/openapi/version.rb +0 -9
- data/lib/gitlab/grape/openapi.rb +0 -21
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gitlab
|
|
4
|
+
module GrapeOpenapi
|
|
5
|
+
module Converters
|
|
6
|
+
class ParameterConverter
|
|
7
|
+
include CoercerResolver
|
|
8
|
+
include Concerns::Serializable
|
|
9
|
+
include Concerns::LimitResolver
|
|
10
|
+
include Concerns::FailFastAnnotatable
|
|
11
|
+
include Concerns::RegexConverter
|
|
12
|
+
|
|
13
|
+
attr_reader :name, :options, :validations, :route
|
|
14
|
+
|
|
15
|
+
def self.convert(name, options:, route:, validations: [])
|
|
16
|
+
new(name, options: options, validations: validations, route: route).convert
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def initialize(name, options:, validations:, route:)
|
|
20
|
+
@name = name
|
|
21
|
+
@options = options
|
|
22
|
+
@validations = validations
|
|
23
|
+
@route = route # Useful for detecting `in` value.
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def in_value
|
|
27
|
+
# Strip only the :version path segment (not substrings like :version_id or :package_version),
|
|
28
|
+
# then match the param name as a complete segment bounded by / . ( or end-of-string.
|
|
29
|
+
route.path.gsub('/:version/', '/').match?(%r{/:#{Regexp.escape(name)}([/.(]|$)}) ? 'path' : 'query'
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def example
|
|
33
|
+
@options.dig(:documentation, :example)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def coercer_mapping
|
|
37
|
+
@coercer_mapping ||= coercer_mapping_for(validations)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def schema
|
|
41
|
+
object_type = TypeResolver.resolve_type(options[:type]) || 'string'
|
|
42
|
+
object_format = TypeResolver.resolve_format(nil, options[:type])
|
|
43
|
+
type_str = options[:type].to_s
|
|
44
|
+
|
|
45
|
+
mapping = coercer_mapping
|
|
46
|
+
built_schema = if mapping
|
|
47
|
+
build_coerced_schema(mapping)
|
|
48
|
+
elsif type_str.start_with?('[') && type_str.exclude?(',')
|
|
49
|
+
build_simple_array_schema
|
|
50
|
+
elsif type_str.start_with?('[')
|
|
51
|
+
build_union_schema(object_type)
|
|
52
|
+
elsif options[:values].is_a?(Range)
|
|
53
|
+
build_range_schema(object_type)
|
|
54
|
+
elsif options[:values]
|
|
55
|
+
build_enum_schema(object_type)
|
|
56
|
+
elsif array_type?(object_type)
|
|
57
|
+
build_array_schema
|
|
58
|
+
else
|
|
59
|
+
build_basic_schema(object_type, object_format)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
apply_allow_blank(built_schema)
|
|
63
|
+
apply_limit!(built_schema, validations)
|
|
64
|
+
built_schema
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def build_simple_array_schema
|
|
68
|
+
item_type = options[:type].to_s.delete('[').delete(']')
|
|
69
|
+
{ type: 'array', items: { type: TypeResolver.resolve_type(item_type) } }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def build_union_schema(object_type)
|
|
73
|
+
types = object_type[1..-2].split(", ")
|
|
74
|
+
members = types.map { |type| TypeResolver.resolve_union_member(type) }
|
|
75
|
+
apply_union_enum!(members)
|
|
76
|
+
apply_union_default!(members)
|
|
77
|
+
{ oneOf: members }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def build_range_schema(object_type)
|
|
81
|
+
range = options[:values]
|
|
82
|
+
schema = { type: object_type }
|
|
83
|
+
|
|
84
|
+
schema[:minimum] = range.begin if range.begin
|
|
85
|
+
schema[:maximum] = range.end if range.end
|
|
86
|
+
schema[:default] = options[:default] if options[:default] && serializable?(options[:default])
|
|
87
|
+
schema
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def build_enum_schema(object_type)
|
|
91
|
+
schema = { type: object_type }
|
|
92
|
+
schema[:enum] = options[:values] unless options[:values].is_a?(Proc)
|
|
93
|
+
schema[:default] = options[:default] if options[:default] && serializable?(options[:default])
|
|
94
|
+
schema
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def build_array_schema
|
|
98
|
+
item_type = extract_array_item_type
|
|
99
|
+
{ type: 'array', items: { type: item_type } }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def build_basic_schema(object_type, object_format)
|
|
103
|
+
schema = { type: object_type }
|
|
104
|
+
schema[:format] = object_format if object_format
|
|
105
|
+
if options[:default] && serializable?(options[:default])
|
|
106
|
+
schema[:default] = options[:default]
|
|
107
|
+
elsif options[:default] &&
|
|
108
|
+
defined?(ActiveSupport::TimeWithZone) &&
|
|
109
|
+
options[:default].is_a?(ActiveSupport::TimeWithZone)
|
|
110
|
+
serialized_default = time_serializer.serialize(options[:default], example: example)
|
|
111
|
+
schema[:default] = serialized_default if serialized_default
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
add_regex_validations!(schema)
|
|
115
|
+
schema
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def array_type?(object_type)
|
|
119
|
+
object_type.include?('[')
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def extract_array_item_type
|
|
123
|
+
options[:type].delete('[').delete(']').downcase
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def add_regex_validations!(schema)
|
|
127
|
+
return unless validations
|
|
128
|
+
|
|
129
|
+
# Only support one Regex validation per attribute
|
|
130
|
+
validation = validations&.find { |v| v[:validator_class] == Grape::Validations::Validators::RegexpValidator }
|
|
131
|
+
return unless validation
|
|
132
|
+
|
|
133
|
+
pattern = regexp_to_pattern(validation[:options])
|
|
134
|
+
schema[:pattern] = pattern if pattern
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def convert
|
|
138
|
+
# For requests that can have a request body (POST, PUT, PATCH, etc.), only return a param if it's in the path,
|
|
139
|
+
# otherwise it'll be a body parameter and shouldn't be included as a query parameter.
|
|
140
|
+
# GET and DELETE requests don't have request bodies, so all their parameters are included.
|
|
141
|
+
method = route.request_method
|
|
142
|
+
return nil if method != 'GET' && method != 'DELETE' && in_value != 'path'
|
|
143
|
+
|
|
144
|
+
annotated = options.dup
|
|
145
|
+
if options[:desc] && fail_fast_in_validations?(validations)
|
|
146
|
+
annotated[:desc] = annotate_fail_fast(options[:desc])
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
param = Gitlab::GrapeOpenapi::Models::Parameter.new(
|
|
150
|
+
name,
|
|
151
|
+
options: annotated,
|
|
152
|
+
schema: schema,
|
|
153
|
+
in_value: in_value
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
mapping = coercer_mapping
|
|
157
|
+
return param unless mapping
|
|
158
|
+
|
|
159
|
+
param.style = mapping[:style] if mapping[:style]
|
|
160
|
+
param.explode = mapping[:explode] if mapping.key?(:explode)
|
|
161
|
+
param
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
private
|
|
165
|
+
|
|
166
|
+
def time_serializer
|
|
167
|
+
@time_serializer ||= Serializers::Time.new
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# When a union (oneOf) schema has a `values:` constraint, propagate it
|
|
171
|
+
# as `enum` onto each oneOf member whose type can carry the values.
|
|
172
|
+
# Skip Procs/Ranges to mirror build_enum_schema behavior.
|
|
173
|
+
def apply_union_enum!(members)
|
|
174
|
+
values = options[:values]
|
|
175
|
+
return if values.nil? || values.is_a?(Proc) || values.is_a?(Range)
|
|
176
|
+
|
|
177
|
+
members.each do |member|
|
|
178
|
+
case member[:type]
|
|
179
|
+
when 'integer'
|
|
180
|
+
ints = values.select { |v| v.is_a?(Integer) }
|
|
181
|
+
member[:enum] = ints if ints.any?
|
|
182
|
+
when 'number'
|
|
183
|
+
nums = values.select { |v| v.is_a?(Numeric) }
|
|
184
|
+
member[:enum] = nums if nums.any?
|
|
185
|
+
when 'string'
|
|
186
|
+
member[:enum] = values.map(&:to_s)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# When a union (oneOf) schema has a `default:`, attach it to every member
|
|
192
|
+
# whose schema can accept the default value. For arrays this includes
|
|
193
|
+
# checking the items type so `[1, 2]` lands on `items: { type: integer }`
|
|
194
|
+
# but not on `items: { type: string }`. Empty arrays are type-agnostic
|
|
195
|
+
# and attach to all array members.
|
|
196
|
+
def apply_union_default!(members)
|
|
197
|
+
default = options[:default]
|
|
198
|
+
return unless default && serializable?(default)
|
|
199
|
+
|
|
200
|
+
members.each do |member|
|
|
201
|
+
member[:default] = default if member_accepts_default?(member, default)
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def member_accepts_default?(member, default)
|
|
206
|
+
case member[:type]
|
|
207
|
+
when 'integer' then default.is_a?(Integer)
|
|
208
|
+
when 'number' then default.is_a?(Numeric)
|
|
209
|
+
when 'boolean' then [true, false].include?(default)
|
|
210
|
+
when 'string' then default.is_a?(String) || default.is_a?(Symbol)
|
|
211
|
+
when 'array' then array_member_accepts?(member, default)
|
|
212
|
+
when 'object' then default.is_a?(Hash)
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def array_member_accepts?(member, default)
|
|
217
|
+
return false unless default.is_a?(Array)
|
|
218
|
+
return true if default.empty?
|
|
219
|
+
|
|
220
|
+
item_type = member.dig(:items, :type)
|
|
221
|
+
default.all? { |element| openapi_type_accepts?(item_type, element) }
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def openapi_type_accepts?(openapi_type, value)
|
|
225
|
+
case openapi_type
|
|
226
|
+
when 'integer' then value.is_a?(Integer)
|
|
227
|
+
when 'number' then value.is_a?(Numeric)
|
|
228
|
+
when 'boolean' then [true, false].include?(value)
|
|
229
|
+
when 'string' then value.is_a?(String) || value.is_a?(Symbol)
|
|
230
|
+
else true
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# allow_blank defaults to true
|
|
235
|
+
# when `allow_blank: false` for a string type minLength should be set to 1
|
|
236
|
+
# when param is required and values option used, the param is not nullable
|
|
237
|
+
def apply_allow_blank(schema)
|
|
238
|
+
if options[:allow_blank] == false || (options[:required] && options[:values])
|
|
239
|
+
schema[:minLength] = 1 if schema[:type] == 'string'
|
|
240
|
+
elsif in_value != 'path'
|
|
241
|
+
# path parameters are never nullable because they are required URL segments
|
|
242
|
+
if schema[:oneOf]
|
|
243
|
+
schema[:oneOf].each { |s| s[:nullable] = true }
|
|
244
|
+
else
|
|
245
|
+
schema[:nullable] = true
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gitlab
|
|
4
|
+
module GrapeOpenapi
|
|
5
|
+
module Converters
|
|
6
|
+
class PathConverter
|
|
7
|
+
# Grape stores response-entity declarations under :entity (when declared
|
|
8
|
+
# via `entity:` on the route) and :success (when declared via the `success`
|
|
9
|
+
# DSL inside a `desc` block, in any of its forms). EntityConverter.register
|
|
10
|
+
# handles all three shapes (Class, Hash with :model, Array of those).
|
|
11
|
+
RESPONSE_DECLARATIONS = %i[entity success].freeze
|
|
12
|
+
|
|
13
|
+
def self.convert(routes, schema_registry, request_body_registry)
|
|
14
|
+
new(routes, schema_registry, request_body_registry).convert
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def initialize(routes, schema_registry, request_body_registry)
|
|
18
|
+
@routes = routes
|
|
19
|
+
@schema_registry = schema_registry
|
|
20
|
+
@request_body_registry = request_body_registry
|
|
21
|
+
@config = Gitlab::GrapeOpenapi.configuration
|
|
22
|
+
@inherited_path_params = {}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def convert
|
|
26
|
+
register_inherited_path_params
|
|
27
|
+
|
|
28
|
+
paths = grouped_routes.transform_values do |routes_for_path|
|
|
29
|
+
build_path_item(routes_for_path)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
paths.reject { |_path, operations| operations.empty? }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
attr_reader :config, :routes, :schema_registry, :request_body_registry, :inherited_path_params
|
|
38
|
+
|
|
39
|
+
def grouped_routes
|
|
40
|
+
groups = {}
|
|
41
|
+
|
|
42
|
+
routes.each do |route|
|
|
43
|
+
next if skip_route?(route)
|
|
44
|
+
|
|
45
|
+
register_route_entities(route)
|
|
46
|
+
|
|
47
|
+
key = grouping_key(route)
|
|
48
|
+
groups[key] ||= []
|
|
49
|
+
groups[key] << route
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
groups.transform_keys { |key| normalize_path(groups[key].first) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Register entities declared on the route's response so they appear in
|
|
56
|
+
# `components.schemas` only for routes that are actually emitted.
|
|
57
|
+
# Running this after `skip_route?` keeps registration in sync with the
|
|
58
|
+
# final spec and avoids `no-unused-components` orphans for entities
|
|
59
|
+
# that belong only to hidden routes or wildcard catch-alls.
|
|
60
|
+
def register_route_entities(route)
|
|
61
|
+
RESPONSE_DECLARATIONS.each do |key|
|
|
62
|
+
definition = route.options[key]
|
|
63
|
+
next unless definition
|
|
64
|
+
|
|
65
|
+
EntityConverter.register(definition, @schema_registry)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def skip_route?(route)
|
|
70
|
+
method = extract_method(route)
|
|
71
|
+
path = normalize_path(route)
|
|
72
|
+
|
|
73
|
+
# Hidden routes (declared with `hidden true`) must be skipped before
|
|
74
|
+
# OperationConverter runs. Otherwise it pollutes the shared schema and
|
|
75
|
+
# request-body registries with entries that no emitted operation references,
|
|
76
|
+
# surfacing as `no-unused-components` warnings under `components.schemas`.
|
|
77
|
+
return true if hidden?(route)
|
|
78
|
+
|
|
79
|
+
# Grape registers catch-all routes with HTTP method * (matches any method) and
|
|
80
|
+
# paths containing *path (wildcard segments). Neither is valid OpenAPI: * isn't
|
|
81
|
+
# an HTTP method, and *path isn't a valid path segment. These are internal
|
|
82
|
+
# Grape routing artifacts, not actual API endpoints.
|
|
83
|
+
method == '*' || path.include?('*')
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def hidden?(route)
|
|
87
|
+
!!route.options.dig(:settings, :description, :hidden)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def normalize_path(route)
|
|
91
|
+
path = route.pattern.origin
|
|
92
|
+
|
|
93
|
+
path
|
|
94
|
+
.gsub(/\(\.:format\)$/, '')
|
|
95
|
+
.gsub(/:\w+/) { |match| "{#{match[1..]}}" }
|
|
96
|
+
.gsub('{version}', config.api_version)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def grouping_key(route)
|
|
100
|
+
normalize_path(route).gsub(/\{[^}]+\}/, '{param}')
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def build_path_item(routes_for_path)
|
|
104
|
+
path_item = Models::PathItem.new
|
|
105
|
+
|
|
106
|
+
routes_for_path.each do |route|
|
|
107
|
+
operation = OperationConverter.convert(
|
|
108
|
+
route,
|
|
109
|
+
schema_registry,
|
|
110
|
+
request_body_registry,
|
|
111
|
+
inherited_path_params: inherited_path_params
|
|
112
|
+
)
|
|
113
|
+
method = extract_method(route)
|
|
114
|
+
path_item.add_operation(method, operation)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
path_item.to_h
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def extract_method(route)
|
|
121
|
+
route.request_method
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Index every declared path placeholder by the path prefix that
|
|
125
|
+
# introduces it, so a route that does not declare a placeholder
|
|
126
|
+
# itself can still inherit the declaration from a sibling route
|
|
127
|
+
# (e.g. a mounted child route inheriting `:id` from its mount
|
|
128
|
+
# parent, where Grape drops the parent's `requires :id` from the
|
|
129
|
+
# child route's own `options[:params]`).
|
|
130
|
+
def register_inherited_path_params
|
|
131
|
+
routes.each do |route|
|
|
132
|
+
next if skip_route?(route)
|
|
133
|
+
|
|
134
|
+
declared = route.options[:params] || {}
|
|
135
|
+
next if declared.empty?
|
|
136
|
+
|
|
137
|
+
segments = normalize_path(route).split('/')
|
|
138
|
+
|
|
139
|
+
declared.each do |placeholder_name, param_options|
|
|
140
|
+
placeholder_pattern = "{#{placeholder_name}}"
|
|
141
|
+
idx = segments.index(placeholder_pattern)
|
|
142
|
+
next unless idx
|
|
143
|
+
|
|
144
|
+
prefix = segments[0..idx].join('/')
|
|
145
|
+
inherited_path_params[[prefix, placeholder_name]] ||= param_options
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gitlab
|
|
4
|
+
module GrapeOpenapi
|
|
5
|
+
module Converters
|
|
6
|
+
class RequestBodyConverter
|
|
7
|
+
DEFAULT_CONTENT_TYPE = 'application/json'
|
|
8
|
+
MULTIPART_FORM_DATA_CONTENT_TYPE = 'multipart/form-data'
|
|
9
|
+
GET_METHOD = 'GET'
|
|
10
|
+
DELETE_METHOD = 'DELETE'
|
|
11
|
+
|
|
12
|
+
attr_reader :route, :options, :params, :request_body_registry
|
|
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
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(route:, options:, params:, request_body_registry:)
|
|
19
|
+
@route = route
|
|
20
|
+
@options = options
|
|
21
|
+
@params = params
|
|
22
|
+
@request_body_registry = request_body_registry
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def convert
|
|
26
|
+
return nil if route_method == GET_METHOD || route_method == DELETE_METHOD
|
|
27
|
+
return nil if params.empty?
|
|
28
|
+
|
|
29
|
+
body_params = Models::RequestBody::Parameters.new(route: route, params: params).extract
|
|
30
|
+
return nil if body_params.empty?
|
|
31
|
+
|
|
32
|
+
build_request_body(body_params)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def route_method
|
|
38
|
+
route.request_method
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def build_request_body(body_params)
|
|
42
|
+
properties = {}
|
|
43
|
+
required_params = []
|
|
44
|
+
|
|
45
|
+
body_params.each do |key, param_options|
|
|
46
|
+
schema = Models::RequestBody::ParameterSchema.new(
|
|
47
|
+
route: route, key: key, param_options: param_options
|
|
48
|
+
).build
|
|
49
|
+
properties[key.to_s] = schema
|
|
50
|
+
required_params << key.to_s if param_options[:required]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
schema = {
|
|
54
|
+
type: 'object',
|
|
55
|
+
properties: properties
|
|
56
|
+
}
|
|
57
|
+
schema[:required] = required_params unless required_params.empty?
|
|
58
|
+
|
|
59
|
+
schema_ref = request_body_registry.register(schema)
|
|
60
|
+
|
|
61
|
+
{
|
|
62
|
+
required: required_params.any?,
|
|
63
|
+
content: {
|
|
64
|
+
content_type(body_params) => {
|
|
65
|
+
schema: schema_ref
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def content_type(body_params)
|
|
72
|
+
custom_content_type = extract_consumes_content_type
|
|
73
|
+
return custom_content_type if custom_content_type
|
|
74
|
+
|
|
75
|
+
return MULTIPART_FORM_DATA_CONTENT_TYPE if allows_file_upload?(body_params)
|
|
76
|
+
|
|
77
|
+
DEFAULT_CONTENT_TYPE
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def extract_consumes_content_type
|
|
81
|
+
consumes = route.settings.dig(:description, :consumes)
|
|
82
|
+
return nil if consumes.blank?
|
|
83
|
+
|
|
84
|
+
content_type = consumes.first
|
|
85
|
+
content_type = DEFAULT_CONTENT_TYPE if content_type == :json
|
|
86
|
+
content_type
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def allows_file_upload?(body_params)
|
|
90
|
+
body_params.any? do |_key, param_options|
|
|
91
|
+
param_options[:type]&.include?('API::Validations::Types::WorkhorseFile')
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gitlab
|
|
4
|
+
module GrapeOpenapi
|
|
5
|
+
module Converters
|
|
6
|
+
class ResponseConverter
|
|
7
|
+
def initialize(route, schema_registry)
|
|
8
|
+
@route = route
|
|
9
|
+
@schema_registry = schema_registry
|
|
10
|
+
@responses = {}
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def convert
|
|
14
|
+
extract_success_response
|
|
15
|
+
extract_failure_responses
|
|
16
|
+
@responses
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def extract_success_response
|
|
22
|
+
entity_definition = @route.options[:entity] || @route.options[:success]
|
|
23
|
+
|
|
24
|
+
case entity_definition
|
|
25
|
+
when nil
|
|
26
|
+
success_code = infer_success_code
|
|
27
|
+
add_simple_response(
|
|
28
|
+
status_code: success_code,
|
|
29
|
+
description: http_status_text(success_code)
|
|
30
|
+
)
|
|
31
|
+
when Class
|
|
32
|
+
process_class_entity(entity_definition)
|
|
33
|
+
when Hash
|
|
34
|
+
process_hash_entity(entity_definition)
|
|
35
|
+
when Array
|
|
36
|
+
process_array_entities(entity_definition)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def process_class_entity(entity_class)
|
|
41
|
+
success_code = infer_success_code
|
|
42
|
+
if EntityConverter.grape_entity?(entity_class)
|
|
43
|
+
add_response_with_entity(
|
|
44
|
+
status_code: success_code,
|
|
45
|
+
description: http_status_text(success_code),
|
|
46
|
+
entity_class: entity_class
|
|
47
|
+
)
|
|
48
|
+
else
|
|
49
|
+
add_simple_response(
|
|
50
|
+
status_code: success_code,
|
|
51
|
+
description: http_status_text(success_code)
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def process_hash_entity(entity_hash)
|
|
57
|
+
success_code = infer_success_code
|
|
58
|
+
|
|
59
|
+
if entity_hash[:model] && EntityConverter.grape_entity?(entity_hash[:model])
|
|
60
|
+
add_response_with_entity(
|
|
61
|
+
status_code: entity_hash[:code] || success_code,
|
|
62
|
+
description: entity_hash[:message] || http_status_text(entity_hash[:code] || success_code),
|
|
63
|
+
entity_class: entity_hash[:model],
|
|
64
|
+
example: entity_hash[:example],
|
|
65
|
+
examples: entity_hash[:examples]
|
|
66
|
+
)
|
|
67
|
+
else
|
|
68
|
+
add_simple_response(
|
|
69
|
+
status_code: entity_hash[:code] || success_code,
|
|
70
|
+
description: entity_hash[:message] || http_status_text(entity_hash[:code] || success_code)
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def process_array_entities(entity_array)
|
|
76
|
+
entity_array.each do |definition|
|
|
77
|
+
case definition
|
|
78
|
+
when Hash
|
|
79
|
+
process_array_hash_item(definition)
|
|
80
|
+
when Class
|
|
81
|
+
process_class_entity(definition)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def process_array_hash_item(definition)
|
|
87
|
+
if definition[:model] && EntityConverter.grape_entity?(definition[:model])
|
|
88
|
+
add_response_with_entity(
|
|
89
|
+
status_code: definition[:code] || infer_success_code,
|
|
90
|
+
description: definition[:message] || http_status_text(definition[:code] || infer_success_code),
|
|
91
|
+
entity_class: definition[:model],
|
|
92
|
+
example: definition[:example],
|
|
93
|
+
examples: definition[:examples]
|
|
94
|
+
)
|
|
95
|
+
else
|
|
96
|
+
add_simple_response(
|
|
97
|
+
status_code: definition[:code] || infer_success_code,
|
|
98
|
+
description: definition[:message] || http_status_text(definition[:code] || infer_success_code)
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def extract_failure_responses
|
|
104
|
+
http_codes = @route.http_codes.presence || infer_failure_codes
|
|
105
|
+
|
|
106
|
+
http_codes.each do |failure_def|
|
|
107
|
+
case failure_def
|
|
108
|
+
when Hash
|
|
109
|
+
add_simple_response(
|
|
110
|
+
status_code: failure_def[:code],
|
|
111
|
+
description: failure_def[:message] || http_status_text(failure_def[:code])
|
|
112
|
+
)
|
|
113
|
+
when Array
|
|
114
|
+
add_simple_response(
|
|
115
|
+
status_code: failure_def[0],
|
|
116
|
+
description: failure_def[1] || http_status_text(failure_def[0])
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def add_response_with_entity(status_code:, description:, entity_class:, example: nil, examples: nil)
|
|
123
|
+
response = Models::Response.new(
|
|
124
|
+
status_code: status_code,
|
|
125
|
+
description: description,
|
|
126
|
+
entity_class: entity_class,
|
|
127
|
+
example: example,
|
|
128
|
+
examples: examples
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
@responses[response.status_code] = response.to_h(@schema_registry)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def add_simple_response(status_code:, description:)
|
|
135
|
+
key = status_code.to_s
|
|
136
|
+
|
|
137
|
+
# `http_codes` (processed by `extract_failure_responses`) may include
|
|
138
|
+
# success codes that are also covered by a `success`/`entity`
|
|
139
|
+
# declaration. Preserve the existing response content (the entity
|
|
140
|
+
# `$ref`) and only refresh the description, so the entity is not
|
|
141
|
+
# silently dropped.
|
|
142
|
+
if @responses[key]
|
|
143
|
+
@responses[key][:description] = description
|
|
144
|
+
else
|
|
145
|
+
@responses[key] = { description: description }
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def infer_success_code
|
|
150
|
+
case http_method
|
|
151
|
+
when 'POST' then 201
|
|
152
|
+
when 'DELETE' then 204
|
|
153
|
+
else 200
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def infer_failure_codes
|
|
158
|
+
codes = []
|
|
159
|
+
codes << 404 if path_has_resource_parameters?
|
|
160
|
+
codes << 400 if route_params.any? || %w[POST PUT PATCH].include?(http_method)
|
|
161
|
+
codes.map { |code| { code: code, message: http_status_text(code) } }
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def route_params
|
|
165
|
+
@route.options[:params] || {}
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def path_has_resource_parameters?
|
|
169
|
+
path = @route.path
|
|
170
|
+
.gsub('.:format', '')
|
|
171
|
+
.gsub(':version', '')
|
|
172
|
+
path.include?(':')
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def http_status_text(code)
|
|
176
|
+
Rack::Utils::HTTP_STATUS_CODES[code.to_i] || 'Success'
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def http_method
|
|
180
|
+
@route.request_method
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|