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 +4 -4
- data/CHANGELOG.md +20 -0
- data/lib/grape_oas/api_model_builders/request.rb +8 -13
- data/lib/grape_oas/api_model_builders/request_params.rb +38 -41
- data/lib/grape_oas/api_model_builders/request_params_support/nested_params_builder.rb +0 -1
- data/lib/grape_oas/api_model_builders/request_params_support/param_location_resolver.rb +33 -27
- data/lib/grape_oas/api_model_builders/request_params_support/param_schema_builder.rb +7 -26
- data/lib/grape_oas/api_model_builders/request_params_support/schema_enhancer.rb +1 -1
- data/lib/grape_oas/api_model_builders/response.rb +5 -1
- data/lib/grape_oas/api_model_builders/response_parsers/base.rb +11 -0
- data/lib/grape_oas/api_model_builders/response_parsers/documentation_responses_parser.rb +5 -2
- data/lib/grape_oas/api_model_builders/response_parsers/http_codes_parser.rb +82 -17
- data/lib/grape_oas/constants.rb +14 -0
- data/lib/grape_oas/exporter/oas2/parameter.rb +53 -10
- data/lib/grape_oas/introspectors/entity_introspector_support/exposure_processor.rb +19 -4
- data/lib/grape_oas/type_resolvers/array_resolver.rb +7 -14
- data/lib/grape_oas/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: eb455b84bab348e43f6f512e31a9c306ec7deb76ddb5e6267231a3c7495615e7
|
|
4
|
+
data.tar.gz: 7096893822180fa7da1976ebbc7b20eaea31108aa8829595d2f4b4764e7350da
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 =
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
76
|
-
#
|
|
77
|
-
#
|
|
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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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,
|
|
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
|
|
100
|
-
# or via body schema
|
|
101
|
-
|
|
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),
|
|
@@ -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
|
|
22
|
-
#
|
|
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
|
|
38
|
-
def self.
|
|
39
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
126
|
-
format: Constants.format_for_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
|
-
|
|
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
|
|
143
|
-
|
|
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
|
-
|
|
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,
|
|
7
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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)
|
data/lib/grape_oas/constants.rb
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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,
|
|
61
|
-
apply_collection_format(result, param,
|
|
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(
|
|
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,
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
62
|
-
schema = build_items_for_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
|
-
|
|
70
|
-
variants =
|
|
71
|
-
ApiModel::Schema.new(one_of: variants
|
|
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)
|
data/lib/grape_oas/version.rb
CHANGED
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.
|
|
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-
|
|
11
|
+
date: 2026-09-16 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: grape
|