grape-oas 1.3.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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -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 +6 -1
  7. data/lib/grape_oas/api_model/schema.rb +2 -2
  8. data/lib/grape_oas/api_model_builder.rb +3 -2
  9. data/lib/grape_oas/api_model_builders/concerns/content_type_resolver.rb +30 -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 +7 -8
  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 +29 -22
  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_location_resolver.rb +7 -2
  19. data/lib/grape_oas/api_model_builders/request_params_support/param_schema_builder.rb +4 -0
  20. data/lib/grape_oas/api_model_builders/request_params_support/schema_enhancer.rb +14 -9
  21. data/lib/grape_oas/api_model_builders/response.rb +28 -4
  22. data/lib/grape_oas/constants.rb +22 -1
  23. data/lib/grape_oas/documentation_extension.rb +7 -9
  24. data/lib/grape_oas/exporter/concerns/schema_indexer.rb +14 -3
  25. data/lib/grape_oas/exporter/oas2/operation.rb +4 -2
  26. data/lib/grape_oas/exporter/oas2/parameter.rb +74 -13
  27. data/lib/grape_oas/exporter/oas2/paths.rb +1 -0
  28. data/lib/grape_oas/exporter/oas2/response.rb +6 -4
  29. data/lib/grape_oas/exporter/oas2/schema.rb +85 -38
  30. data/lib/grape_oas/exporter/oas2_schema.rb +7 -5
  31. data/lib/grape_oas/exporter/oas3/operation.rb +5 -3
  32. data/lib/grape_oas/exporter/oas3/parameter.rb +8 -3
  33. data/lib/grape_oas/exporter/oas3/paths.rb +1 -0
  34. data/lib/grape_oas/exporter/oas3/request_body.rb +5 -8
  35. data/lib/grape_oas/exporter/oas3/response.rb +6 -9
  36. data/lib/grape_oas/exporter/oas3/schema.rb +273 -117
  37. data/lib/grape_oas/exporter/oas31/schema.rb +12 -2
  38. data/lib/grape_oas/exporter/oas31_schema.rb +1 -1
  39. data/lib/grape_oas/exporter/oas3_schema.rb +3 -2
  40. data/lib/grape_oas/introspectors/entity_introspector.rb +7 -2
  41. data/lib/grape_oas/introspectors/entity_introspector_support/exposure_processor.rb +55 -20
  42. data/lib/grape_oas/introspectors/entity_introspector_support/inheritance_builder.rb +1 -1
  43. data/lib/grape_oas/introspectors/entity_introspector_support/property_extractor.rb +2 -8
  44. data/lib/grape_oas/introspectors/entity_introspector_support/type_schema_resolver.rb +4 -20
  45. data/lib/grape_oas/introspectors/entity_introspector_support.rb +24 -0
  46. data/lib/grape_oas/range_utils.rb +25 -2
  47. data/lib/grape_oas/type_resolvers/array_resolver.rb +54 -22
  48. data/lib/grape_oas/type_resolvers/base.rb +2 -2
  49. data/lib/grape_oas/type_resolvers/default_resolver.rb +23 -0
  50. data/lib/grape_oas/type_resolvers/dry_type_resolver.rb +1 -1
  51. data/lib/grape_oas/type_resolvers/primitive_resolver.rb +29 -46
  52. data/lib/grape_oas/type_resolvers/registry.rb +31 -15
  53. data/lib/grape_oas/version.rb +1 -1
  54. data/lib/grape_oas.rb +37 -7
  55. metadata +5 -4
  56. data/CONTRIBUTING.md +0 -87
  57. data/RELEASING.md +0 -109
@@ -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
@@ -4,12 +4,15 @@ module GrapeOAS
4
4
  module Exporter
5
5
  module OAS2
6
6
  class Schema
7
- def initialize(schema, ref_tracker = nil, nullable_strategy: nil)
7
+ def initialize(schema, ref_tracker = nil, nullable_strategy: nil, composition_extensions: false)
8
8
  @schema = schema
9
9
  @ref_tracker = ref_tracker
10
10
  @nullable_strategy = nullable_strategy
11
+ @composition_extensions = composition_extensions
11
12
  end
12
13
 
14
+ # OAS 2.0 (Swagger) natively supports `type: file`, so no
15
+ # file-type normalization is needed here (unlike OAS 3.x).
13
16
  def build
14
17
  return {} unless @schema
15
18
 
@@ -23,6 +26,7 @@ module GrapeOAS
23
26
 
24
27
  schema_hash = build_base_hash
