grape-oas 1.4.0 → 1.5.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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +63 -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 +22 -29
  16. data/lib/grape_oas/api_model_builders/request_params.rb +121 -44
  17. data/lib/grape_oas/api_model_builders/request_params_support/nested_params_builder.rb +1 -2
  18. data/lib/grape_oas/api_model_builders/request_params_support/param_location_resolver.rb +33 -27
  19. data/lib/grape_oas/api_model_builders/request_params_support/param_schema_builder.rb +8 -27
  20. data/lib/grape_oas/api_model_builders/request_params_support/schema_enhancer.rb +4 -10
  21. data/lib/grape_oas/api_model_builders/response.rb +33 -5
  22. data/lib/grape_oas/api_model_builders/response_parsers/base.rb +11 -0
  23. data/lib/grape_oas/api_model_builders/response_parsers/documentation_responses_parser.rb +5 -2
  24. data/lib/grape_oas/api_model_builders/response_parsers/http_codes_parser.rb +82 -17
  25. data/lib/grape_oas/constants.rb +30 -4
  26. data/lib/grape_oas/documentation_extension.rb +7 -9
  27. data/lib/grape_oas/exporter/concerns/schema_indexer.rb +14 -3
  28. data/lib/grape_oas/exporter/oas2/operation.rb +4 -2
  29. data/lib/grape_oas/exporter/oas2/parameter.rb +125 -22
  30. data/lib/grape_oas/exporter/oas2/paths.rb +1 -0
  31. data/lib/grape_oas/exporter/oas2/response.rb +6 -4
  32. data/lib/grape_oas/exporter/oas2/schema.rb +56 -28
  33. data/lib/grape_oas/exporter/oas2_schema.rb +7 -5
  34. data/lib/grape_oas/exporter/oas3/operation.rb +5 -3
  35. data/lib/grape_oas/exporter/oas3/parameter.rb +4 -2
  36. data/lib/grape_oas/exporter/oas3/paths.rb +1 -0
  37. data/lib/grape_oas/exporter/oas3/request_body.rb +5 -8
  38. data/lib/grape_oas/exporter/oas3/response.rb +6 -9
  39. data/lib/grape_oas/exporter/oas3/schema.rb +232 -84
  40. data/lib/grape_oas/exporter/oas31/schema.rb +12 -2
  41. data/lib/grape_oas/exporter/oas31_schema.rb +1 -1
  42. data/lib/grape_oas/exporter/oas3_schema.rb +3 -2
  43. data/lib/grape_oas/introspectors/entity_introspector.rb +2 -1
  44. data/lib/grape_oas/introspectors/entity_introspector_support/exposure_processor.rb +73 -23
  45. data/lib/grape_oas/introspectors/entity_introspector_support/property_extractor.rb +2 -8
  46. data/lib/grape_oas/introspectors/entity_introspector_support/type_schema_resolver.rb +4 -20
  47. data/lib/grape_oas/range_utils.rb +25 -2
  48. data/lib/grape_oas/type_resolvers/array_resolver.rb +46 -19
  49. data/lib/grape_oas/type_resolvers/base.rb +2 -2
  50. data/lib/grape_oas/type_resolvers/default_resolver.rb +23 -0
  51. data/lib/grape_oas/type_resolvers/dry_type_resolver.rb +1 -1
  52. data/lib/grape_oas/type_resolvers/primitive_resolver.rb +29 -46
  53. data/lib/grape_oas/type_resolvers/registry.rb +31 -15
  54. data/lib/grape_oas/version.rb +1 -1
  55. data/lib/grape_oas.rb +37 -7
  56. metadata +5 -4
  57. data/CONTRIBUTING.md +0 -87
  58. data/RELEASING.md +0 -109
@@ -3,20 +3,31 @@
3
3
  module GrapeOAS
4
4
  module ApiModelBuilders
5
5
  module ResponseParsers
