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
@@ -3,6 +3,7 @@
3
3
  module GrapeOAS
4
4
  module ApiModelBuilders
5
5
  class Request
6
+ include Concerns::RouteValidations
6
7
  include Concerns::TypeResolver
7
8
  include Concerns::OasUtilities
8
9
 
@@ -96,7 +97,9 @@ module GrapeOAS
96
97
 
97
98
  # Find contract from Grape's contract storage locations.
98
99
  # Contracts can be defined in several ways:
99
- # 1. Via `contract MyContract` DSL - stores in inheritable_setting.route[:saved_validations]
100
+ # 1. Via `contract MyContract` DSL - stored on the endpoint's inheritable
101
+ # settings (`route_validations` on Grape 4.0, `route[:saved_validations]`
102
+ # on Grape 3.x)
100
103
  # 2. Via `desc "...", contract: MyContract` - stores in route.options[:contract]
101
104
  # 3. Via `desc "...", schema: MySchema` - stores in route.options[:schema]
102
105
  # 4. Via route.settings[:contract] - used by mounted APIs or legacy configuration
@@ -116,38 +119,42 @@ module GrapeOAS
116
119
  end
117
120
 
118
121
  # Extract contract from Grape's native contract() DSL storage location.
119
- # When using `contract MyContract` in Grape DSL, the contract is stored in
120
- # route.app.inheritable_setting.route[:saved_validations] as validator options.
121
- # This is a point-in-time copy specific to this endpoint, ensuring each route
122
+ # When using `contract MyContract` in Grape DSL, the contract is stored as
123
+ # a point-in-time copy specific to this endpoint, ensuring each route
122
124
  # gets only its own contract even when multiple routes define different contracts.
123
125
  #
124
126
  # @return [Object, nil] The contract instance or nil if not found
125
127
  def extract_contract_from_grape_validations
126
128
  return unless route.respond_to?(:app) && route.app.respond_to?(:inheritable_setting)
127
129
 
128
- setting = route.app.inheritable_setting
129
- return unless setting.respond_to?(:route)
130
-
131
- # Use route[:saved_validations] which contains only the validations
132
- # for this specific endpoint (point-in-time copy), not the shared
133
- # namespace_stackable[:validations] which contains all validators for the API class
134
- validations = setting.route[:saved_validations]
130
+ validations = grape_route_validations(route.app.inheritable_setting)
135
131
  return unless validations.is_a?(Array)
136
132
 
137
- # Find ContractScopeValidator which holds the Dry contract/schema
138
- contract_validation = validations.find do |v|
139
- next unless v.is_a?(Hash)
133
+ # Find ContractScopeValidator which holds the Dry contract/schema.
134
+ # Grape < 3.2 stores hashes: {validator_class: ..., opts: {schema: ...}}
135
+ # Grape >= 3.2 stores validator instances directly (instantiated at definition time)
136
+ return unless defined?(Grape::Validations::Validators::ContractScopeValidator)
140
137
 
141
- validator_class = v[:validator_class]
142
- validator_class.is_a?(Class) &&
143
- defined?(Grape::Validations::Validators::ContractScopeValidator) &&
144
- validator_class <= Grape::Validations::Validators::ContractScopeValidator
138
+ validations.each do |v|
139
+ case v
140
+ when Hash
141
+ next unless v[:validator_class].is_a?(Class) &&
142
+ v[:validator_class] <= Grape::Validations::Validators::ContractScopeValidator
143
+
144
+ return v.dig(:opts, :schema)
145
+ when Grape::Validations::Validators::ContractScopeValidator
146
+ schema = contract_schema_from(v)
147
+ GrapeOAS.logger&.warn("ContractScopeValidator found but @schema is nil") if schema.nil?
148
+ return schema
149
+ end
145
150
  end
146
151
 
147
- return unless contract_validation
152
+ nil
153
+ end
148
154
 