25
28
  apply_constraints(schema_hash)
29
+ apply_compatibility_composition_extension(schema_hash)
26
30
  apply_extensions(schema_hash)
27
31
  schema_hash.compact
28
32
  end
@@ -32,9 +36,9 @@ module GrapeOAS
32
36
  "type" => @schema.type,
33
37
  "format" => @schema.format,
34
38
  "description" => @schema.description&.to_s,
35
- "properties" => build_properties(@schema.properties),
36
- "enum" => normalize_enum(@schema.enum, @schema.type)
39
+ "properties" => build_properties(@schema.properties)
37
40
  }
41
+ schema_hash["enum"] = normalize_enum(@schema.enum, @schema.type, nullable: nullable?) if @schema.enum
38
42
  if @schema.items
39
43
  schema_hash["items"] = build_schema_or_ref(@schema.items, include_metadata: false)
40
44
  if !schema_hash["description"] && @schema.items.respond_to?(:description) && @schema.items.description
@@ -52,19 +56,21 @@ module GrapeOAS
52
56
  schema_hash["example"] = @schema.examples if @schema.examples
53
57
  schema_hash["required"] = @schema.required if @schema.required && !@schema.required.empty?
54
58
  schema_hash["discriminator"] = @schema.discriminator if @schema.discriminator
59
+ schema_hash["default"] = @schema.default unless @schema.default.nil?
55
60
  schema_hash
56
61
  end
57
62
 
58
- def apply_constraints(schema_hash)
59
- schema_hash["minLength"] = @schema.min_length if @schema.min_length
60
- schema_hash["maxLength"] = @schema.max_length if @schema.max_length
61
- schema_hash["pattern"] = @schema.pattern if @schema.pattern
62
- schema_hash["minimum"] = @schema.minimum if @schema.minimum
63
- schema_hash["maximum"] = @schema.maximum if @schema.maximum
64
- schema_hash["exclusiveMinimum"] = @schema.exclusive_minimum if @schema.exclusive_minimum
65
- schema_hash["exclusiveMaximum"] = @schema.exclusive_maximum if @schema.exclusive_maximum
66
- schema_hash["minItems"] = @schema.min_items if @schema.min_items
67
- schema_hash["maxItems"] = @schema.max_items if @schema.max_items
63
+ def apply_constraints(schema_hash, schema = @schema)
64
+ schema_hash["minimum"] = schema.minimum unless schema.minimum.nil?
65
+ schema_hash["maximum"] = schema.maximum unless schema.maximum.nil?
66
+ schema_hash["exclusiveMinimum"] = schema.exclusive_minimum if schema.exclusive_minimum
67
+ schema_hash["exclusiveMaximum"] = schema.exclusive_maximum if schema.exclusive_maximum
68
+ schema_hash["minLength"] = schema.min_length unless schema.min_length.nil?
69
+ schema_hash["maxLength"] = schema.max_length unless schema.max_length.nil?
70
+ schema_hash["pattern"] = schema.pattern if schema.pattern
71
+ schema_hash["minItems"] = schema.min_items unless schema.min_items.nil?
72
+ schema_hash["maxItems"] = schema.max_items unless schema.max_items.nil?
73
+ schema_hash["uniqueItems"] = true if schema.unique_items
68
74
  end
69
75
 
70
76
  def apply_extensions(schema_hash)
@@ -74,34 +80,60 @@ module GrapeOAS
74
80
 
75
81
  private
76
82
 
83
+ def apply_compatibility_composition_extension(result)
84
+ return unless @composition_extensions
85
+
86
+ { "x-oneOf" => @schema.one_of, "x-anyOf" => @schema.any_of }.each do |key, alternatives|
87
+ next unless alternatives && alternatives.size > 1
88
+ next if @schema.extensions&.key?(key)
89
+
90
+ result[key] = alternatives.map { |item| build_schema_or_ref(item) }
91
+ end
92
+ end
93
+
94
+ def schema_nullable?(schema)
95
+ schema.respond_to?(:nullable) && !!schema.nullable
96
+ end
97
+
77
98
  def nullable?
78
- @schema.respond_to?(:nullable) && @schema.nullable
99
+ schema_nullable?(@schema)
79
100
  end
80
101
 
81
- # Build schema from oneOf/anyOf by using first type (OAS2 doesn't support these)
82
- # Extensions are merged to allow x-anyOf/x-oneOf for consumers that support them
102
+ # OAS2 keeps a first-alternative fallback for tools that ignore extensions.
83
103
  def build_first_of_schema(composition_type)
