swagger23 0.1.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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +125 -0
- data/bin/swagger23 +113 -0
- data/lib/swagger23/converter.rb +71 -0
- data/lib/swagger23/converters/components.rb +79 -0
- data/lib/swagger23/converters/info.rb +16 -0
- data/lib/swagger23/converters/paths.rb +274 -0
- data/lib/swagger23/converters/security.rb +99 -0
- data/lib/swagger23/converters/servers.rb +37 -0
- data/lib/swagger23/error.rb +8 -0
- data/lib/swagger23/ref_rewriter.rb +72 -0
- data/lib/swagger23/schema_processor.rb +93 -0
- data/lib/swagger23/version.rb +5 -0
- data/lib/swagger23.rb +112 -0
- data/sig/swagger23.rbs +71 -0
- data/swagger23.gemspec +48 -0
- metadata +111 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Swagger23
|
|
4
|
+
module Converters
|
|
5
|
+
# Converts Swagger 2.0 `paths` to OpenAPI 3.0 `paths`.
|
|
6
|
+
#
|
|
7
|
+
# Key transformations per operation:
|
|
8
|
+
# - `in: body` parameter → requestBody
|
|
9
|
+
# - `in: formData` parameters → requestBody (form-encoded or multipart)
|
|
10
|
+
# - `consumes` / `produces` → content-type keys in requestBody / responses
|
|
11
|
+
# - response `schema` → response.content[mime].schema
|
|
12
|
+
# - response headers → response.headers (simplified)
|
|
13
|
+
# - all other parameters unchanged (path, query, header, cookie)
|
|
14
|
+
module Paths
|
|
15
|
+
HTTP_METHODS = %w[get put post delete options head patch trace].freeze
|
|
16
|
+
|
|
17
|
+
# @param swagger [Hash] the full Swagger 2.0 document (needed for global consumes/produces)
|
|
18
|
+
def self.convert(swagger)
|
|
19
|
+
paths = swagger["paths"] || {}
|
|
20
|
+
global_consumes = Array(swagger["consumes"])
|
|
21
|
+
global_produces = Array(swagger["produces"])
|
|
22
|
+
|
|
23
|
+
converted = {}
|
|
24
|
+
paths.each do |path, path_item|
|
|
25
|
+
converted[path] = convert_path_item(path_item, global_consumes, global_produces)
|
|
26
|
+
end
|
|
27
|
+
converted
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# -------------------------------------------------------------------------
|
|
31
|
+
# Path item
|
|
32
|
+
# -------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
def self.convert_path_item(path_item, global_consumes, global_produces)
|
|
35
|
+
# Swagger 2.0 allows a $ref at path-item level (e.g. to share path items).
|
|
36
|
+
# Pass it through; RefRewriter will rewrite the ref if it matches a known prefix.
|
|
37
|
+
return path_item.dup if path_item.key?("$ref")
|
|
38
|
+
|
|
39
|
+
result = {}
|
|
40
|
+
|
|
41
|
+
# Path-level parameters (non-operation)
|
|
42
|
+
if (params = path_item["parameters"])
|
|
43
|
+
result["parameters"] = params.map { |p| convert_parameter(p) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Copy path-level extensions
|
|
47
|
+
path_item.each do |key, value|
|
|
48
|
+
result[key] = value if key.start_with?("x-")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
HTTP_METHODS.each do |method|
|
|
52
|
+
next unless path_item.key?(method)
|
|
53
|
+
|
|
54
|
+
result[method] = convert_operation(
|
|
55
|
+
path_item[method],
|
|
56
|
+
global_consumes,
|
|
57
|
+
global_produces
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
result
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# -------------------------------------------------------------------------
|
|
65
|
+
# Operation
|
|
66
|
+
# -------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
def self.convert_operation(op, global_consumes, global_produces)
|
|
69
|
+
result = {}
|
|
70
|
+
|
|
71
|
+
# Passthrough scalar fields
|
|
72
|
+
%w[summary description operationId deprecated tags externalDocs].each do |field|
|
|
73
|
+
result[field] = op[field] if op.key?(field)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Security
|
|
77
|
+
result["security"] = op["security"] if op.key?("security")
|
|
78
|
+
|
|
79
|
+
# Extensions
|
|
80
|
+
op.each { |k, v| result[k] = v if k.start_with?("x-") }
|
|
81
|
+
|
|
82
|
+
# Effective consumes / produces for this operation
|
|
83
|
+
op_consumes = Array(op["consumes"]).then { |a| a.empty? ? global_consumes : a }
|
|
84
|
+
op_produces = Array(op["produces"]).then { |a| a.empty? ? global_produces : a }
|
|
85
|
+
|
|
86
|
+
# Default mime types when none specified
|
|
87
|
+
op_consumes = ["application/json"] if op_consumes.empty?
|
|
88
|
+
op_produces = ["application/json"] if op_produces.empty?
|
|
89
|
+
|
|
90
|
+
# Split parameters
|
|
91
|
+
all_params = Array(op["parameters"])
|
|
92
|
+
body_param = all_params.find { |p| p["in"] == "body" }
|
|
93
|
+
form_params = all_params.select { |p| p["in"] == "formData" }
|
|
94
|
+
other_params = all_params.reject { |p| %w[body formData].include?(p["in"]) }
|
|
95
|
+
|
|
96
|
+
# Regular parameters (path / query / header / cookie)
|
|
97
|
+
unless other_params.empty?
|
|
98
|
+
result["parameters"] = other_params.map { |p| convert_parameter(p) }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# requestBody from body parameter
|
|
102
|
+
if body_param
|
|
103
|
+
result["requestBody"] = build_request_body_from_body_param(body_param, op_consumes)
|
|
104
|
+
elsif !form_params.empty?
|
|
105
|
+
result["requestBody"] = build_request_body_from_form_params(form_params, op_consumes)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# responses
|
|
109
|
+
if (responses = op["responses"])
|
|
110
|
+
result["responses"] = convert_responses(responses, op_produces)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# callbacks – not present in Swagger 2.0, skip
|
|
114
|
+
result
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# -------------------------------------------------------------------------
|
|
118
|
+
# Parameters
|
|
119
|
+
# -------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
# Convert a single non-body/formData parameter.
|
|
122
|
+
def self.convert_parameter(param)
|
|
123
|
+
return param if param.key?("$ref")
|
|
124
|
+
|
|
125
|
+
result = {}
|
|
126
|
+
%w[name in description required deprecated allowEmptyValue].each do |field|
|
|
127
|
+
result[field] = param[field] if param.key?(field)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# OAS 3.0 §4.8.12.1: path parameters MUST have required: true.
|
|
131
|
+
# Swagger 2.0 technically requires it too, but many real specs omit it.
|
|
132
|
+
result["required"] = true if result["in"] == "path"
|
|
133
|
+
|
|
134
|
+
# `collectionFormat` → `style` + `explode`
|
|
135
|
+
if (schema = build_param_schema(param))
|
|
136
|
+
result["schema"] = schema
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Extensions
|
|
140
|
+
param.each { |k, v| result[k] = v if k.start_with?("x-") }
|
|
141
|
+
|
|
142
|
+
result
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Build the schema for a parameter from its inline type/format/etc.
|
|
146
|
+
def self.build_param_schema(param)
|
|
147
|
+
# If there's already a nested schema object use it
|
|
148
|
+
return param["schema"] if param.key?("schema")
|
|
149
|
+
|
|
150
|
+
schema = {}
|
|
151
|
+
%w[type format default enum minimum maximum minLength maxLength
|
|
152
|
+
pattern items uniqueItems collectionFormat].each do |field|
|
|
153
|
+
schema[field] = param[field] if param.key?(field)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# collectionFormat → style
|
|
157
|
+
if (collection_format = schema.delete("collectionFormat"))
|
|
158
|
+
case collection_format
|
|
159
|
+
when "csv" then schema["style"] = "form"; schema["explode"] = false
|
|
160
|
+
when "ssv" then schema["style"] = "spaceDelimited"
|
|
161
|
+
when "tsv" then schema["style"] = "tabDelimited"
|
|
162
|
+
when "pipes" then schema["style"] = "pipeDelimited"
|
|
163
|
+
when "multi" then schema["style"] = "form"; schema["explode"] = true
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
schema.empty? ? nil : schema
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# -------------------------------------------------------------------------
|
|
171
|
+
# requestBody helpers
|
|
172
|
+
# -------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
def self.build_request_body_from_body_param(body_param, consumes)
|
|
175
|
+
schema = body_param["schema"] || {}
|
|
176
|
+
content = {}
|
|
177
|
+
|
|
178
|
+
consumes.each do |mime|
|
|
179
|
+
content[mime] = { "schema" => schema }
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
result = { "content" => content }
|
|
183
|
+
result["description"] = body_param["description"] if body_param["description"]
|
|
184
|
+
result["required"] = body_param["required"] if body_param.key?("required")
|
|
185
|
+
result
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def self.build_request_body_from_form_params(form_params, consumes)
|
|
189
|
+
properties = {}
|
|
190
|
+
required = []
|
|
191
|
+
|
|
192
|
+
form_params.each do |param|
|
|
193
|
+
name = param["name"]
|
|
194
|
+
schema = build_param_schema(param) || {}
|
|
195
|
+
|
|
196
|
+
# file upload → binary string
|
|
197
|
+
if param["type"] == "file"
|
|
198
|
+
schema = { "type" => "string", "format" => "binary" }
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
schema["description"] = param["description"] if param["description"]
|
|
202
|
+
properties[name] = schema
|
|
203
|
+
required << name if param["required"]
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
form_schema = { "type" => "object", "properties" => properties }
|
|
207
|
+
form_schema["required"] = required unless required.empty?
|
|
208
|
+
|
|
209
|
+
# Decide content-type: multipart/form-data if any file uploads
|
|
210
|
+
has_file = form_params.any? { |p| p["type"] == "file" }
|
|
211
|
+
mime = has_file ? "multipart/form-data" : "application/x-www-form-urlencoded"
|
|
212
|
+
|
|
213
|
+
# If the operation already declares an explicit form mime, honour it
|
|
214
|
+
explicit = consumes.find { |c| c =~ /multipart|form-urlencoded/ }
|
|
215
|
+
mime = explicit || mime
|
|
216
|
+
|
|
217
|
+
{ "content" => { mime => { "schema" => form_schema } } }
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# -------------------------------------------------------------------------
|
|
221
|
+
# Responses
|
|
222
|
+
# -------------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
def self.convert_responses(responses, produces)
|
|
225
|
+
result = {}
|
|
226
|
+
responses.each do |status, response|
|
|
227
|
+
result[status.to_s] = convert_response(response, produces)
|
|
228
|
+
end
|
|
229
|
+
result
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def self.convert_response(response, produces)
|
|
233
|
+
return response if response.key?("$ref")
|
|
234
|
+
|
|
235
|
+
result = {}
|
|
236
|
+
result["description"] = response["description"] || ""
|
|
237
|
+
|
|
238
|
+
# schema → content
|
|
239
|
+
if (schema = response["schema"])
|
|
240
|
+
content = {}
|
|
241
|
+
produces.each do |mime|
|
|
242
|
+
content[mime] = { "schema" => schema }
|
|
243
|
+
end
|
|
244
|
+
result["content"] = content
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# headers
|
|
248
|
+
# OAS 3.0 Header Object: description stays at the top level; type/format go
|
|
249
|
+
# inside a nested `schema` object (Header Object mirrors Parameter Object).
|
|
250
|
+
if (headers = response["headers"])
|
|
251
|
+
converted = {}
|
|
252
|
+
headers.each do |name, header|
|
|
253
|
+
h = {}
|
|
254
|
+
h["description"] = header["description"] if header.key?("description")
|
|
255
|
+
schema = {}
|
|
256
|
+
%w[type format].each { |f| schema[f] = header[f] if header.key?(f) }
|
|
257
|
+
h["schema"] = schema unless schema.empty?
|
|
258
|
+
header.each { |k, v| h[k] = v if k.start_with?("x-") }
|
|
259
|
+
converted[name] = h
|
|
260
|
+
end
|
|
261
|
+
result["headers"] = converted
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# extensions
|
|
265
|
+
response.each { |k, v| result[k] = v if k.start_with?("x-") }
|
|
266
|
+
|
|
267
|
+
result
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Alias used by Components converter for shared parameter conversion
|
|
272
|
+
ParameterConverter = Paths
|
|
273
|
+
end
|
|
274
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Swagger23
|
|
4
|
+
module Converters
|
|
5
|
+
# Converts Swagger 2.0 `securityDefinitions` to OpenAPI 3.0
|
|
6
|
+
# `components/securitySchemes`.
|
|
7
|
+
#
|
|
8
|
+
# Swagger 2.0 types and their OpenAPI 3.0 equivalents:
|
|
9
|
+
#
|
|
10
|
+
# basic → http / scheme: basic
|
|
11
|
+
# apiKey → apiKey (identical structure)
|
|
12
|
+
# oauth2 implicit → oauth2 / flows.implicit
|
|
13
|
+
# oauth2 password → oauth2 / flows.password
|
|
14
|
+
# oauth2 application → oauth2 / flows.clientCredentials
|
|
15
|
+
# oauth2 accessCode → oauth2 / flows.authorizationCode
|
|
16
|
+
module Security
|
|
17
|
+
def self.convert(swagger)
|
|
18
|
+
defs = swagger["securityDefinitions"]
|
|
19
|
+
return {} unless defs
|
|
20
|
+
|
|
21
|
+
schemes = {}
|
|
22
|
+
defs.each do |name, definition|
|
|
23
|
+
schemes[name] = convert_scheme(definition)
|
|
24
|
+
end
|
|
25
|
+
schemes
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.convert_scheme(defn)
|
|
29
|
+
type = defn["type"]
|
|
30
|
+
|
|
31
|
+
case type
|
|
32
|
+
when "basic"
|
|
33
|
+
result = { "type" => "http", "scheme" => "basic" }
|
|
34
|
+
result["description"] = defn["description"] if defn["description"]
|
|
35
|
+
add_extensions(result, defn)
|
|
36
|
+
result
|
|
37
|
+
|
|
38
|
+
when "apiKey"
|
|
39
|
+
result = {
|
|
40
|
+
"type" => "apiKey",
|
|
41
|
+
"name" => defn["name"],
|
|
42
|
+
"in" => defn["in"]
|
|
43
|
+
}
|
|
44
|
+
result["description"] = defn["description"] if defn["description"]
|
|
45
|
+
add_extensions(result, defn)
|
|
46
|
+
result
|
|
47
|
+
|
|
48
|
+
when "oauth2"
|
|
49
|
+
convert_oauth2(defn)
|
|
50
|
+
|
|
51
|
+
else
|
|
52
|
+
# Unknown type – pass through as-is with extensions
|
|
53
|
+
result = defn.dup
|
|
54
|
+
result
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.convert_oauth2(defn)
|
|
59
|
+
flow_name = defn["flow"]
|
|
60
|
+
result = { "type" => "oauth2" }
|
|
61
|
+
result["description"] = defn["description"] if defn["description"]
|
|
62
|
+
|
|
63
|
+
scopes = defn["scopes"] || {}
|
|
64
|
+
auth_url = defn["authorizationUrl"]
|
|
65
|
+
token_url = defn["tokenUrl"]
|
|
66
|
+
|
|
67
|
+
flow = {}
|
|
68
|
+
flow["scopes"] = scopes
|
|
69
|
+
|
|
70
|
+
case flow_name
|
|
71
|
+
when "implicit"
|
|
72
|
+
flow["authorizationUrl"] = auth_url
|
|
73
|
+
result["flows"] = { "implicit" => flow }
|
|
74
|
+
when "password"
|
|
75
|
+
flow["tokenUrl"] = token_url
|
|
76
|
+
result["flows"] = { "password" => flow }
|
|
77
|
+
when "application"
|
|
78
|
+
flow["tokenUrl"] = token_url
|
|
79
|
+
result["flows"] = { "clientCredentials" => flow }
|
|
80
|
+
when "accessCode"
|
|
81
|
+
flow["authorizationUrl"] = auth_url
|
|
82
|
+
flow["tokenUrl"] = token_url
|
|
83
|
+
result["flows"] = { "authorizationCode" => flow }
|
|
84
|
+
else
|
|
85
|
+
result["flows"] = {}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
add_extensions(result, defn)
|
|
89
|
+
result
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def self.add_extensions(result, defn)
|
|
93
|
+
defn.each do |key, value|
|
|
94
|
+
result[key] = value if key.start_with?("x-")
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Swagger23
|
|
4
|
+
module Converters
|
|
5
|
+
# Converts Swagger 2.0 `host` + `basePath` + `schemes` into an OpenAPI 3.0
|
|
6
|
+
# `servers` array.
|
|
7
|
+
#
|
|
8
|
+
# Swagger 2.0 fields used:
|
|
9
|
+
# host – e.g. "api.example.com" or "api.example.com:8080"
|
|
10
|
+
# basePath – e.g. "/v2"
|
|
11
|
+
# schemes – e.g. ["https", "http"]
|
|
12
|
+
#
|
|
13
|
+
# OpenAPI 3.0 result:
|
|
14
|
+
# servers:
|
|
15
|
+
# - url: https://api.example.com/v2
|
|
16
|
+
# - url: http://api.example.com/v2
|
|
17
|
+
module Servers
|
|
18
|
+
DEFAULT_HOST = "localhost"
|
|
19
|
+
DEFAULT_BASEPATH = "/"
|
|
20
|
+
|
|
21
|
+
def self.convert(swagger)
|
|
22
|
+
host = swagger["host"] || DEFAULT_HOST
|
|
23
|
+
base_path = swagger["basePath"] || DEFAULT_BASEPATH
|
|
24
|
+
schemes = Array(swagger["schemes"])
|
|
25
|
+
|
|
26
|
+
base_path = "/#{base_path}" unless base_path.start_with?("/")
|
|
27
|
+
|
|
28
|
+
# When no schemes are provided, default to https and use the actual host.
|
|
29
|
+
effective_schemes = schemes.empty? ? ["https"] : schemes
|
|
30
|
+
|
|
31
|
+
effective_schemes.map do |scheme|
|
|
32
|
+
{ "url" => "#{scheme}://#{host}#{base_path}" }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Swagger23
|
|
4
|
+
# Walks any JSON-like structure and rewrites all $ref values so that internal
|
|
5
|
+
# Swagger 2.0 references are mapped to their OpenAPI 3.0 equivalents.
|
|
6
|
+
#
|
|
7
|
+
# Rewrite rules:
|
|
8
|
+
# #/definitions/Foo → #/components/schemas/Foo
|
|
9
|
+
# #/parameters/Foo → #/components/parameters/Foo
|
|
10
|
+
# #/responses/Foo → #/components/responses/Foo
|
|
11
|
+
# #/securityDefinitions/X → #/components/securitySchemes/X
|
|
12
|
+
#
|
|
13
|
+
# Implementation note:
|
|
14
|
+
# Uses an iterative BFS on a JSON deep-clone rather than recursion so that
|
|
15
|
+
# very large or deeply nested specifications never risk a SystemStackError.
|
|
16
|
+
# The JSON round-trip (C extension) is the fastest way to deep-clone an
|
|
17
|
+
# arbitrary JSON-compatible Ruby object and ensures no shared references
|
|
18
|
+
# with the original swagger document remain.
|
|
19
|
+
module RefRewriter
|
|
20
|
+
REF_MAP = {
|
|
21
|
+
"#/definitions/" => "#/components/schemas/",
|
|
22
|
+
"#/parameters/" => "#/components/parameters/",
|
|
23
|
+
"#/responses/" => "#/components/responses/",
|
|
24
|
+
"#/securityDefinitions/" => "#/components/securitySchemes/"
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
# @param obj [Hash, Array, Object] JSON-compatible Ruby object
|
|
28
|
+
# @return a deep copy of obj with all $ref strings rewritten
|
|
29
|
+
def self.rewrite(obj)
|
|
30
|
+
# Deep-clone via JSON round-trip: fast (C ext), handles shared refs,
|
|
31
|
+
# does not mutate the original document.
|
|
32
|
+
# max_nesting: false disables JSON's default 100-level depth guard —
|
|
33
|
+
# real Swagger specs (e.g. Kubernetes) can have deeply nested allOf/anyOf
|
|
34
|
+
# compositions that exceed that limit.
|
|
35
|
+
clone = JSON.parse(JSON.generate(obj, max_nesting: false), max_nesting: false)
|
|
36
|
+
|
|
37
|
+
# Iterative BFS – O(n) in node count, O(max_width) in memory.
|
|
38
|
+
# Mutates the clone in-place; no recursion, no stack pressure.
|
|
39
|
+
queue = [clone]
|
|
40
|
+
until queue.empty?
|
|
41
|
+
current = queue.shift
|
|
42
|
+
|
|
43
|
+
case current
|
|
44
|
+
in Hash
|
|
45
|
+
current.each_pair do |key, value|
|
|
46
|
+
case [key, value]
|
|
47
|
+
in ["$ref", String => ref]
|
|
48
|
+
current[key] = rewrite_ref(ref)
|
|
49
|
+
in [_, Hash | Array => nested]
|
|
50
|
+
queue << nested
|
|
51
|
+
else
|
|
52
|
+
# primitive value — nothing to traverse
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
in Array
|
|
56
|
+
current.each { |item| queue << item if item in Hash | Array }
|
|
57
|
+
else
|
|
58
|
+
# scalar root (e.g. rewrite(42)) — nothing to do
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
clone
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.rewrite_ref(ref)
|
|
66
|
+
REF_MAP.each do |from, to|
|
|
67
|
+
return ref.sub(from, to) if ref.start_with?(from)
|
|
68
|
+
end
|
|
69
|
+
ref
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Swagger23
|
|
4
|
+
# Performs schema-level semantic transformations that bridge Swagger 2.0 and
|
|
5
|
+
# OpenAPI 3.0 type systems. Operates on the already-converted, already
|
|
6
|
+
# $ref-rewritten document (i.e. called after RefRewriter).
|
|
7
|
+
#
|
|
8
|
+
# Transformations applied to every Hash node in the document:
|
|
9
|
+
#
|
|
10
|
+
# 1. x-nullable: true → nullable: true (x-nullable key removed)
|
|
11
|
+
# Common in Java/Spring Boot codegen, AWS API Gateway exports, and any
|
|
12
|
+
# tool targeting Swagger 2.0 that needed to express nullability.
|
|
13
|
+
#
|
|
14
|
+
# 2. discriminator: "PropName" → discriminator: {propertyName: "PropName"}
|
|
15
|
+
# Swagger 2.0 uses a bare string; OAS 3.0 uses an object.
|
|
16
|
+
#
|
|
17
|
+
# 3. type: ["SomeType", "null"] → type: "SomeType", nullable: true
|
|
18
|
+
# Several tools (e.g. swagger-codegen from JSON Schema draft 4 sources)
|
|
19
|
+
# emit an array of types to express nullability. OAS 3.0 uses nullable.
|
|
20
|
+
#
|
|
21
|
+
# Uses an iterative BFS (same pattern as RefRewriter) to avoid stack overflow
|
|
22
|
+
# on deeply nested documents.
|
|
23
|
+
module SchemaProcessor
|
|
24
|
+
# @param obj [Hash, Array, Object] the already-converted OpenAPI 3.0 tree
|
|
25
|
+
# @return obj mutated in-place
|
|
26
|
+
def self.process(obj)
|
|
27
|
+
queue = [obj]
|
|
28
|
+
until queue.empty?
|
|
29
|
+
current = queue.shift
|
|
30
|
+
|
|
31
|
+
case current
|
|
32
|
+
in Hash
|
|
33
|
+
transform!(current)
|
|
34
|
+
current.each_value { |v| queue << v if v in Hash | Array }
|
|
35
|
+
in Array
|
|
36
|
+
current.each { |item| queue << item if item in Hash | Array }
|
|
37
|
+
else
|
|
38
|
+
# scalar or nil — nothing to transform
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
obj
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# Per-node transformations
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
def self.transform!(h)
|
|
50
|
+
nullable_from_x_nullable!(h)
|
|
51
|
+
coerce_discriminator!(h)
|
|
52
|
+
unwrap_type_array!(h)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# x-nullable: true → nullable: true
|
|
56
|
+
# If nullable is already explicitly set we respect it and still remove x-nullable.
|
|
57
|
+
def self.nullable_from_x_nullable!(h)
|
|
58
|
+
return unless h.key?("x-nullable")
|
|
59
|
+
|
|
60
|
+
h["nullable"] = h["x-nullable"] unless h.key?("nullable")
|
|
61
|
+
h.delete("x-nullable")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# discriminator: "PropName" → discriminator: {propertyName: "PropName"}
|
|
65
|
+
# If discriminator is already an object (already-converted or OAS 3 input), leave it.
|
|
66
|
+
def self.coerce_discriminator!(h)
|
|
67
|
+
return unless h.key?("discriminator") && h["discriminator"].is_a?(String)
|
|
68
|
+
|
|
69
|
+
h["discriminator"] = { "propertyName" => h["discriminator"] }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# type: ["T", "null"] → type: "T", nullable: true
|
|
73
|
+
# type: ["T"] → type: "T" (single-element array)
|
|
74
|
+
# type: ["A", "B"] → kept as-is (union of non-null types — no standard mapping)
|
|
75
|
+
def self.unwrap_type_array!(h)
|
|
76
|
+
return unless h.key?("type") && h["type"].is_a?(Array)
|
|
77
|
+
|
|
78
|
+
types = h["type"]
|
|
79
|
+
non_null = types.reject { |t| t == "null" }
|
|
80
|
+
has_null = non_null.size < types.size
|
|
81
|
+
|
|
82
|
+
if non_null.size == 1
|
|
83
|
+
h["type"] = non_null.first
|
|
84
|
+
h["nullable"] = true if has_null
|
|
85
|
+
elsif non_null.empty?
|
|
86
|
+
# type: ["null"] — technically invalid but handle gracefully
|
|
87
|
+
h.delete("type")
|
|
88
|
+
h["nullable"] = true
|
|
89
|
+
end
|
|
90
|
+
# else: multiple non-null types — leave array intact; let the validator surface it
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|