grape-oas 1.4.0 → 1.5.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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +43 -0
  3. data/README.md +27 -10
  4. data/UPGRADING.md +104 -0
  5. data/grape-oas.gemspec +2 -1
  6. data/lib/grape_oas/api_model/api.rb +2 -1
  7. data/lib/grape_oas/api_model/schema.rb +1 -1
  8. data/lib/grape_oas/api_model_builder.rb +3 -2
  9. data/lib/grape_oas/api_model_builders/concerns/content_type_resolver.rb +9 -1
  10. data/lib/grape_oas/api_model_builders/concerns/oas_utilities.rb +13 -0
  11. data/lib/grape_oas/api_model_builders/concerns/route_validations.rb +26 -0
  12. data/lib/grape_oas/api_model_builders/concerns/type_resolver.rb +4 -2
  13. data/lib/grape_oas/api_model_builders/operation.rb +3 -4
  14. data/lib/grape_oas/api_model_builders/path.rb +33 -6
  15. data/lib/grape_oas/api_model_builders/request.rb +14 -16
  16. data/lib/grape_oas/api_model_builders/request_params.rb +83 -3
  17. data/lib/grape_oas/api_model_builders/request_params_support/nested_params_builder.rb +1 -1
  18. data/lib/grape_oas/api_model_builders/request_params_support/param_schema_builder.rb +1 -1
  19. data/lib/grape_oas/api_model_builders/request_params_support/schema_enhancer.rb +3 -9
  20. data/lib/grape_oas/api_model_builders/response.rb +28 -4
  21. data/lib/grape_oas/constants.rb +16 -4
  22. data/lib/grape_oas/documentation_extension.rb +7 -9
  23. data/lib/grape_oas/exporter/concerns/schema_indexer.rb +14 -3
  24. data/lib/grape_oas/exporter/oas2/operation.rb +4 -2
  25. data/lib/grape_oas/exporter/oas2/parameter.rb +73 -13
  26. data/lib/grape_oas/exporter/oas2/paths.rb +1 -0
  27. data/lib/grape_oas/exporter/oas2/response.rb +6 -4
  28. data/lib/grape_oas/exporter/oas2/schema.rb +56 -28
  29. data/lib/grape_oas/exporter/oas2_schema.rb +7 -5
  30. data/lib/grape_oas/exporter/oas3/operation.rb +5 -3
  31. data/lib/grape_oas/exporter/oas3/parameter.rb +4 -2
  32. data/lib/grape_oas/exporter/oas3/paths.rb +1 -0
  33. data/lib/grape_oas/exporter/oas3/request_body.rb +5 -8
  34. data/lib/grape_oas/exporter/oas3/response.rb +6 -9
  35. data/lib/grape_oas/exporter/oas3/schema.rb +232 -84
  36. data/lib/grape_oas/exporter/oas31/schema.rb +12 -2
  37. data/lib/grape_oas/exporter/oas31_schema.rb +1 -1
  38. data/lib/grape_oas/exporter/oas3_schema.rb +3 -2
  39. data/lib/grape_oas/introspectors/entity_introspector.rb +2 -1
  40. data/lib/grape_oas/introspectors/entity_introspector_support/exposure_processor.rb +55 -20
  41. data/lib/grape_oas/introspectors/entity_introspector_support/property_extractor.rb +2 -8
  42. data/lib/grape_oas/introspectors/entity_introspector_support/type_schema_resolver.rb +4 -20
  43. data/lib/grape_oas/range_utils.rb +25 -2
  44. data/lib/grape_oas/type_resolvers/array_resolver.rb +53 -19
  45. data/lib/grape_oas/type_resolvers/base.rb +2 -2
  46. data/lib/grape_oas/type_resolvers/default_resolver.rb +23 -0
  47. data/lib/grape_oas/type_resolvers/dry_type_resolver.rb +1 -1
  48. data/lib/grape_oas/type_resolvers/primitive_resolver.rb +29 -46
  49. data/lib/grape_oas/type_resolvers/registry.rb +31 -15
  50. data/lib/grape_oas/version.rb +1 -1
  51. data/lib/grape_oas.rb +37 -7
  52. metadata +5 -4
  53. data/CONTRIBUTING.md +0 -87
  54. data/RELEASING.md +0 -109