84
- schemas = @schema.send(composition_type)
104
+ schemas = composition_type == :any_of ? @schema.any_of : @schema.one_of
85
105
  first_schema = schemas.first
86
106
  return {} unless first_schema
87
107
 
88
- # Build the first schema as the fallback
89
108
  result = build_schema_or_ref(first_schema)
90
109
  result["description"] = @schema.description.to_s if @schema.description
110
+
111
+ apply_compatibility_composition_extension(result)
91
112
  apply_extensions(result)
113
+ if result.key?("$ref") && result.size > 1
114
+ ref = { "$ref" => result.delete("$ref") }
115
+ result["allOf"] = [ref]
116
+ end
92
117
  result
93
118
  end
94
119
 
95
120
  # Build allOf schema for inheritance
96
121
  def build_all_of_schema
97
- all_of_items = @schema.all_of.map do |item|
98
- build_schema_or_ref(item)
99
- end
122
+ items = @schema.all_of.map { |item| build_schema_or_ref(item) }
123
+ result = { "allOf" => items }
124
+ apply_composition_attributes(result)
125
+ result
126
+ end
100
127
 
101
- result = { "allOf" => all_of_items }
128
+ def apply_composition_attributes(result)
129
+ result["type"] = @schema.type if @schema.type
130
+ result["format"] = @schema.format if @schema.format
102
131
  result["description"] = @schema.description.to_s if @schema.description
103
- result["x-nullable"] = true if @nullable_strategy == Constants::NullableStrategy::EXTENSION && nullable?
104
- result
132
+ result["default"] = @schema.default unless @schema.default.nil?
133
+ result["enum"] = normalize_enum(@schema.enum, @schema.type, nullable: nullable?) if @schema.enum
134
+ result.delete("enum") if result.key?("enum") && result["enum"].nil?
135
+ apply_constraints(result)
136
+ apply_extensions(result)
105
137
  end
106
138
 
107
139
  def build_properties(properties)
@@ -116,15 +148,18 @@ module GrapeOAS
116
148
  def build_schema_or_ref(schema, include_metadata: true)
117
149
  if schema.respond_to?(:canonical_name) && schema.canonical_name
118
150
  @ref_tracker << schema.canonical_name if @ref_tracker
119
- ref_name = schema.canonical_name.gsub("::", "_")
151
+ ref_name = GrapeOAS.schema_ref_name.call(schema.canonical_name)
120
152
  ref_hash = { "$ref" => "#/definitions/#{ref_name}" }
121
153
  return ref_hash unless include_metadata
122
154
 
123
155
  result = {}
124
- if @nullable_strategy == Constants::NullableStrategy::EXTENSION && schema.respond_to?(:nullable) && schema.nullable
125
- result["x-nullable"] = true
126
- end
156
+ result["x-nullable"] = true if @nullable_strategy == Constants::NullableStrategy::EXTENSION && schema_nullable?(schema)
127
157
  result["description"] = schema.description.to_s if schema.description
158
+ result["default"] = schema.default unless schema.default.nil?
159
+ result["enum"] = normalize_enum(schema.enum, schema.type, nullable: schema_nullable?(schema)) if schema.enum
160
+ result.delete("enum") if result.key?("enum") && result["enum"].nil?
161
+ apply_constraints(result, schema)
162
+ result.merge!(schema.extensions) if schema.extensions
128
163
  if result.empty?
129
164
  ref_hash
130
165
  else
@@ -132,24 +167,36 @@ module GrapeOAS
132
167
  result
133
168
  end
134
169
  else
135
- built = Schema.new(schema, @ref_tracker, nullable_strategy: @nullable_strategy).build
170
+ # self.class preserves any subclass so nested schemas use
171
+ # the version-correct builder.
172
+ built = self.class.new(schema, @ref_tracker, nullable_strategy: @nullable_strategy,
173
+ composition_extensions: @composition_extensions,).build
136
174
  built.delete("description") unless include_metadata
137
175
  built
138
176
  end
139
177
  end
140
178
 
141
- def normalize_enum(enum_vals, type)
179
+ def normalize_enum(enum_vals, type, nullable: false)
142
180
  return nil unless enum_vals.is_a?(Array)
143
181
 
144
- coerced = enum_vals.map do |v|
145
- case type
146
- when Constants::SchemaTypes::INTEGER then v.to_i if v.respond_to?(:to_i)
147
- when Constants::SchemaTypes::NUMBER then v.to_f if v.respond_to?(:to_f)
148
- else v
149
- end
150
- end.compact
182
+ has_nil = nullable && enum_vals.include?(nil)
151
183
 