6
- # Parser for responses defined via :http_codes, :failure, or :success options
7
- # These are legacy grape-swagger formats that we support for compatibility
6
+ # Parser for responses defined via :http_codes, :failure, :success, or
7
+ # :default / :default_response (the OAS "default" catch-all).
8
8
  class HttpCodesParser
9
9
  include Base
10
10
 
11
+ DEFAULT_RESPONSE_MESSAGE = "Default Response"
12
+ DEFAULT_RESPONSE_CODE = "default"
13
+
11
14
  def applicable?(route)
12
15
  options_applicable?(route) || desc_block?(route)
13
16
  end
14
17
 
15
18
  def parse(route)
16
19
  specs = parse_from_options(route)
17
- return specs unless specs.empty?
20
+ specs = parse_from_desc(route) if specs.empty?
21
+ return specs if specs.any? { |spec| spec[:code].to_s == DEFAULT_RESPONSE_CODE }
22
+
23
+ specs + default_response_specs(route)
24
+ end
18
25
 
19
- parse_from_desc(route)
26
+ def default_response_specs(route)
27
+ data = route.options
28
+ value = default_response_value(data)
29
+ value ||= default_response_value(desc_data(route))
30
+ value ? parse_default_response(value, route) : []
20
31
  end
21
32
 
22
33
  private
@@ -46,9 +57,59 @@ module GrapeOAS
46
57
  def parse_values(data, route)
47
58
  return [] unless data.is_a?(Hash)
48
59
 
49
- %i[http_codes failure success].flat_map do |key|
60
+ specs = %i[http_codes failure success].flat_map do |key|
50
61
  parse_value(data[key], route)
51
62
  end
63
+ default_value = data[:default_response] || data[:default]
64
+ return specs unless default_value
65
+
66
+ specs.reject { |spec| spec[:code].to_s == DEFAULT_RESPONSE_CODE } +
67
+ parse_default_response(default_value, route)
68
+ end
69
+
70
+ # Grape 3.x stores `desc { default ... }` as :default. Grape 4.0
71
+ # (ruby-grape/grape#2861) renamed that key to :default_response and
72
+ # remaps the deprecated `default` alias at write time. Always emit the
73
+ # OAS "default" status — grape-swagger ignores a numeric `code:` here.
74
+ def parse_default_response(value, route)
75
+ entries_for(value).map { |entry| normalize_default_response_entry(entry, route) }
76
+ end
77
+
78
+ def normalize_default_response_entry(entry, route)
79
+ if entry.is_a?(Hash)
80
+ entry = normalize_hash_keys(entry)
81
+ {
82
+ code: DEFAULT_RESPONSE_CODE,
83
+ message: extract_description(entry) || DEFAULT_RESPONSE_MESSAGE,
84
+ entity: extract_entity(entry, nil),
85
+ headers: entry[:headers],
86
+ examples: entry[:examples],
87
+ as: entry[:as],
88
+ one_of: normalize_one_of(entry[:one_of]),
89
+ is_array: entry.key?(:is_array) ? entry[:is_array] : route.options[:is_array],
90
+ required: entry[:required]
91
+ }
92
+ else
93
+ {
94
+ code: DEFAULT_RESPONSE_CODE,
95
+ message: DEFAULT_RESPONSE_MESSAGE,
96
+ entity: entry,
97
+ headers: nil,
98
+ is_array: route.options[:is_array]
99
+ }
100
+ end
101
+ end
102
+
103
+ def default_response_value(data)
104
+ return unless data.is_a?(Hash)
105
+
106
+ data[:default_response] || data[:default]
107
+ end
108
+
109
+ def normalize_one_of(one_of)
110
+ return one_of unless one_of.is_a?(Array)
111
+
112
+ one_of.map { |entry| normalize_hash_keys(entry) }
52
113
  end
53
114
 
54
115
  def parse_value(value, route)
@@ -73,12 +134,14 @@ module GrapeOAS
73
134
  def options_applicable?(route)