@@ -3,7 +3,13 @@
3
3
  module GrapeOAS
4
4
  module ApiModelBuilders
5
5
  class RequestParams
6
- ROUTE_PARAM_REGEX = /(?<=:)\w+/
6
+ include Concerns::RouteValidations
7
+
8
+ ROUTE_PARAM_REGEX = /(?<=[:*])\w+/
9
+
10
+ def self.path_param_names(path)
11
+ path.sub(/(\(\.[^)]+\))+$/, "").scan(ROUTE_PARAM_REGEX)
12
+ end
7
13
 
8
14
  attr_reader :api, :route, :path_param_name_map
9
15
 
@@ -14,8 +20,8 @@ module GrapeOAS
14
20
  end
15
21
 
16
22
  def build
17
- route_params = route.path.scan(ROUTE_PARAM_REGEX)
18
- all_params = route.options[:params] || {}
23
+ route_params = self.class.path_param_names(route.path)
24
+ all_params = declared_params
19
25
 
20
26
  # Check if we have nested params (bracket notation)
21
27
  has_nested = all_params.keys.any? { |k| k.include?("[") }
@@ -152,6 +158,80 @@ module GrapeOAS
152
158
  spec.dig(:documentation, :collectionFormat) || spec.dig(:documentation, :collection_format)
153
159
  end
154
160
 
161
+ # Grape 3.x stores documented params in `route.options[:params]`.
162
+ # Grape 4.0 moved them to `Route#params` (grape#2785) and no longer
163
+ # copies the hash into options. Path captures that are not Hash specs
164
+ # (empty-string defaults from the pattern) are dropped.
165
+ def declared_params
166
+ specs = params_from_options || params_from_route
167
+ return {} unless specs.is_a?(Hash)
168
+
169
+ flat = specs.select { |_name, spec| spec.is_a?(Hash) }
170
+ conditional = conditional_param_names
171
+ return flat if conditional.empty?
172
+
173
+ flat.each_with_object({}) do |(name, spec), params|
174
+ params[name] = conditional.include?(name.to_s) ? spec.merge(required: false) : spec
175
+ end
176
+ end
177
+
178
+ # Returns names of params that Grape will only validate conditionally
179
+ # (declared inside a `given` block). Their validators have a
180
+ # `params_scope` with `@dependent_on` set.
181
+ def conditional_param_names
182
+ return Set.new unless route.respond_to?(:app) && route.app.respond_to?(:inheritable_setting)
183
+
184
+ validations = grape_route_validations(route.app.inheritable_setting)
185
+ return Set.new unless validations.is_a?(Array)
186
+
187
+ conditional = Set.new
188
+ unconditional = Set.new
189
+ validations.each do |validator|
190
+ scope, attrs = validator_details(validator)
191
+ next unless scope.respond_to?(:full_name)
192
+
193
+ target = conditional_scope?(scope) ? conditional : unconditional
194
+ Array(attrs).each { |attr| target << scope.full_name(attr) }
195
+ end
196
+ conditional - unconditional
197
+ end
198
+
199
+ # Grape < 3.2 stores validators as hashes; >= 3.2 stores instances.
200
+ def validator_details(validator)
201
+ presence = Grape::Validations::Validators::PresenceValidator
202
+ if validator.is_a?(Hash)
203
+ return unless validator[:validator_class].is_a?(Class) && validator[:validator_class] <= presence
204
+
205
+ [validator[:params_scope], validator[:attributes]]
206
+ elsif validator.is_a?(presence)
207
+ [validator.instance_variable_get(:@scope), validator.attrs]
208
+ end
209
+ end
210
+
211
+ def conditional_scope?(scope)
212
+ while scope
213
+ return true if scope.instance_variable_get(:@dependent_on)&.any?
214
+
215
+ scope = scope.respond_to?(:parent) ? scope.parent : nil
216
+ end
217
+ false
218
+ end
219
+
220
+ def params_from_options
221
+ params = route.options[:params]
222
+ params if params.is_a?(Hash) && params.any?
223
+ end
224
+
225
+ def params_from_route
226
+ return unless route.respond_to?(:params)
227
+ # Grape 3.x Route#params(input = nil) merges path captures; skip it.
228
+ # Grape 4.0 Route#params takes no arguments and is the documentation hash.
229
+ return unless route.method(:params).arity.zero?
230
+
231
+ params = route.params
232
+ params if params.is_a?(Hash) && params.any?
233
+ end
234
+
155
235
  def location_resolver