152
- coerced.uniq
184
+ result = enum_vals.each_with_object([]) do |v, acc|
185
+ next if v.nil?
186
+
187
+ coerced_v = case type
188
+ when Constants::SchemaTypes::INTEGER then v.to_i if v.respond_to?(:to_i)
189
+ when Constants::SchemaTypes::NUMBER then v.to_f if v.respond_to?(:to_f)
190
+ else v
191
+ end
192
+ acc << coerced_v unless coerced_v.nil?
193
+ end
194
+
195
+ result.uniq!
196
+ result.push(nil) if has_nil
197
+ return nil if result.empty?
198
+
199
+ result
153
200
  end
154
201
  end
155
202
  end
@@ -67,18 +67,19 @@ module GrapeOAS
67
67
  end
68
68
 
69
69
  def nullable_strategy
70
- @api.nullable_strategy
70
+ @api.nullable_strategy || Constants::NullableStrategy::OAS2_DEFAULT
71
71
  end
72
72
 
73
73
  def build_paths
74
74
  OAS2::Paths.new(@api, @ref_tracker,
75
75
  nullable_strategy: nullable_strategy,
76
+ composition_extensions: @api.oas2_composition_extensions,
76
77
  suppress_default_error_response: @api.suppress_default_error_response,).build
77
78
  end
78
79
 
79
80
  def build_schema_or_ref(schema)
80
81
  if schema.respond_to?(:canonical_name) && schema.canonical_name
81
- ref_name = schema.canonical_name.gsub("::", "_")
82
+ ref_name = GrapeOAS.schema_ref_name.call(schema.canonical_name)
82
83
  @ref_tracker << schema.canonical_name
83
84
  { "$ref" => "#/definitions/#{ref_name}" }
84
85
  else
@@ -87,7 +88,8 @@ module GrapeOAS
87
88
  end
88
89
 
89
90
  def build_schema(schema)
90
- OAS2::Schema.new(schema, @ref_tracker, nullable_strategy: nullable_strategy).build
91
+ OAS2::Schema.new(schema, @ref_tracker, nullable_strategy: nullable_strategy,
92
+ composition_extensions: @api.oas2_composition_extensions,).build
91
93
  end
92
94
 
93
95
  def build_definitions
@@ -106,9 +108,9 @@ module GrapeOAS
106
108
 
107
109
  processed << canonical_name
108
110
 
109
- ref_name = canonical_name.gsub("::", "_")
111
+ ref_name = GrapeOAS.schema_ref_name.call(canonical_name)
110
112
  schema = find_schema_by_canonical_name(canonical_name)
111
- definitions[ref_name] = OAS2::Schema.new(schema, @ref_tracker, nullable_strategy: nullable_strategy).build if schema
113
+ definitions[ref_name] = build_schema(schema) if schema
112
114
  collect_refs(schema, pending) if schema
113
115
 
114
116
  @ref_tracker.to_a.each do |cn|
@@ -12,10 +12,12 @@ module GrapeOAS
12
12
  def build_version_specific_fields
13
13
  strategy = @options[:nullable_strategy] || Constants::NullableStrategy::KEYWORD
14
14
 
15
+ schema_options = { nullable_strategy: strategy, schema_builder: @options[:schema_builder] || Schema }
16
+
15
17
  {
16
- "parameters" => Parameter.new(@op, @ref_tracker, nullable_strategy: strategy).build,
17
- "requestBody" => RequestBody.new(@op.request_body, @ref_tracker, nullable_strategy: strategy).build,
18
- "responses" => Response.new(@op.responses, @ref_tracker, nullable_strategy: strategy).build
18
+ "parameters" => Parameter.new(@op, @ref_tracker, **schema_options).build,
19
+ "requestBody" => RequestBody.new(@op.request_body, @ref_tracker, **schema_options).build,
20
+ "responses" => Response.new(@op.responses, @ref_tracker, **schema_options).build
19
21
  }
20
22
  end
21
23
  end
@@ -4,22 +4,27 @@ module GrapeOAS
4
4
  module Exporter
5
5
  module OAS3
6
6
  class Parameter
7
- def initialize(operation, ref_tracker = nil, nullable_strategy: Constants::NullableStrategy::KEYWORD)
7
+ def initialize(operation, ref_tracker = nil, nullable_strategy: Constants::NullableStrategy::KEYWORD,
8
+ schema_builder: Schema)
8
9
  @op = operation