74
135
  entity_hash = route.options[:entity].is_a?(Hash) ? route.options[:entity] : nil
75
136
  route.options[:http_codes] || route.options[:failure] || route.options[:success] ||
137
+ route.options[:default] || route.options[:default_response] ||
76
138
  (entity_hash && (entity_hash[:code] || entity_hash[:model] || entity_hash[:entity] || entity_hash[:one_of]))
77
139
  end
78
140
 
79
141
  def desc_block?(route)
80
142
  data = desc_data(route)
81
- data && (data[:success] || data[:failure] || data[:http_codes] || data[:entity])
143
+ data && (data[:success] || data[:failure] || data[:http_codes] || data[:entity] ||
144
+ data[:default] || data[:default_response])
82
145
  end
83
146
 
84
147
  def desc_block_has_explicit_success?(route)
@@ -122,17 +185,19 @@ module GrapeOAS
122
185
  end
123
186
 
124
187
  def normalize_entry(entry, route)
125
- case entry
126
- when Hash
127
- normalize_hash_entry(entry, route)
128
- when Array
129
- normalize_array_entry(entry, route)
130
- when Class, Module
131
- # Plain entity class (e.g., success TestEntity)
132
- normalize_entity_entry(entry, route)
133
- else
134
- normalize_plain_entry(entry, route)
135
- end
188
+ spec = case entry
189
+ when Hash
190
+ normalize_hash_entry(entry, route)
191
+ when Array
192
+ normalize_array_entry(entry, route)
193
+ when Class, Module
194
+ # Plain entity class (e.g., success TestEntity)
195
+ normalize_entity_entry(entry, route)
196
+ else
197
+ normalize_plain_entry(entry, route)
198
+ end
199
+ spec[:code] = normalize_status_code(spec[:code])
200
+ spec
136
201
  end
137
202
 
138
203
  def normalize_hash_entry(entry, route)
@@ -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,32 @@ 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/
73
+ end
74
+
75
+ def self.nil_type?(type)
76
+ return true if type.nil?
77
+
78
+ name = type.to_s
79
+ name == "NilClass" || name == "Nil" || name.end_with?("::Nil")
80
+ end
81
+
82
+ def self.nullable_type(types)
83
+ return unless types.is_a?(Array) && types.size == 2
84
+
85
+ nil_types, other_types = types.partition { |type| nil_type?(type) }
86
+ other_types.first if nil_types.size == 1
61
87
  end
62
88
 
63
89
  # Default values for OpenAPI spec when not provided by user
@@ -95,7 +121,7 @@ module GrapeOAS
95
121
  "float" => { type: SchemaTypes::NUMBER, format: "float" },
96
122
  "bigdecimal" => { type: SchemaTypes::NUMBER, format: "double" },
97
123
  "string" => { type: SchemaTypes::STRING },
98
- "integer" => { type: SchemaTypes::INTEGER, format: "int32" },
124
+ "integer" => { type: SchemaTypes::INTEGER },
99
125
  "number" => { type: SchemaTypes::NUMBER, format: "double" },
100
126
  "boolean" => { type: SchemaTypes::BOOLEAN },
101
127
  "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,24 +18,76 @@ 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
- params = Array(@op.parameters).map { |param| build_parameter(param) }
29
- params << build_body_parameter(@op.request_body) if @op.request_body
31
+ params = representable_parameters.map { |param| build_parameter(param) }
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
 
33
42
  private
34
43
 