156
236
  RequestParamsSupport::ParamLocationResolver
157
237
  end
@@ -147,7 +147,7 @@ module GrapeOAS
147
147
  schema.additional_properties = doc[:additional_properties] if doc.key?(:additional_properties)
148
148
  schema.unevaluated_properties = doc[:unevaluated_properties] if doc.key?(:unevaluated_properties)
149
149
  schema.format = doc[:format] if doc[:format]
150
- schema.examples = doc[:example] if doc[:example]
150
+ schema.examples = doc[:example] if doc.key?(:example)
151
151
  nullable = SchemaEnhancer.extract_nullable(doc)
152
152
  schema.nullable = (schema.nullable || nullable) if schema.respond_to?(:nullable=)
153
153
  end
@@ -36,7 +36,7 @@ module GrapeOAS
36
36
 
37
37
  # is_array: true on a typed array like "[String]" is redundant and would
38
38
  # double-wrap it as Array<Array<...>> via build_primitive_array_schema.
39
- return GrapeOAS.type_resolvers.build_schema(raw_type) if doc[:is_array] && extract_typed_array_member(raw_type)
39
+ return GrapeOAS.type_resolvers.build_schema(raw_type) if doc[:is_array] && TypeResolvers::ArrayResolver.handles?(raw_type)
40
40
  return build_primitive_array_schema(doc_type, raw_type) if doc[:is_array]
41
41
  return build_entity_schema(doc_type) if grape_entity?(doc_type)
42
42
  return build_entity_schema(raw_type) if grape_entity?(raw_type)
@@ -24,15 +24,9 @@ module GrapeOAS
24
24
  apply_default(schema, spec, doc)
25
25
  end
26
26
 
27
- # Extracts nullable flag from a documentation hash.
28
- #
29
- # @param doc [Hash] the documentation hash
30
- # @return [Boolean] true if nullable
31
- def self.extract_nullable(doc)
32
- doc[:nullable] || (doc[:x].is_a?(Hash) && doc[:x][:nullable]) || false
33
- end
34
-
35
27
  class << self
28
+ include GrapeOAS::ApiModelBuilders::Concerns::OasUtilities
29
+
36
30
  private
37
31
 
38
32
  def apply_additional_properties(schema, doc)
@@ -58,7 +52,7 @@ module GrapeOAS
58
52
 
59
53
  def apply_format_and_example(schema, doc)
60
54
  schema.format = doc[:format] if doc[:format] && schema.respond_to?(:format=)
61
- schema.examples = doc[:example] if doc[:example] && schema.respond_to?(:examples=)
55
+ schema.examples = doc[:example] if doc.key?(:example) && schema.respond_to?(:examples=)
62
56
  end
63
57
 
64
58
  def apply_values(schema, spec)
@@ -65,6 +65,9 @@ module GrapeOAS
65
65
  # Else if any spec has `one_of:`, build a oneOf response from one_of entries and
66
66
  # any regular specs in the group (this branch only runs when no `as:` entries exist)
67
67
  def build_response_from_group(group_specs)
68
+ first_spec = group_specs.first
69
+ return build_response_without_content(first_spec) if bodyless_status?(first_spec[:code])
70
+
68
71
  has_one_of = group_specs.any? { |s| s[:one_of] && !s[:one_of].empty? }
69
72
  has_as = group_specs.any? { |s| !s[:as].nil? }
70
73
 
@@ -169,14 +172,16 @@ module GrapeOAS
169
172
 
