grape-oas 1.5.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e76945cff9198556752b881ff62da8c288a18e3f6447a36b7668d516ed575f4f
4
- data.tar.gz: 8f36610d66d2d5935004972fdc063e74316b2a576be030ce7d8a0a903fa43f79
3
+ metadata.gz: eb455b84bab348e43f6f512e31a9c306ec7deb76ddb5e6267231a3c7495615e7
4
+ data.tar.gz: 7096893822180fa7da1976ebbc7b20eaea31108aa8829595d2f4b4764e7350da
5
5
  SHA512:
6
- metadata.gz: df261c7d71e8c228c096484cda70c821e4cdd58f1aad04b31bf402513dd414eff69512a38d9c6ef131de418fd19403c0ddadfc4ae7fe0bd8e12078ffc66e285a
7
- data.tar.gz: 233f3e233f053f12f7b9fb2eb4635c8969233b4d8848b61048bf273512cffed54ce9499f473b90b56875c0d429f4d12a46f74148689fa122002a7e049fc1f383
6
+ metadata.gz: 3cd2cb8c1c03fc8c14c427eb247de87d2a7cc782be02e41bc435e1f400a6eb0d1972dd1ea51e6c8e11f6854b4c4d646692fb80191db9d68b65e4e271604c3772
7
+ data.tar.gz: ba055b0ad8596358370103f503f009c80a6dd8395983077b6a1573aab4798c9bd5b5fa1d872c6210a57892151ef70d2a19538d3629823f076322e56274e8be4e
data/CHANGELOG.md CHANGED
@@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.5.1] - 2026-09-16
9
+
10
+ ### Fixed
11
+
12
+ - [#110](https://github.com/numbata/grape-oas/pull/110): Honor Grape `desc` `default` / `default_response` as OAS `responses.default` - [@numbata](https://github.com/numbata).
13
+ - [#136](https://github.com/numbata/grape-oas/pull/136): Preserve top-level Grape `desc:` metadata on request-body properties - [@numbata](https://github.com/numbata).
14
+ - [#140](https://github.com/numbata/grape-oas/pull/140): Preserve nullable entity scalars documented with `types:` - [@numbata](https://github.com/numbata).
15
+ - [#142](https://github.com/numbata/grape-oas/pull/142): Normalize symbolic response status keys to numeric HTTP codes - [@numbata](https://github.com/numbata).
16
+ - [#145](https://github.com/numbata/grape-oas/pull/145): Preserve explicitly body-located GET, HEAD, and DELETE parameters - [@numbata](https://github.com/numbata).
17
+ - [#146](https://github.com/numbata/grape-oas/pull/146): Preserve declared path metadata inside nested body parameter groups and always mark path parameters as required, per OAS, even when their Grape declaration is optional - [@numbata](https://github.com/numbata).
18
+ - [#149](https://github.com/numbata/grape-oas/pull/149): Preserve explicitly query/header/path-located nested parameters on POST, PUT, and PATCH routes; reject unrecognized `param_type:`/`in:` values and `path` locations without a matching route capture instead of emitting invalid output; drop OAS 2.0-invalid `cookie` parameters with a warning instead of emitting them - [@numbata](https://github.com/numbata).
19
+ - [#152](https://github.com/numbata/grape-oas/pull/152): Emit only spec-valid OAS 2.0 non-body parameters and warn when unsupported schemas are dropped - [@numbata](https://github.com/numbata).
20
+
21
+ ### Changed
22
+
23
+ - [#145](https://github.com/numbata/grape-oas/pull/145): Apply route-level body opt-in consistently to flat parameters and `body_name` contracts - [@numbata](https://github.com/numbata).
24
+
25
+ - [#108](https://github.com/numbata/grape-oas/pull/108): Bump actions/checkout from 6 to 7 - [@dependabot[bot]](https://github.com/dependabot[bot]).
26
+ - [#137](https://github.com/numbata/grape-oas/pull/137): Test against Grape 4.0 in CI - [@numbata](https://github.com/numbata).
27
+
8
28
  ## [1.5.0] - 2026-09-07
9
29
 
10
30
  ### Added
@@ -17,9 +17,11 @@ module GrapeOAS
17
17
  end
18
18
 
19
19
  def build
20
- body_schema, route_params = GrapeOAS::ApiModelBuilders::RequestParams
21
- .new(api: api, route: route, path_param_name_map: path_param_name_map)
22
- .build
20
+ body_schema, route_params = RequestParams.new(
21
+ api: api,
22
+ route: route,
23
+ path_param_name_map: path_param_name_map,
24
+ ).build
23
25
 
24
26
  contract_schema = build_contract_schema
25
27
 
@@ -39,15 +41,6 @@ module GrapeOAS
39
41
  private
40
42
 
41
43
  def append_request_body(body_schema)
42
- # OAS spec says GET/HEAD/DELETE "MAY ignore" request bodies
43
- # Skip by default unless explicitly allowed via documentation option
44
- http_method = operation.http_method.to_s.downcase
45
- if Constants::HttpMethods::BODYLESS_HTTP_METHODS.include?(http_method)
46
- allow_body = route.options.dig(:documentation, :request_body) ||
47
- route.options[:request_body]
48
- return unless allow_body
49
- end
50
-
51
44
  media_ext = media_type_extensions(Constants::MimeTypes::JSON)
52
45
 
53
46
  # Set canonical_name if not already set (e.g., DryIntrospector may have set it for polymorphism)
@@ -235,7 +228,9 @@ module GrapeOAS
235
228
  http_method = operation.http_method.to_s.downcase
236
229
  return false unless Constants::HttpMethods::BODYLESS_HTTP_METHODS.include?(http_method)
237
230
 
238
- !(route.options.dig(:documentation, :request_body) || route.options[:request_body])
231
+ # Parameter-level body annotations apply to Grape params only; contracts
232
+ # retain route-level body opt-in semantics.
233
+ !RequestParamsSupport::ParamLocationResolver.route_body_opted_in?(route)
239
234
  end
240
235
 
241
236
  def build_query_parameter(name, schema, required, doc = {})
@@ -27,22 +27,36 @@ module GrapeOAS
27
27
  has_nested = all_params.keys.any? { |k| k.include?("[") }
28
28
 
29
29
  if has_nested
30
- build_with_nested_params(all_params, route_params)
30
+ body_schema, parameters = build_with_nested_params(all_params, route_params)
31
31
  else
32
- build_flat_params(all_params, route_params)
32
+ body_schema, parameters = build_flat_params(all_params, route_params)
33
33
  end
34
+
35
+ [body_schema, parameters]
34
36
  end
35
37
 
36
38
  private
37
39
 
38
40
  # Builds params when nested structures are detected.
39
41
  def build_with_nested_params(all_params, route_params)
40
- body_schema = nested_params_builder.build(all_params, path_params: route_params)
42
+ body_params = nested_body_params(all_params, route_params)
43
+ body_schema = nested_params_builder.build(body_params, path_params: route_params)
41
44
  non_body_params = extract_non_body_params(all_params, route_params)
42
45
 
43
46
  [body_schema, non_body_params]
44
47
  end
45
48
 
49
+ def nested_body_params(all_params, route_params)
50
+ body_roots = all_params.filter_map do |name, spec|
51
+ next if name.include?("[")
52
+
53
+ location = location_resolver.resolve(name: name, spec: spec, route_params: route_params, route: route)
54
+ name if location == "body"
55
+ end.to_set
56
+
57
+ all_params.select { |name, _spec| body_roots.include?(name.split("[", 2).first) }
58
+ end
59
+
46
60
  # Builds params for flat (non-nested) structures.
47
61
  def build_flat_params(all_params, route_params)
48
62
  body_schema = ApiModel::Schema.new(type: Constants::SchemaTypes::OBJECT)
@@ -72,33 +86,38 @@ module GrapeOAS
72
86
  end
73
87
 
74
88
  # Extracts non-body params (path, query, header) from flat params.
75
- # For non-body HTTP methods (GET, HEAD, DELETE), also includes nested params
76
- # as flat query parameters with bracket notation (e.g., "tax_id[type]"),
77
- # unless request_body is explicitly enabled.
89
+ # Nested params (bracket notation, e.g. "tax_id[type]") are included as
90
+ # flat non-body parameters, taking their parent's resolved location,
91
+ # regardless of HTTP method — a nested Hash explicitly documented
92
+ # `in: "header"` on a write route (POST/PUT/PATCH) still belongs in
93
+ # the header, not the body.
78
94
  def extract_non_body_params(all_params, route_params)
79
95
  params = []
80
- http_method = route.request_method.to_s.downcase
81
- flatten_nested = should_flatten_nested_to_query?(http_method, all_params)
82
96
 
83
97
  all_params.each do |name, spec|
84
98
  # Skip hidden params
85
99
  next if location_resolver.hidden_parameter?(spec)
86
100
 
87
- is_nested = name.include?("[")
88
- is_hash_param = location_resolver.body_param?(spec)
101
+ if name.include?("[")
102
+ root = name.split("[", 2).first
103
+ root_spec = all_params[root] || {}
104
+ next if location_resolver.hidden_parameter?(root_spec)
89
105
 
90
- # For nested bracket params (e.g., "tax_id[type]"), include as query params
91
- # for non-body HTTP methods (unless request_body is explicitly enabled)
92
- if is_nested
93
- next unless flatten_nested
106
+ root_location = location_resolver.resolve(name: root, spec: root_spec, route_params: route_params, route: route)
107
+ next if root_location == "body"
108
+ # A Hash root can itself be a real route capture (e.g. ":filter"),
109
+ # but that doesn't make its bracket children path segments too —
110
+ # only the root itself matches the URL template.
111
+ next if root_location == "path"
94
112
 
95
- params << build_parameter(name, "query", spec[:required] || false, schema_builder.build(spec), spec)
113
+ params << build_parameter(name, root_location, spec[:required] || false, schema_builder.build(spec), spec)
96
114
  next
97
115
  end
98
116
 
99
- # Skip Hash type params (they're handled via nested bracket params above
100
- # or via body schema for POST/PUT/PATCH)
101
- next if is_hash_param
117
+ # Skip Hash type params with nested children of their own (they're
118
+ # handled via the nested bracket branch above or via body schema).
119
+ # A childless Hash falls through to the generic path below.
120
+ next if location_resolver.hash_param?(spec) && all_params.keys.any? { |k| k.start_with?("#{name}[") }
102
121
 
103
122
  location = location_resolver.resolve(
104
123
  name: name,
@@ -115,28 +134,6 @@ module GrapeOAS
115
134
  params
116
135
  end
117
136
 
118
- # Determines whether nested params should be flattened to query params.
119
- # Returns true for GET/HEAD/DELETE unless body is explicitly requested via:
120
- # - route-level `request_body: true` option
121
- # - any parameter with `documentation: { in: 'body' }` or `documentation: { param_type: 'body' }`
122
- def should_flatten_nested_to_query?(http_method, all_params)
123
- return false unless Constants::HttpMethods::BODYLESS_HTTP_METHODS.include?(http_method)
124
-
125
- # If request_body is explicitly enabled at route level, use body schema
126
- return false if route.options.dig(:documentation, :request_body) || route.options[:request_body]
127
-
128
- # If any parameter is explicitly marked as body, use body schema
129
- has_explicit_body_param = all_params.any? do |name, spec|
130
- next false if name.include?("[") # Skip bracket params, check parent Hash params only
131
-
132
- param_type = spec.dig(:documentation, :param_type)&.to_s&.downcase
133
- in_location = spec.dig(:documentation, :in)&.to_s&.downcase
134
- param_type == "body" || in_location == "body"
135
- end
136
-
137
- !has_explicit_body_param
138
- end
139
-
140
137
  def build_parameter(name, location, required, schema, spec)
141
138
  doc = spec[:documentation] || {}
142
139
  style = doc.fetch(:style) { doc["style"] }
@@ -145,7 +142,7 @@ module GrapeOAS
145
142
  ApiModel::Parameter.new(
146
143
  location: location,
147
144
  name: name,
148
- required: required,
145
+ required: location == "path" || required,
149
146
  schema: schema,
150
147
  description: spec[:documentation]&.dig(:desc) || spec[:desc],
151
148
  collection_format: extract_collection_format(spec),
@@ -30,7 +30,6 @@ module GrapeOAS
30
30
 
31
31
  top_level.each do |name, spec|
32
32
  next if path_params.include?(name)
33
- next if ParamLocationResolver.explicit_non_body_param?(spec)
34
33
  next if ParamLocationResolver.hidden_parameter?(spec)
35
34
 
36
35
  child_schema = @schema_builder.build(spec)
@@ -5,6 +5,12 @@ module GrapeOAS
5
5
  module RequestParamsSupport
6
6
  # Resolves the location (path, query, body, header) for a parameter.
7
7
  class ParamLocationResolver
8
+ # Locations `param_type:`/`in:` may explicitly name. An unrecognized
9
+ # value (a typo, or a grape-swagger location this library doesn't
10
+ # resolve to, like `formData`) is treated as unset rather than
11
+ # emitted verbatim, since nothing downstream validates `Parameter#location`.
12
+ VALID_EXPLICIT_LOCATIONS = %w[body query header path cookie].freeze
13
+
8
14
  # Determines the location for a parameter.
9
15
  #
10
16
  # @param name [String] the parameter name
@@ -18,29 +24,14 @@ module GrapeOAS
18
24
  extract_from_spec(spec, route)
19
25
  end
20
26
 
21
- # Checks if a parameter should be in the request body.
22
- # Supports both `param_type: 'body'` and `in: 'body'` for grape-swagger compatibility.
23
- #
24
- # @param spec [Hash] the parameter specification
25
- # @return [Boolean] true if it's a body parameter
26
- def self.body_param?(spec)
27
- param_type = spec.dig(:documentation, :param_type)&.to_s&.downcase
28
- in_location = spec.dig(:documentation, :in)&.to_s&.downcase
29
-
30
- param_type == "body" || in_location == "body" || [Hash, "Hash"].include?(spec[:type])
31
- end
32
-
33
- # Checks if a parameter is explicitly marked as NOT a body param.
34
- # Supports both `param_type` and `in` for grape-swagger compatibility.
27
+ # Checks if a parameter is a Hash type. Hash parents are represented
28
+ # by their children (nested params) or the request body, never
29
+ # themselves as a parameter.
35
30
  #
36
31
  # @param spec [Hash] the parameter specification
37
- # @return [Boolean] true if explicitly non-body
38
- def self.explicit_non_body_param?(spec)
39
- param_type = spec.dig(:documentation, :param_type)&.to_s&.downcase
40
- in_location = spec.dig(:documentation, :in)&.to_s&.downcase
41
- location = param_type || in_location
42
-
43
- location && %w[query header path].include?(location)
32
+ # @return [Boolean] true if the parameter type is Hash
33
+ def self.hash_param?(spec)
34
+ [Hash, "Hash"].include?(spec[:type])
44
35
  end
45
36
 
46
37
  # Checks if a parameter should be hidden from documentation.
@@ -56,6 +47,10 @@ module GrapeOAS
56
47
  hidden
57
48
  end
58
49
 
50
+ def self.route_body_opted_in?(route)
51
+ !!(route.options[:body_name] || route.options.dig(:documentation, :request_body) || route.options[:request_body])
52
+ end
53
+
59
54
  class << self
60
55
  private
61
56
 
@@ -70,24 +65,35 @@ module GrapeOAS
70
65
  # Note: If both `param_type` and `in` are specified, `param_type` takes precedence.
71
66
  # For example, `{ param_type: 'query', in: 'body' }` will be treated as query.
72
67
  #
68
+ # `resolve` already returns "path" for an actual route capture before
69
+ # calling this method, so an explicit `in: "path"` / `param_type: "path"`
70
+ # reaching here is always a mismatch (the name can never appear in the
71
+ # URL template) and is ignored, same as an unrecognized location.
72
+ #
73
73
  # @param spec [Hash] the parameter specification
74
74
  # @param route [Object] the Grape route object
75
75
  # @return [String] the parameter location
76
76
  def extract_from_spec(spec, route)
77
- # If body_name is set on the route, treat non-path params as body by default
78
- param_type = spec.dig(:documentation, :param_type)
79
- in_location = spec.dig(:documentation, :in)
80
- return "body" if route.options[:body_name] && !param_type && !in_location
77
+ location = explicit_location(spec)
78
+ location = nil if location == "path"
79
+ return "body" if route_body_opted_in?(route) && location.nil?
81
80
 
82
81
  # Support both param_type and in for grape-swagger compatibility
83
82
  # param_type takes precedence over in when both are specified
84
- explicit_location = (param_type || in_location)&.to_s&.downcase
85
- return explicit_location if explicit_location
83
+ return location if location
86
84
 
87
85
  # Default: body for write methods (POST/PUT/PATCH), query for read methods (GET/DELETE/HEAD)
88
86
  http_method = route.request_method.to_s.downcase
89
87
  Constants::HttpMethods::BODYLESS_HTTP_METHODS.include?(http_method) ? "query" : "body"
90
88
  end
89
+
90
+ def explicit_location(spec)
91
+ doc = spec[:documentation] || {}
92
+ param_type = doc[:param_type] || doc["param_type"]
93
+ in_location = doc[:in] || doc["in"]
94
+ location = (param_type || in_location)&.to_s&.downcase
95
+ location if VALID_EXPLICIT_LOCATIONS.include?(location)
96
+ end
91
97
  end
92
98
  end
93
99
  end
@@ -119,19 +119,17 @@ module GrapeOAS
119
119
  type_names = extract_multi_types(type)
120
120
 
121
121
  # OPTIMIZE: [Type, Nil] becomes nullable Type instead of oneOf
122
- if nullable_type_pair?(type_names)
123
- non_nil_type = type_names.find { |t| !nil_type_name?(t) }
122
+ if (nullable_type = Constants.nullable_type(type_names))
124
123
  return ApiModel::Schema.new(
125
- type: resolve_schema_type(non_nil_type),
126
- format: Constants.format_for_type(non_nil_type),
124
+ type: resolve_schema_type(nullable_type),
125
+ format: Constants.format_for_type(nullable_type),
127
126
  nullable: true,
128
127
  )
129
128
  end
130
129
 
131
130
  # General case: build oneOf schema
132
131
  # Filter out nil types - OpenAPI 3.0 uses nullable property instead
133
- has_nil_type = type_names.any? { |t| nil_type_name?(t) }
134
- non_nil_types = type_names.reject { |t| nil_type_name?(t) }
132
+ nil_types, non_nil_types = type_names.partition { |type_name| Constants.nil_type?(type_name) }
135
133
 
136
134
  schemas = non_nil_types.map do |type_name|
137
135
  ApiModel::Schema.new(
@@ -139,26 +137,9 @@ module GrapeOAS
139
137
  format: Constants.format_for_type(type_name),
140
138
  )
141
139
  end
142
- ApiModel::Schema.new(one_of: schemas, nullable: has_nil_type ? true : nil)
143
- end
144
-
145
- # Checks if type_names is a pair of [SomeType, NilType]
146
- def nullable_type_pair?(type_names)
147
- return false unless type_names.size == 2
148
-
149
- type_names.one? { |t| nil_type_name?(t) }
150
- end
151
-
152
- # Checks if the type name represents a nil/null type
153
- def nil_type_name?(type_name)
154
- normalized = type_name.to_s
155
- # Match common nil type patterns:
156
- # - "NilClass" (Ruby's nil type)
157
- # - "Nil" (shorthand)
158
- # - "Foo::Nil", "Types::Nil" (namespaced nil types)
159
- normalized == "NilClass" ||
160
- normalized == "Nil" ||
161
- normalized.end_with?("::Nil")
140
+ schema = ApiModel::Schema.new(one_of: schemas)
141
+ schema.nullable = true if nil_types.any?
142
+ schema
162
143
  end
163
144
 
164
145
  def build_primitive_schema(raw_type, doc)
@@ -13,7 +13,7 @@ module GrapeOAS
13
13
  def self.apply(schema, spec, doc)
14
14
  nullable = extract_nullable(doc)
15
15
 
16
- schema.description ||= doc[:desc]
16
+ schema.description ||= doc[:desc] || spec[:desc]
17
17
  # Preserve existing nullable: true (e.g., from [Type, Nil] optimization)
18
18
  schema.nullable = (schema.nullable || nullable) if schema.respond_to?(:nullable=)
19
19
 
@@ -48,7 +48,11 @@ module GrapeOAS
48
48
  # Parsers are tried in order of priority
49
49
  def response_specs
50
50
  parser = parsers.find { |p| p.applicable?(route) }
51
- parser ? parser.parse(route) : []
51
+ return [] unless parser
52
+
53
+ parser.parse(route).each do |spec|
54
+ spec[:code] = ResponseParsers::Base.normalize_status_code(spec[:code])
55
+ end
52
56
  end
53
57
 
54
58
  def parsers
@@ -29,6 +29,17 @@ module GrapeOAS
29
29
 
30
30
  private
31
31
 
32
+ def normalize_status_code(status)
33
+ return status unless status.is_a?(Symbol)
34
+
35
+ status_name = status.to_s
36
+ return status_name if status_name == "default" || status_name.match?(/\A[1-5](?:\d{2}|XX)\z/)
37
+
38
+ Rack::Utils.status_code(status)
39
+ end
40
+
41
+ module_function :normalize_status_code
42
+
32
43
  # Extract status code from hash, supporting multiple key names
33
44
  def extract_status_code(hash, default_code)
34
45
  hash[:code] || hash[:status] || hash[:http_status] || default_code
@@ -17,10 +17,10 @@ module GrapeOAS
17
17
  doc_resps = route.options.dig(:documentation, :responses)
18
18
  return [] unless doc_resps.is_a?(Hash)
19
19
 
20
- doc_resps.map do |code, doc|
20
+ specs = doc_resps.map do |code, doc|
21
21
  doc = normalize_hash_keys(doc)
22
22
  {
23
- code: code,
23
+ code: normalize_status_code(code),
24
24
  message: extract_description(doc),
25
25
  headers: doc[:headers],
26
26
  entity: extract_entity(doc, route.options[:entity]),
@@ -28,6 +28,9 @@ module GrapeOAS
28
28
  examples: doc[:examples]
29
29
  }
30
30
  end
31
+ return specs if specs.any? { |spec| spec[:code].to_s == HttpCodesParser::DEFAULT_RESPONSE_CODE }
32
+
33
+ specs + HttpCodesParser.new.default_response_specs(route)
31
34
  end
32
35
  end
33
36
  end
@@ -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)
@@ -72,6 +72,20 @@ module GrapeOAS
72
72
  VARIANT_COLLECTION = /\A(?<container>Array|Set)\[(?<inner>#{CONST_NAME}(?:,\s*#{CONST_NAME})+)\]\z/
73
73
  end
74
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
87
+ end
88
+
75
89
  # Default values for OpenAPI spec when not provided by user
76
90
  module Defaults
77
91
  LICENSE_NAME = "Proprietary"
@@ -28,7 +28,7 @@ module GrapeOAS
28
28
  FORM_MEDIA_TYPES = %w[application/x-www-form-urlencoded multipart/form-data].freeze
29
29
 
30
30
  def build
31
- params = Array(@op.parameters).map { |param| build_parameter(param) }
31
+ params = representable_parameters.map { |param| build_parameter(param) }
32
32
  if @op.request_body
33
33
  if form_only_request?
34
34
  params.concat(build_form_parameters(@op.request_body))
@@ -41,10 +41,53 @@ module GrapeOAS
41
41
 
42
42
  private
43
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
+
44
86
  def build_parameter(param)
45
- type = param.schema&.type
46
- format = param.schema&.format
47
- 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]
48
91
  is_primitive = type && primitive_types.include?(type)
49
92
 
50
93
  if is_primitive && param.location != "body"
@@ -57,8 +100,8 @@ module GrapeOAS
57
100
  "type" => mapping ? mapping[:type] : type,
58
101
  "format" => format || (mapping ? mapping[:format] : nil)
59
102
  }
60
- apply_schema_constraints(result, param.schema)
61
- apply_collection_format(result, param, type)
103
+ apply_schema_constraints(result, schema)
104
+ apply_collection_format(result, param, schema)
62
105
  result.compact
63
106
  else
64
107
  {
@@ -66,7 +109,7 @@ module GrapeOAS
66
109
  "in" => param.location,
67
110
  "required" => param.required,
68
111
  "description" => param.description,
69
- "schema" => build_schema_or_ref(param.schema)
112
+ "schema" => build_schema_or_ref(schema)
70
113
  }.tap do |h|
71
114
  h["type"] = type if type
72
115
  h["format"] = format if format
@@ -111,10 +154,10 @@ module GrapeOAS
111
154
  result
112
155
  end
113
156
 
114
- def apply_collection_format(result, param, type)
115
- return unless type == Constants::SchemaTypes::ARRAY
157
+ def apply_collection_format(result, param, schema)
158
+ return unless schema.type == Constants::SchemaTypes::ARRAY
116
159
 
117
- result["items"] = build_schema_or_ref(param.schema.items) if param.schema.items
160
+ result["items"] = build_schema_or_ref(schema.items) if schema.items
118
161
  return unless param.collection_format
119
162
 
120
163
  valid_formats = %w[csv ssv tsv pipes multi brackets]
@@ -44,10 +44,18 @@ module GrapeOAS
44
44
  # @return [ApiModel::Schema] the built schema
45
45
  def schema_for_exposure(exposure, doc)
46
46
  opts = exposure_options(exposure)
47
- type = opts[:using] || doc[:type]
47
+ nullable_type = Constants.nullable_type(doc[:types])
48
+ if opts[:using] && doc.key?(:types)
49
+ GrapeOAS.logger.warn("Ignoring types: because using: takes precedence")
50
+ elsif doc.key?(:types) && !nullable_type
51
+ GrapeOAS.logger.warn("Ignoring unsupported entity documentation types: #{doc[:types].inspect}")
52
+ end
48
53
 
54
+ type = opts[:using] || nullable_type || doc[:type]
49
55
  schema = type_resolver.build_exposure_base_schema(type)
50
- schema = apply_exposure_properties(schema, doc)
56
+ schema = apply_exposure_properties(
57
+ schema, doc, inferred_nullable: !nullable_type.nil? && !opts[:using],
58
+ )
51
59
  SchemaConstraints.apply(schema, doc)
52
60
  schema
53
61
  end
@@ -206,8 +214,15 @@ module GrapeOAS
206
214
  schema
207
215
  end
208
216
 
209
- def apply_exposure_properties(schema, doc)
210
- nullable = PropertyExtractor.extract_nullable(doc)
217
+ def apply_exposure_properties(schema, doc, inferred_nullable: false)
218
+ schema = schema.dup if inferred_nullable && !schema.canonical_name
219
+ nullable_false = doc[:nullable] == false ||
220
+ (doc[:x].is_a?(Hash) && doc[:x][:nullable] == false)
221
+ nullable = if (schema.nullable || inferred_nullable) && nullable_false
222
+ false
223
+ else
224
+ schema.nullable || inferred_nullable || PropertyExtractor.extract_nullable(doc)
225
+ end
211
226
  if nullable && schema.canonical_name
212
227
  # Don't mutate the shared cached entity schema. Create a wrapper with
213
228
  # all_of so the exporter emits { nullable: true, allOf: [{ $ref }] }.
@@ -58,17 +58,19 @@ module GrapeOAS
58
58
  end
59
59
 
60
60
  def build_items_schema(type_names)
61
- if nullable_type_pair?(type_names)
62
- schema = build_items_for_name(type_names.find { |name| !nil_type_name?(name) })
61
+ if (nullable_type = Constants.nullable_type(type_names))
62
+ schema = build_items_for_name(nullable_type)
63
63
  schema.nullable = true
64
64
  return schema
65
65
  end
66
66
 
67
67
  return build_items_for_name(type_names.first) if type_names.size == 1
68
68
 
69
- has_nil_type = type_names.any? { |name| nil_type_name?(name) }
70
- variants = type_names.reject { |name| nil_type_name?(name) }.map { |name| build_items_for_name(name) }
71
- ApiModel::Schema.new(one_of: variants, nullable: has_nil_type ? true : nil)
69
+ nil_types, other_types = type_names.partition { |type| Constants.nil_type?(type) }
70
+ variants = other_types.map { |type| build_items_for_name(type) }
71
+ schema = ApiModel::Schema.new(one_of: variants)
72
+ schema.nullable = true if nil_types.any?
73
+ schema
72
74
  end
73
75
 
74
76
  def build_items_for_name(type_name)
@@ -80,15 +82,6 @@ module GrapeOAS
80
82
  end
81
83
  end
82
84
 
83
- def nullable_type_pair?(type_names)
84
- type_names.size == 2 && type_names.one? { |name| nil_type_name?(name) }
85
- end
86
-
87
- def nil_type_name?(type_name)
88
- normalized = type_name.to_s
89
- normalized == "NilClass" || normalized == "Nil" || normalized.end_with?("::Nil")
90
- end
91
-
92
85
  def build_schema_from_class(klass)
93
86
  # First, check if Introspectors can handle this class
94
87
  # (e.g., Grape::Entity, Dry::Schema, custom types)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module GrapeOAS
4
- VERSION = "1.5.0"
4
+ VERSION = "1.5.1"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: grape-oas
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.5.0
4
+ version: 1.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrei Subbota
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-07 00:00:00.000000000 Z
11
+ date: 2026-09-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: grape