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.
@@ -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,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Swagger23
4
+ class Error < StandardError; end
5
+
6
+ # Raised when the input document is not a valid Swagger 2.0 document.
7
+ class InvalidSwaggerError < Error; end
8
+ 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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Swagger23
4
+ VERSION = "0.1.0"
5
+ end