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,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