44
+ # Swagger 2.0 restricts `in` to query|header|path|formData|body and
45
+ # non-body parameters to primitive types or arrays of primitives.
46
+ # Drop unsupported parameters rather than emit an invalid document,
47
+ # since ParamLocationResolver resolves per-parameter without knowing
48
+ # the target OAS version.
49
+ def representable_parameters
50
+ Array(@op.parameters).reject do |param|
51
+ if param.location == "cookie"
52
+ GrapeOAS.logger.warn("Dropping cookie parameter '#{param.name}': not representable in OAS 2.0")
53
+ true
54
+ elsif param.location != "body" &&
55
+ unrepresentable?(param.schema, location: param.location)
56
+ GrapeOAS.logger.warn(
57
+ "Dropping parameter '#{param.name}': schema is not representable as an " \
58
+ "OAS 2.0 #{param.location} parameter",
59
+ )
60
+ true
61
+ else
62
+ false
63
+ end
64
+ end
65
+ end
66
+
67
+ def first_alternative_schema(schema)
68
+ return schema if schema.nil? || schema.type
69
+ return schema if schema.all_of&.any?
70
+ return first_alternative_schema(schema.one_of.first) if schema.one_of&.any?
71
+ return first_alternative_schema(schema.any_of.first) if schema.any_of&.any?
72
+
73
+ schema
74
+ end
75
+
76
+ def unrepresentable?(schema, location:, array_item: false)
77
+ schema = first_alternative_schema(schema)
78
+ return true unless schema&.type
79
+ return true if schema.all_of&.any?
80
+ return unrepresentable?(schema.items, location: location, array_item: true) if schema.type == Constants::SchemaTypes::ARRAY
81
+ return true if schema.type == Constants::SchemaTypes::OBJECT
82
+
83
+ schema.type == Constants::SchemaTypes::FILE && (location != "formData" || array_item)
84
+ end
85
+
35
86
  def build_parameter(param)
36
- type = param.schema&.type
37
- format = param.schema&.format
38
- primitive_types = PRIMITIVE_MAPPINGS.keys + %w[object string boolean file json array number]
87
+ schema = param.location == "body" ? param.schema : first_alternative_schema(param.schema)
88
+ type = schema&.type
89
+ format = schema&.format
90
+ primitive_types = PRIMITIVE_MAPPINGS.keys + %w[string boolean file json array number]
39
91
  is_primitive = type && primitive_types.include?(type)
40
92
 
41
93
  if is_primitive && param.location != "body"
@@ -48,8 +100,8 @@ module GrapeOAS
48
100
  "type" => mapping ? mapping[:type] : type,
49
101
  "format" => format || (mapping ? mapping[:format] : nil)
50
102
  }
51
- apply_schema_constraints(result, param.schema)
52
- apply_collection_format(result, param, type)
103
+ apply_schema_constraints(result, schema)
104
+ apply_collection_format(result, param, schema)
53
105
  result.compact
54
106
  else
55
107
  {
@@ -57,7 +109,7 @@ module GrapeOAS
57
109
  "in" => param.location,
58
110
  "required" => param.required,
59
111
  "description" => param.description,
60
- "schema" => build_schema_or_ref(param.schema)
112
+ "schema" => build_schema_or_ref(schema)
61
113
  }.tap do |h|
62
114
  h["type"] = type if type
63
115
  h["format"] = format if format
@@ -76,6 +128,7 @@ module GrapeOAS
76
128
  result["maxLength"] = schema.max_length if schema.respond_to?(:max_length) && !schema.max_length.nil?
77
129
  result["minItems"] = schema.min_items if schema.respond_to?(:min_items) && !schema.min_items.nil?
78
130
  result["maxItems"] = schema.max_items if schema.respond_to?(:max_items) && !schema.max_items.nil?
131
+ result["uniqueItems"] = true if schema.respond_to?(:unique_items) && schema.unique_items
79
132
  result["pattern"] = schema.pattern if schema.respond_to?(:pattern) && schema.pattern
80
133
  result["enum"] = normalize_enum(schema.enum, result["type"]) if schema.respond_to?(:enum) && schema.enum
81
134
  result["default"] = schema.default if schema.respond_to?(:default) && !schema.default.nil?
@@ -84,28 +137,77 @@ module GrapeOAS
84
137
  def normalize_enum(enum_vals, type)
85
138
  return nil unless enum_vals.is_a?(Array)