149
- # The contract instance is stored in opts[:schema]
150
- contract_validation.dig(:opts, :schema)
155
+ def contract_schema_from(validator)
156
+ schema = validator.schema if validator.respond_to?(:schema)
157
+ schema || validator.instance_variable_get(:@schema)
151
158
  end
152
159
 
153
160
  def build_contract_schema
@@ -256,7 +263,7 @@ module GrapeOAS
256
263
  end
257
264
 
258
265
  def path_param_names
259
- names = route.path.scan(RequestParams::ROUTE_PARAM_REGEX)
266
+ names = RequestParams.path_param_names(route.path)
260
267
  mapped_names = path_param_name_map ? path_param_name_map.values : []
261
268
  (names + mapped_names).map(&:to_s).uniq
262
269
  end
@@ -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
@@ -65,7 +65,7 @@ module GrapeOAS
65
65
  # Precedence (highest to lowest):
66
66
  # 1. `param_type` option (e.g., `documentation: { param_type: 'query' }`)
67
67
  # 2. `in` option (e.g., `documentation: { in: 'query' }`)
68
- # 3. Falls back to "query" if neither is specified
68
+ # 3. Defaults to "body" for write methods (POST/PUT/PATCH), "query" for read methods
69
69
  #
70
70
  # Note: If both `param_type` and `in` are specified, `param_type` takes precedence.
71
71
  # For example, `{ param_type: 'query', in: 'body' }` will be treated as query.
@@ -81,7 +81,12 @@ module GrapeOAS
81
81
 
82
82
  # Support both param_type and in for grape-swagger compatibility
83
83
  # param_type takes precedence over in when both are specified
84
- (param_type || in_location)&.to_s&.downcase || "query"
84
+ explicit_location = (param_type || in_location)&.to_s&.downcase
85
+ return explicit_location if explicit_location
86
+
87
+ # Default: body for write methods (POST/PUT/PATCH), query for read methods (GET/DELETE/HEAD)
88
+ http_method = route.request_method.to_s.downcase
89
+ Constants::HttpMethods::BODYLESS_HTTP_METHODS.include?(http_method) ? "query" : "body"
85
90
  end
86
91
  end
87
92
  end
@@ -33,6 +33,10 @@ module GrapeOAS
33
33
 
34
34
  return build_entity_array_schema(spec, raw_type, doc_type) if entity_array_type?(type_source, doc_type, spec)
35
35
  return build_doc_entity_array_schema(doc_type) if doc[:is_array] && grape_entity?(doc_type)
36
+
37
+ # is_array: true on a typed array like "[String]" is redundant and would
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] && TypeResolvers::ArrayResolver.handles?(raw_type)
36
40
  return build_primitive_array_schema(doc_type, raw_type) if doc[:is_array]
37
41
  return build_entity_schema(doc_type) if grape_entity?(doc_type)
38
42
  return build_entity_schema(raw_type) if grape_entity?(raw_type)
@@ -21,17 +21,12 @@ module GrapeOAS
21
21
  apply_format_and_example(schema, doc)
22
22
  SchemaConstraints.apply(schema, doc)
23
23
  apply_values(schema, spec)
24
- end
25
-
26
- # Extracts nullable flag from a documentation hash.
27
- #
28
- # @param doc [Hash] the documentation hash
29
- # @return [Boolean] true if nullable
30
- def self.extract_nullable(doc)
31
- doc[:nullable] || (doc[:x].is_a?(Hash) && doc[:x][:nullable]) || false
24
+ apply_default(schema, spec, doc)
32
25
  end
33
26
 
34
27
  class << self
28
+ include GrapeOAS::ApiModelBuilders::Concerns::OasUtilities
29
+
35
30
  private
36
31
 
37
32
  def apply_additional_properties(schema, doc)
@@ -45,9 +40,19 @@ module GrapeOAS
45
40
  schema.defs = defs if defs.is_a?(Hash) && schema.respond_to?(:defs=)
46
41
  end
47
42
 