170
173
  # Merges examples from multiple specs
171
174
  def merge_examples(specs)
172
- examples = specs.map { |s| s[:examples] }.compact
175
+ examples = specs.filter_map { |s| s[:examples] }
173
176
  return nil if examples.empty?
174
177
 
175
178
  examples.reduce({}, :merge)
176
179
  end
177
180
 
178
181
  def build_response_from_spec(spec)
182
+ code_str = spec[:code].to_s
179
183
  entity_schema = build_schema(spec[:entity])
184
+ entity_schema = array_schema(entity_schema) if spec[:is_array]
180
185
  schema = wrap_with_root(entity_schema, spec[:entity], is_array: spec[:is_array])
181
186
  media_types = Array(response_content_types).map do |mime|
182
187
  build_media_type(
@@ -189,7 +194,7 @@ module GrapeOAS
189
194
  description = message.is_a?(String) ? message : message&.to_s
190
195
 
191
196
  GrapeOAS::ApiModel::Response.new(
192
- http_status: spec[:code].to_s,
197
+ http_status: code_str,
193
198
  description: description || "Success",
194
199
  media_types: media_types,
195
200
  headers: normalize_headers(spec[:headers]) || headers_from_route,
@@ -198,6 +203,25 @@ module GrapeOAS
198
203
  )
199
204
  end
200
205
 
206
+ def build_response_without_content(spec)
207
+ message = spec[:message]
208
+ description = message.is_a?(String) ? message : message&.to_s
209
+
210
+ GrapeOAS::ApiModel::Response.new(
211
+ http_status: spec[:code].to_s,
212
+ description: description || "Success",
213
+ media_types: [],
214
+ headers: normalize_headers(spec[:headers]) || headers_from_route,
215
+ extensions: spec[:extensions] || extensions_from_route,
216
+ )
217
+ end
218
+
219
+ # RFC 9110 §6.4.1 and §15.3.6: these responses never carry a message body.
220
+ def bodyless_status?(code)
221
+ status = code.to_s.to_i
222
+ status.between?(100, 199) || status == 204 || status == 205 || status == 304
223
+ end
224
+
201
225
  def array_schema(schema)
202
226
  GrapeOAS::ApiModel::Schema.new(
203
227
  type: Constants::SchemaTypes::ARRAY,
@@ -238,7 +262,7 @@ module GrapeOAS
238
262
  # Build schema for response body
239
263
  # Delegates to EntityIntrospector when entity is present
240
264
  def build_schema(entity_class)
241
- return GrapeOAS::ApiModel::Schema.new(type: Constants::SchemaTypes::STRING) unless entity_class
265
+ return GrapeOAS::ApiModel::Schema.new unless entity_class
242
266
 
243
267
  GrapeOAS.introspectors.build_schema(entity_class, stack: [], registry: {})
244
268
  end
@@ -291,7 +315,7 @@ module GrapeOAS
291
315
  end
292
316
 
293
317
  def response_content_types
294
- resolve_content_types
318
+ explicit_media_types(:produces) || resolve_content_types
295
319
  end
296
320
  end
297
321
  end
@@ -12,6 +12,7 @@ module GrapeOAS
12
12
  OBJECT = "object"
13
13
  ARRAY = "array"
14
14
  FILE = "file"
15
+ NULL = "null"
15
16
 
16
17
  ALL = [STRING, INTEGER, NUMBER, BOOLEAN, OBJECT, ARRAY, FILE].freeze
17
18
  end
@@ -45,6 +46,11 @@ module GrapeOAS
45
46
  TYPE_ARRAY = :type_array
46
47
  # OAS 2.0: emits `"x-nullable": true` extension
47
48
  EXTENSION = :extension
49
+
50
+ # Version-specific defaults used by exporters
51
+ OAS2_DEFAULT = EXTENSION
52
+ OAS3_DEFAULT = KEYWORD
53
+ OAS31_DEFAULT = TYPE_ARRAY
48
54
  end
49
55
 
50
56
  # Maximum number of elements to expand from a non-numeric Range into an enum array.
@@ -52,12 +58,18 @@ module GrapeOAS
52
58
  MAX_ENUM_RANGE_SIZE = 100
53
59
 
54
60
  # Regex patterns for Grape's stringified type notations.
55
- # Grape converts `type: [SomeClass]` to "[SomeClass]" and
56
- # `type: [String, Integer]` to "[String, Integer]" for documentation.
61
+ # Grape's ParamsDocumentation TypeCache stores `coerce_type.to_s`:
62
+ # - `type: [SomeClass]` / `type: Array[SomeClass]` → "[SomeClass]" (Array#to_s)
63
+ # - `types: [String, Integer]` → "[String, Integer]" (Array#to_s of the types list)
64
+ # - Grape 3.3+ `type: Array[Integer, String]` → "Array[Integer, String]"
65
+ # (VariantCollectionCoercer#to_s, grape#2758). Distinct from the `types:`
66
+ # form so a collection of variant members is not confused with a scalar
67
+ # that accepts multiple types. `Set[...]` is the same for Set containers.
57
68
  module TypePatterns
58
69
  CONST_NAME = /(?:::)?[A-Z]\w*(?:::[A-Z]\w*)*/
59
- TYPED_ARRAY = /\A\[(?<inner>#{CONST_NAME})\]\z/
70
+ TYPED_ARRAY = /\A(?:(?<container>Array|Set))?\[(?<inner>#{CONST_NAME})\]\z/
60
71
  MULTI_TYPE = /\A\[(#{CONST_NAME}(?:,\s*#{CONST_NAME})+)\]\z/
72
+ VARIANT_COLLECTION = /\A(?<container>Array|Set)\[(?<inner>#{CONST_NAME}(?:,\s*#{CONST_NAME})+)\]\z/
61
73
  end
62
74
 
63
75
  # Default values for OpenAPI spec when not provided by user
@@ -95,7 +107,7 @@ module GrapeOAS
95
107
  "float" => { type: SchemaTypes::NUMBER, format: "float" },
96
108
  "bigdecimal" => { type: SchemaTypes::NUMBER, format: "double" },
97
109
  "string" => { type: SchemaTypes::STRING },
98
- "integer" => { type: SchemaTypes::INTEGER, format: "int32" },
110
+ "integer" => { type: SchemaTypes::INTEGER },
99
111
  "number" => { type: SchemaTypes::NUMBER, format: "double" },
100
112
  "boolean" => { type: SchemaTypes::BOOLEAN },
101
113
  "grape::api::boolean" => { type: SchemaTypes::BOOLEAN },
@@ -3,9 +3,7 @@
3
3
  module GrapeOAS
4
4
  module DocumentationExtension
5
5
  # Primary entry for grape-oas documentation
6
- def add_oas_documentation(**options)
7
- return if options.delete(:hide_documentation_path)
8
-
6
+ def add_oas_documentation(hide_documentation_path: true, **options)
9
7
  # Prefer grape-oas namespaced options to avoid clashing with grape-swagger
10
8
  default_mount_path = options.delete(:oas_mount_path) ||
11
9
  options.delete(:mount_path) ||
@@ -25,13 +23,13 @@ module GrapeOAS
25
23
  api = self
26
24
 
27
25
  mount_paths.each do |key, path|
28
- add_documentation_routes(path, key, default_format, cache_control, etag_value, options, api)
26
+ add_documentation_routes(path, key, default_format, cache_control, etag_value, options, api, hidden: hide_documentation_path)
29
27
  end
30
28
  end
31
29
 
32
- def add_documentation_routes(path, key, default_format, cache_control, etag_value, options, api)
30
+ def add_documentation_routes(path, key, default_format, cache_control, etag_value, options, api, hidden: false)
33
31
  # Main route without namespace filter
34
- add_route(path) do
32
+ add_route(path, hidden: hidden) do
35
33
  GrapeOAS::DocumentationExtension.generate_documentation(
36
34
  self, api, key, default_format, cache_control, etag_value, options, nil,
37
35
  )
@@ -39,7 +37,7 @@ module GrapeOAS
39
37
 
40
38
  # Route with namespace filter: /swagger_doc/:namespace
41
39
  # Supports nested namespaces via *namespace (catches slashes)
42
- add_route("#{path}/*namespace") do
40
+ add_route("#{path}/*namespace", hidden: hidden) do
43
41
  namespace_filter = params[:namespace]&.sub(/\.json$/, "")
44
42
  GrapeOAS::DocumentationExtension.generate_documentation(
45
43
  self, api, key, default_format, cache_control, etag_value, options, namespace_filter,
@@ -89,10 +87,10 @@ module GrapeOAS
89
87
  private
90
88
 
91
89
  # Minimal route mounting helper
92
- def add_route(path, &block)
90
+ def add_route(path, hidden: false, &block)
93
91
  api_class = self
94
92
  namespace do
95
- get(path) { instance_exec(api_class, &block) }
93
+ get(path, hidden: hidden) { instance_exec(api_class, &block) }
96
94
  end
97
95
  end
98
96
 
@@ -47,10 +47,21 @@ module GrapeOAS
47
47
  end
48
48
  end
49
49
 
50
- def index_schema(schema, index)
51
- return unless schema.respond_to?(:canonical_name) && schema.canonical_name
50
+ def index_schema(schema, index, seen = Set.new)
51
+ return unless schema
52
+
53
+ schema_id = schema.object_id
54
+ return if seen.include?(schema_id)
52
55
 
53
- index[schema.canonical_name] ||= schema
56
+ seen << schema_id
57
+ index[schema.canonical_name] ||= schema if schema.respond_to?(:canonical_name) && schema.canonical_name
58
+ index_schema(schema.items, index, seen) if schema.respond_to?(:items) && schema.items
59
+ schema.properties.each_value { |child| index_schema(child, index, seen) } if schema.respond_to?(:properties) && schema.properties
60
+ %i[all_of one_of any_of].each do |composition|
61
+ next unless schema.respond_to?(composition)
62
+
63
+ Array(schema.public_send(composition)).each { |child| index_schema(child, index, seen) }
64
+ end
54
65
  end
55
66
 
56
67
  def collect_refs(schema, pending, seen = Set.new)
@@ -15,8 +15,10 @@ module GrapeOAS
15
15
  {
16
16
  "consumes" => consumes,
17
17
  "produces" => produces,
18
- "parameters" => Parameter.new(@op, @ref_tracker, nullable_strategy: strategy).build,
19
- "responses" => Response.new(@op.responses, @ref_tracker, nullable_strategy: strategy).build
18
+ "parameters" => Parameter.new(@op, @ref_tracker, nullable_strategy: strategy,
19
+ composition_extensions: @options[:composition_extensions],).build,
20
+ "responses" => Response.new(@op.responses, @ref_tracker, nullable_strategy: strategy,
21
+ composition_extensions: @options[:composition_extensions],).build
20
22
  }
21
23
  end
22
24
 
@@ -5,7 +5,7 @@ module GrapeOAS
5
5
  module OAS2
6
6
  class Parameter
7
7
  PRIMITIVE_MAPPINGS = {
8
- Constants::SchemaTypes::INTEGER => { type: Constants::SchemaTypes::INTEGER, format: "int32" },
8
+ Constants::SchemaTypes::INTEGER => { type: Constants::SchemaTypes::INTEGER },
9
9
  "long" => { type: Constants::SchemaTypes::INTEGER, format: "int64" },
10
10
  "float" => { type: Constants::SchemaTypes::NUMBER, format: "float" },
11
11
  "double" => { type: Constants::SchemaTypes::NUMBER, format: "double" },
@@ -18,15 +18,24 @@ module GrapeOAS
18
18
  "uuid" => { type: Constants::SchemaTypes::STRING, format: "uuid" }
19
19
  }.freeze
20
20
 
21
- def initialize(operation, ref_tracker = nil, nullable_strategy: nil)
21
+ def initialize(operation, ref_tracker = nil, nullable_strategy: nil, composition_extensions: false)
22
22
  @op = operation
23
23
  @ref_tracker = ref_tracker
24
24
  @nullable_strategy = nullable_strategy
25
+ @composition_extensions = composition_extensions
25
26
  end
26
27
 
28
+ FORM_MEDIA_TYPES = %w[application/x-www-form-urlencoded multipart/form-data].freeze
29
+
27
30
  def build
28
31
  params = Array(@op.parameters).map { |param| build_parameter(param) }
29
- params << build_body_parameter(@op.request_body) if @op.request_body
32
+ if @op.request_body
33
+ if form_only_request?
34
+ params.concat(build_form_parameters(@op.request_body))
35
+ else
36
+ params << build_body_parameter(@op.request_body)
37
+ end
38
+ end
30
39
  params
31
40
  end
32
41
 
@@ -76,6 +85,7 @@ module GrapeOAS
76
85
  result["maxLength"] = schema.max_length if schema.respond_to?(:max_length) && !schema.max_length.nil?
77
86
  result["minItems"] = schema.min_items if schema.respond_to?(:min_items) && !schema.min_items.nil?
78
87
  result["maxItems"] = schema.max_items if schema.respond_to?(:max_items) && !schema.max_items.nil?
88
+ result["uniqueItems"] = true if schema.respond_to?(:unique_items) && schema.unique_items
79
89
  result["pattern"] = schema.pattern if schema.respond_to?(:pattern) && schema.pattern
80
90
  result["enum"] = normalize_enum(schema.enum, result["type"]) if schema.respond_to?(:enum) && schema.enum
81
91
  result["default"] = schema.default if schema.respond_to?(:default) && !schema.default.nil?
@@ -84,15 +94,18 @@ module GrapeOAS
84
94
  def normalize_enum(enum_vals, type)
85
95
  return nil unless enum_vals.is_a?(Array)
86
96
 
87
- coerced = enum_vals.map do |v|
88
- case type
89
- when Constants::SchemaTypes::INTEGER then v.to_i if v.respond_to?(:to_i)
90
- when Constants::SchemaTypes::NUMBER then v.to_f if v.respond_to?(:to_f)
91
- else v
92
- end
93
- end.compact
97
+ result = enum_vals.each_with_object([]) do |v, acc|
98
+ next if v.nil?
99
+
100
+ coerced_v = case type
101
+ when Constants::SchemaTypes::INTEGER then v.to_i if v.respond_to?(:to_i)
102
+ when Constants::SchemaTypes::NUMBER then v.to_f if v.respond_to?(:to_f)
103
+ else v
104
+ end
105
+ acc << coerced_v unless coerced_v.nil?
106
+ end
94
107
 
95
- result = coerced.uniq
108
+ result.uniq!
96
109
  return nil if result.empty?
97
110
 
98
111
  result
@@ -100,12 +113,58 @@ module GrapeOAS
100
113
 
101
114
  def apply_collection_format(result, param, type)
102
115
  return unless type == Constants::SchemaTypes::ARRAY
116
+
117
+ result["items"] = build_schema_or_ref(param.schema.items) if param.schema.items
103
118
  return unless param.collection_format
104
119
 
105
120
  valid_formats = %w[csv ssv tsv pipes multi brackets]
106
121
  result["collectionFormat"] = param.collection_format if valid_formats.include?(param.collection_format)
107
122
  end
108
123
 
124
+ def form_only_request?
125
+ consumes = Array(@op.consumes)
126
+ consumes.any? && consumes.all? { |mime| FORM_MEDIA_TYPES.include?(mime) }
127
+ end
128
+
129
+ def build_form_parameters(request_body)
130
+ schema = Array(request_body.media_types).first&.schema
131
+ return [] unless schema
132
+
133
+ unless [nil, "object"].include?(schema.type) && !composition?(schema)
134
+ raise ArgumentError, "OAS2 form bodies must have object properties; use OAS3 for this request schema"
135
+ end
136
+
137
+ required = Array(schema.required).map(&:to_s)
138
+ schema.properties.map do |name, property_schema|
139
+ unless form_property?(property_schema)
140
+ raise ArgumentError, "OAS2 cannot represent form field #{name.inspect}; use OAS3 for complex form fields"
141
+ end
142
+
143
+ build_parameter(
144
+ ApiModel::Parameter.new(
145
+ name: name.to_s,
146
+ location: "formData",
147
+ required: required.include?(name.to_s),
148
+ description: property_schema.description,
149
+ schema: property_schema,
150
+ ),
151
+ )
152
+ end
153
+ end
154
+
155
+ def form_property?(schema, array_item: false)
156
+ return false unless schema && !composition?(schema)
157
+ return false if array_item && schema.canonical_name
158
+ return form_property?(schema.items, array_item: true) if schema.type == "array"
159
+
160
+ types = array_item ? %w[string integer number boolean] : %w[string integer number boolean file]
161
+ types.include?(schema.type)
162
+ end
163
+
164
+ def composition?(schema)
165
+ schema.all_of&.any? || schema.one_of&.any? || schema.any_of&.any?
166
+ end
167
+
109
168
  def build_body_parameter(request_body)
110
169
  schema = build_body_schema(request_body)
111
170
  name = derive_body_name(request_body)
@@ -139,10 +198,11 @@ module GrapeOAS
139
198
  def build_schema_or_ref(schema)
140
199
  if schema.respond_to?(:canonical_name) && schema.canonical_name
141
200
  @ref_tracker << schema.canonical_name if @ref_tracker
142
- ref_name = schema.canonical_name.gsub("::", "_")
201
+ ref_name = GrapeOAS.schema_ref_name.call(schema.canonical_name)
143
202
  { "$ref" => "#/definitions/#{ref_name}" }
144
203
  else
145
- Schema.new(schema, @ref_tracker, nullable_strategy: @nullable_strategy).build
204
+ Schema.new(schema, @ref_tracker, nullable_strategy: @nullable_strategy,
205
+ composition_extensions: @composition_extensions,).build
146
206
  end
147
207
  end
148
208
  end
@@ -12,6 +12,7 @@ module GrapeOAS
12
12
  def build_operation(operation)
13
13
  Operation.new(operation, @ref_tracker,
14
14
  nullable_strategy: @options[:nullable_strategy],
15
+ composition_extensions: @options[:composition_extensions],
15
16
  suppress_default_error_response: @options[:suppress_default_error_response],).build
16
17
  end
17
18
  end
@@ -4,10 +4,11 @@ module GrapeOAS
4
4
  module Exporter
5
5
  module OAS2
6
6
  class Response
7
- def initialize(responses, ref_tracker = nil, nullable_strategy: nil)
7
+ def initialize(responses, ref_tracker = nil, nullable_strategy: nil, composition_extensions: false)
8
8
  @responses = responses
9
9
  @ref_tracker = ref_tracker
10
10
  @nullable_strategy = nullable_strategy
11
+ @composition_extensions = composition_extensions
11
12
  end
12
13
 
13
14
  def build
@@ -34,10 +35,11 @@ module GrapeOAS
34
35
  def build_schema_or_ref(schema)
35
36
  if schema.respond_to?(:canonical_name) && schema.canonical_name
36
37
  @ref_tracker << schema.canonical_name if @ref_tracker
37
- ref_name = schema.canonical_name.gsub("::", "_")
38
+ ref_name = GrapeOAS.schema_ref_name.call(schema.canonical_name)
38
39
  { "$ref" => "#/definitions/#{ref_name}" }
39
40
  else
40
- Schema.new(schema, @ref_tracker, nullable_strategy: @nullable_strategy).build
41
+ Schema.new(schema, @ref_tracker, nullable_strategy: @nullable_strategy,
42
+ composition_extensions: @composition_extensions,).build
41
43
  end
42
44
  end
43
45
 
@@ -60,7 +62,7 @@ module GrapeOAS
60
62
  end
61
63
 
62
64
  def build_examples(media_types, response_examples = nil)
63
- return nil unless media_types
65
+ return nil if media_types.nil? || media_types.empty?
64
66
 
65
67
  mt = Array(media_types).first
66
68
  # Media type examples take precedence over response-level examples