86
139
 
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
140
+ result = enum_vals.each_with_object([]) do |v, acc|
141
+ next if v.nil?
142
+
143
+ coerced_v = case type
144
+ when Constants::SchemaTypes::INTEGER then v.to_i if v.respond_to?(:to_i)
145
+ when Constants::SchemaTypes::NUMBER then v.to_f if v.respond_to?(:to_f)
146
+ else v
147
+ end
148
+ acc << coerced_v unless coerced_v.nil?
149
+ end
94
150
 
95
- result = coerced.uniq
151
+ result.uniq!
96
152
  return nil if result.empty?
97
153
 
98
154
  result
99
155
  end
100
156
 
101
- def apply_collection_format(result, param, type)
102
- return unless type == Constants::SchemaTypes::ARRAY
157
+ def apply_collection_format(result, param, schema)
158
+ return unless schema.type == Constants::SchemaTypes::ARRAY
159
+
160
+ result["items"] = build_schema_or_ref(schema.items) if schema.items
103
161
  return unless param.collection_format
104
162
 
105
163
  valid_formats = %w[csv ssv tsv pipes multi brackets]
106
164
  result["collectionFormat"] = param.collection_format if valid_formats.include?(param.collection_format)
107
165
  end
108
166
 
167
+ def form_only_request?
168
+ consumes = Array(@op.consumes)
169
+ consumes.any? && consumes.all? { |mime| FORM_MEDIA_TYPES.include?(mime) }
170
+ end
171
+
172
+ def build_form_parameters(request_body)
173
+ schema = Array(request_body.media_types).first&.schema
174
+ return [] unless schema
175
+
176
+ unless [nil, "object"].include?(schema.type) && !composition?(schema)
177
+ raise ArgumentError, "OAS2 form bodies must have object properties; use OAS3 for this request schema"
178
+ end
179
+
180
+ required = Array(schema.required).map(&:to_s)
181
+ schema.properties.map do |name, property_schema|
182
+ unless form_property?(property_schema)
183
+ raise ArgumentError, "OAS2 cannot represent form field #{name.inspect}; use OAS3 for complex form fields"
184
+ end
185
+
186
+ build_parameter(
187
+ ApiModel::Parameter.new(
188
+ name: name.to_s,
189
+ location: "formData",
190
+ required: required.include?(name.to_s),
191
+ description: property_schema.description,
192
+ schema: property_schema,
193
+ ),
194
+ )
195
+ end
196
+ end
197
+
198
+ def form_property?(schema, array_item: false)
199
+ return false unless schema && !composition?(schema)
200
+ return false if array_item && schema.canonical_name
201
+ return form_property?(schema.items, array_item: true) if schema.type == "array"
202
+
203
+ types = array_item ? %w[string integer number boolean] : %w[string integer number boolean file]
204
+ types.include?(schema.type)
205
+ end
206
+
207
+ def composition?(schema)
208
+ schema.all_of&.any? || schema.one_of&.any? || schema.any_of&.any?
209
+ end
210
+
109
211
  def build_body_parameter(request_body)
110
212
  schema = build_body_schema(request_body)
111
213
  name = derive_body_name(request_body)
@@ -139,10 +241,11 @@ module GrapeOAS
139
241
  def build_schema_or_ref(schema)
140
242
  if schema.respond_to?(:canonical_name) && schema.canonical_name
141
243
  @ref_tracker << schema.canonical_name if @ref_tracker
142
- ref_name = schema.canonical_name.gsub("::", "_")
244
+ ref_name = GrapeOAS.schema_ref_name.call(schema.canonical_name)
143
245
  { "$ref" => "#/definitions/#{ref_name}" }
144
246
  else
145
- Schema.new(schema, @ref_tracker, nullable_strategy: @nullable_strategy).build
247
+ Schema.new(schema, @ref_tracker, nullable_strategy: @nullable_strategy,
248
+ composition_extensions: @composition_extensions,).build
146
249
  end
147
250
  end
148
251
  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