43
+ def apply_default(schema, spec, doc)
44
+ return unless schema.respond_to?(:default=)
45
+
46
+ if spec.key?(:default)
47
+ schema.default = spec[:default]
48
+ elsif doc.key?(:default)
49
+ schema.default = doc[:default]
50
+ end
51
+ end
52
+
48
53
  def apply_format_and_example(schema, doc)
49
54
  schema.format = doc[:format] if doc[:format] && schema.respond_to?(:format=)
50
- schema.examples = doc[:example] if doc[:example] && schema.respond_to?(:examples=)
55
+ schema.examples = doc[:example] if doc.key?(:example) && schema.respond_to?(:examples=)
51
56
  end
52
57
 
53
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,12 +46,32 @@ 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.
51
57
  # Prevents OOM on wide string ranges (e.g. "a".."zzzzzz").
52
58
  MAX_ENUM_RANGE_SIZE = 100
53
59
 
60
+ # Regex patterns for Grape's stringified type notations.
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.
68
+ module TypePatterns
69
+ CONST_NAME = /(?:::)?[A-Z]\w*(?:::[A-Z]\w*)*/
70
+ TYPED_ARRAY = /\A(?:(?<container>Array|Set))?\[(?<inner>#{CONST_NAME})\]\z/
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
+
54
75
  # Default values for OpenAPI spec when not provided by user
55
76
  module Defaults
56
77
  LICENSE_NAME = "Proprietary"
@@ -86,7 +107,7 @@ module GrapeOAS
86
107
  "float" => { type: SchemaTypes::NUMBER, format: "float" },
87
108
  "bigdecimal" => { type: SchemaTypes::NUMBER, format: "double" },
88
109
  "string" => { type: SchemaTypes::STRING },
89
- "integer" => { type: SchemaTypes::INTEGER, format: "int32" },
110
+ "integer" => { type: SchemaTypes::INTEGER },
90
111
  "number" => { type: SchemaTypes::NUMBER, format: "double" },
91
112
  "boolean" => { type: SchemaTypes::BOOLEAN },
92
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,22 +85,27 @@ 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
91
+ result["default"] = schema.default if schema.respond_to?(:default) && !schema.default.nil?
81
92
  end
82
93
 
83
94
  def normalize_enum(enum_vals, type)
84
95
  return nil unless enum_vals.is_a?(Array)
85
96
 
86
- coerced = enum_vals.map do |v|
87
- case type
88
- when Constants::SchemaTypes::INTEGER then v.to_i if v.respond_to?(:to_i)
89
- when Constants::SchemaTypes::NUMBER then v.to_f if v.respond_to?(:to_f)
90
- else v
91
- end
92
- 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
93
107
 
94
- result = coerced.uniq
108
+ result.uniq!
95
109
  return nil if result.empty?
96
110
 
97
111
  result
@@ -99,12 +113,58 @@ module GrapeOAS
99
113
 
100
114
  def apply_collection_format(result, param, type)
101
115
  return unless type == Constants::SchemaTypes::ARRAY
116
+
117
+ result["items"] = build_schema_or_ref(param.schema.items) if param.schema.items
102
118
  return unless param.collection_format
103
119
 
104
120
  valid_formats = %w[csv ssv tsv pipes multi brackets]
105
121
  result["collectionFormat"] = param.collection_format if valid_formats.include?(param.collection_format)
106
122
  end
107
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
+
108
168
  def build_body_parameter(request_body)
109
169
  schema = build_body_schema(request_body)
110
170
  name = derive_body_name(request_body)
@@ -138,10 +198,11 @@ module GrapeOAS
138
198
  def build_schema_or_ref(schema)
139
199
  if schema.respond_to?(:canonical_name) && schema.canonical_name
140
200
  @ref_tracker << schema.canonical_name if @ref_tracker
141
- ref_name = schema.canonical_name.gsub("::", "_")
201
+ ref_name = GrapeOAS.schema_ref_name.call(schema.canonical_name)
142
202
  { "$ref" => "#/definitions/#{ref_name}" }
143
203
  else
144
- 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
145
206
  end
146
207
  end
147
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