9
10
  @ref_tracker = ref_tracker
10
11
  @nullable_strategy = nullable_strategy
12
+ @schema_builder = schema_builder
11
13
  end
12
14
 
13
15
  def build
14
16
  Array(@op.parameters).map do |param|
17
+ schema_hash = @schema_builder.new(param.schema, @ref_tracker, nullable_strategy: @nullable_strategy).build
18
+ schema_description = schema_hash.delete("description")
19
+ description = param.description || schema_description
15
20
  {
16
21
  "name" => param.name,
17
22
  "in" => param.location,
18
23
  "required" => param.required,
19
- "description" => param.description,
24
+ "description" => description,
20
25
  "style" => param.style,
21
26
  "explode" => param.explode,
22
- "schema" => Schema.new(param.schema, @ref_tracker, nullable_strategy: @nullable_strategy).build
27
+ "schema" => schema_hash
23
28
  }.compact
24
29
  end.presence
25
30
  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] || Constants::NullableStrategy::KEYWORD,
15
+ schema_builder: @options[:schema_builder] || Schema,
15
16
  suppress_default_error_response: @options[:suppress_default_error_response],).build
16
17
  end
17
18
  end
@@ -4,10 +4,12 @@ module GrapeOAS
4
4
  module Exporter
5
5
  module OAS3
6
6
  class RequestBody
7
- def initialize(request_body, ref_tracker = nil, nullable_strategy: Constants::NullableStrategy::KEYWORD)
7
+ def initialize(request_body, ref_tracker = nil, nullable_strategy: Constants::NullableStrategy::KEYWORD,
8
+ schema_builder: Schema)
8
9
  @request_body = request_body
9
10
  @ref_tracker = ref_tracker
10
11
  @nullable_strategy = nullable_strategy
12
+ @schema_builder = schema_builder
11
13
  end
12
14
 
13
15
  def build
@@ -40,13 +42,8 @@ module GrapeOAS
40
42
  end
41
43
 
42
44
  def build_schema_or_ref(schema)
43
- if schema.respond_to?(:canonical_name) && schema.canonical_name
44
- @ref_tracker << schema.canonical_name if @ref_tracker
45
- ref_name = schema.canonical_name.gsub("::", "_")
46
- { "$ref" => "#/components/schemas/#{ref_name}" }
47
- else
48
- Schema.new(schema, @ref_tracker, nullable_strategy: @nullable_strategy).build
49
- end
45
+ @schema_builder.new(schema, @ref_tracker, nullable_strategy: @nullable_strategy)
46
+ .build_schema_or_ref(schema)
50
47
  end
51
48
  end
52
49
  end
@@ -4,10 +4,12 @@ module GrapeOAS
4
4
  module Exporter
5
5
  module OAS3
6
6
  class Response
7
- def initialize(responses, ref_tracker = nil, nullable_strategy: Constants::NullableStrategy::KEYWORD)
7
+ def initialize(responses, ref_tracker = nil, nullable_strategy: Constants::NullableStrategy::KEYWORD,
8
+ schema_builder: Schema)
8
9
  @responses = responses
9
10
  @ref_tracker = ref_tracker
10
11
  @nullable_strategy = nullable_strategy
12
+ @schema_builder = schema_builder
11
13
  end
12
14
 
13
15
  def build
@@ -42,7 +44,7 @@ module GrapeOAS
42
44
  end
43
45
 
44
46
  def build_content(media_types, response_examples = nil)
45
- return nil unless media_types
47
+ return nil if media_types.nil? || media_types.empty?
46
48
 
47
49
  media_types.each_with_object({}) do |mt, h|
48
50
  entry = { "schema" => build_schema_or_ref(mt.schema) }
@@ -71,13 +73,8 @@ module GrapeOAS
71
73
  end
72
74
 
73
75
  def build_schema_or_ref(schema)
74
- if schema.respond_to?(:canonical_name) && schema.canonical_name
75
- @ref_tracker << schema.canonical_name if @ref_tracker
76
- ref_name = schema.canonical_name.gsub("::", "_")
77
- { "$ref" => "#/components/schemas/#{ref_name}" }
78
- else
79
- Schema.new(schema, @ref_tracker, nullable_strategy: @nullable_strategy).build
80
- end
76
+ @schema_builder.new(schema, @ref_tracker, nullable_strategy: @nullable_strategy)
77
+ .build_schema_or_ref(schema)
81
78
  end
82
79
  end
83
80
  end