Skip to content

Commit 6cd85b9

Browse files
authored
Merge pull request #95 from sphinx-contrib/2to3
Add OAS 2 -> 3 converter
2 parents 227ef53 + 57c14a9 commit 6cd85b9

13 files changed

+3240
-0
lines changed

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"PyYAML >= 3.12",
3232
"jsonschema >= 2.5.1",
3333
"m2r >= 0.2",
34+
"picobox >= 2.2",
3435
],
3536
project_urls={
3637
"Documentation": "https://sphinxcontrib-openapi.readthedocs.io/",

sphinxcontrib/openapi/_lib2to3.py

Lines changed: 378 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,378 @@
1+
"""Partial OpenAPI v2.x (fka Swagger) to OpenAPI v3.x converter."""
2+
3+
import functools
4+
import urllib
5+
6+
import picobox
7+
8+
9+
__all__ = [
10+
"convert",
11+
]
12+
13+
14+
def convert(spec):
15+
"""Convert a given OAS 2 spec to OAS 3."""
16+
17+
return Lib2to3().convert(spec)
18+
19+
20+
def _is_vendor_extension(key):
21+
"""Return 'True' if a given key is a vendor extension."""
22+
23+
return key.startswith("x-")
24+
25+
26+
def _get_properties(node, properties, *, vendor_extensions=False):
27+
"""Return a subset of 'node' properties w/ or wo/ vendor extensions."""
28+
29+
return {
30+
key: value
31+
for key, value in node.items()
32+
if any([key in properties, vendor_extensions and _is_vendor_extension(key)])
33+
}
34+
35+
36+
def _get_schema_properties(node, *, except_for=None):
37+
"""Find and return 'Schema Object' properties."""
38+
39+
except_for = except_for or set()
40+
schema = _get_properties(
41+
node,
42+
{
43+
"additionalProperties",
44+
"allOf",
45+
"default",
46+
"description",
47+
"discriminator",
48+
"enum",
49+
"example",
50+
"exclusiveMaximum",
51+
"exclusiveMinimum",
52+
"externalDocs",
53+
"format",
54+
"items",
55+
"maxItems",
56+
"maxLength",
57+
"maxProperties",
58+
"maximum",
59+
"minItems",
60+
"minLength",
61+
"minProperties",
62+
"minimum",
63+
"multipleOf",
64+
"pattern",
65+
"properties",
66+
"readOnly",
67+
"required",
68+
"title",
69+
"type",
70+
"uniqueItems",
71+
"xml",
72+
}
73+
- set(except_for),
74+
)
75+
76+
if "discriminator" in schema:
77+
schema["discriminator"] = {"propertyName": schema["discriminator"]}
78+
return schema
79+
80+
81+
def _items_wo_vendor_extensions(node):
82+
"""Iterate over 'node' properties excluding vendor extensions."""
83+
84+
for key, value in node.items():
85+
if _is_vendor_extension(key):
86+
continue
87+
yield key, value
88+
89+
90+
class Lib2to3:
91+
92+
_target_version = "3.0.3"
93+
_injector = picobox.Stack()
94+
95+
def _insert_into_injector(name):
96+
def decorator(fn):
97+
@functools.wraps(fn)
98+
def wrapper(self, node, *args, **kwargs):
99+
with Lib2to3._injector.push(picobox.Box(), chain=True) as box:
100+
box.put(name, factory=lambda: node, scope=picobox.threadlocal)
101+
return fn(self, node, *args, **kwargs)
102+
103+
return wrapper
104+
105+
return decorator
106+
107+
def __init__(self):
108+
self._schemes = set()
109+
110+
@_insert_into_injector("spec")
111+
def convert(self, spec):
112+
# The following OAS 2 fields are ignored and not converted. Mostly due
113+
# to the fact that we expect *resolved* spec as input, and most of its
114+
# fields are used to group shared (i.e. referenced) objects that will
115+
# not exist in the resolved spec.
116+
#
117+
# - definitions
118+
# - parameters
119+
# - responses
120+
# - securityDefinitions
121+
# - security
122+
#
123+
# By no means one must assume that these fields will never be
124+
# converted. I simply have no time to work on this, and for
125+
# sphixcontrib-openapi purposes it's not actually needed.
126+
127+
converted = {
128+
"info": spec["info"],
129+
"openapi": self._target_version,
130+
"paths": self.convert_paths(spec["paths"]),
131+
}
132+
converted.update(
133+
_get_properties(spec, {"tags", "externalDocs"}, vendor_extensions=True),
134+
)
135+
136+
servers = self.convert_servers(spec)
137+
if servers:
138+
converted["servers"] = servers
139+
140+
return converted
141+
142+
@_insert_into_injector("paths")
143+
def convert_paths(self, paths):
144+
converted = _get_properties(paths, {}, vendor_extensions=True)
145+
146+
for endpoint, path in _items_wo_vendor_extensions(paths):
147+
converted[endpoint] = self.convert_path(path)
148+
149+
return converted
150+
151+
@_insert_into_injector("path")
152+
def convert_path(self, path):
153+
converted = _get_properties(path, {}, vendor_extensions=True)
154+
155+
for key, value in _items_wo_vendor_extensions(path):
156+
if key == "parameters":
157+
converted[key] = self.convert_parameters(value)
158+
else:
159+
converted[key] = self.convert_operation(value)
160+
161+
return converted
162+
163+
@_insert_into_injector("operation")
164+
def convert_operation(self, operation):
165+
converted = _get_properties(
166+
operation,
167+
{
168+
"tags",
169+
"summary",
170+
"description",
171+
"externalDocs",
172+
"operationId",
173+
"deprecated",
174+
"security",
175+
},
176+
vendor_extensions=True,
177+
)
178+
179+
# Memorize every encountered 'schemes'. Since this property does not
180+
# exist in OAS 3, it seems the best we can do is to use them in OAS 3
181+
# 'servers' object.
182+
self._schemes.update(operation.get("schemes", []))
183+
184+
if "parameters" in operation:
185+
parameters = self.convert_parameters(operation["parameters"])
186+
187+
# Both 'body' and 'formData' parameters are mutually exclusive,
188+
# therefore there's no way we may end up with both kinds at once.
189+
request_body = self.convert_request_body(operation)
190+
request_body = request_body or self.convert_request_body_formdata(operation)
191+
192+
if parameters:
193+
converted["parameters"] = parameters
194+
195+
if request_body:
196+
converted["requestBody"] = request_body
197+
198+
converted["responses"] = self.convert_responses(operation["responses"])
199+
return converted
200+
201+
@_injector.pass_("spec")
202+
def convert_request_body(self, operation, *, spec):
203+
# OAS 3 expects an explicitly specified mimetype of the request body.
204+
# It's not clear what to do if OAS 2 'consumes' is not defined. Let's
205+
# start with a glob pattern and figure out what a better option could
206+
# be later on.
207+
consumes = operation.get("consumes") or spec.get("consumes") or ["*/*"]
208+
209+
for parameter in operation["parameters"]:
210+
if parameter["in"] == "body":
211+
# Since 'requestBody' is completely new and nested object in
212+
# OAS 3, it's not clear what should we insert possible vendor
213+
# extensions. Thus, let's ignore them until we figure it out.
214+
converted = _get_properties(parameter, {"description", "required"})
215+
converted["content"] = {
216+
consume: {"schema": parameter["schema"]} for consume in consumes
217+
}
218+
return converted
219+
220+
return None
221+
222+
@_injector.pass_("spec")
223+
def convert_request_body_formdata(self, operation, *, spec):
224+
consumes = (
225+
operation.get("consumes")
226+
or spec.get("consumes")
227+
or ["application/x-www-form-urlencoded"]
228+
)
229+
supported = {
230+
"application/x-www-form-urlencoded",
231+
"multipart/form-data",
232+
}
233+
mimetypes = supported.intersection(consumes)
234+
schema = {"type": "object", "properties": {}}
235+
236+
for parameter in operation["parameters"]:
237+
if parameter["in"] == "formData":
238+
schema["properties"][parameter["name"]] = _get_schema_properties(
239+
parameter, except_for={"name", "in", "required"}
240+
)
241+
242+
if parameter.get("required"):
243+
schema.setdefault("required", []).append(parameter["name"])
244+
245+
# Excerpt from OpenAPI 2.x spec:
246+
#
247+
# > If type is "file", the consumes MUST be either
248+
# > "multipart/form-data", "application/x-www-form-urlencoded"
249+
# > or both and the parameter MUST be in "formData".
250+
#
251+
# This is weird since HTTP does not allow file uploading in
252+
# 'application/x-www-form-urlencoded'. Moreover, Swagger
253+
# editor complains if 'file' is detected and there's no
254+
# 'multipart/form-data' in `consumes'.
255+
if parameter["type"] == "file":
256+
mimetypes = ["multipart/form-data"]
257+
258+
if not schema["properties"]:
259+
return None
260+
return {"content": {mimetype: {"schema": schema} for mimetype in mimetypes}}
261+
262+
@_insert_into_injector("parameters")
263+
def convert_parameters(self, parameters):
264+
return [
265+
self.convert_parameter(parameter)
266+
for parameter in parameters
267+
# If a parameter is one of the backward compatible type, delegate
268+
# the call to the converter function. Incompatible types, such as
269+
# 'formData' and 'body', must be handled separately since they are
270+
# reflected in 'Operation Object' in OAS 3.
271+
if parameter["in"] in {"query", "header", "path"}
272+
]
273+
274+
@_insert_into_injector("parameter")
275+
def convert_parameter(self, parameter):
276+
schema = _get_schema_properties(
277+
parameter,
278+
# Some of 'Parameter Object' properties have the same name as some
279+
# of 'Schema Object' properties. Since we know for sure that in
280+
# this context they are part of 'Parameter Object', we should
281+
# ignore their meaning as part of 'Schema Object'.
282+
except_for={"name", "in", "description", "required"},
283+
)
284+
converted = {
285+
key: value for key, value in parameter.items() if key not in schema
286+
}
287+
converted["schema"] = schema
288+
collection_format = converted.pop("collectionFormat", None)
289+
290+
if converted["in"] in {"path", "header"} and collection_format == "csv":
291+
converted["style"] = "simple"
292+
elif converted["in"] in {"query"} and collection_format:
293+
styles = {
294+
"csv": {"style": "form", "explode": False},
295+
"multi": {"style": "form", "explode": True},
296+
"ssv": {"style": "spaceDelimited"},
297+
"pipes": {"style": "pipeDelimited"},
298+
# OAS 3 does not explicitly say what is the alternative to
299+
# 'collectionFormat=tsv'. We have no other option but to ignore
300+
# it. Fortunately, we don't care much as it's not used by the
301+
# renderer.
302+
"tsv": {},
303+
}
304+
converted.update(styles[collection_format])
305+
306+
return converted
307+
308+
@_insert_into_injector("responses")
309+
def convert_responses(self, responses):
310+
converted = _get_properties(responses, {}, vendor_extensions=True)
311+
312+
for status_code, response in _items_wo_vendor_extensions(responses):
313+
converted[status_code] = self.convert_response(response)
314+
315+
return converted
316+
317+
@_injector.pass_("spec")
318+
@_injector.pass_("operation")
319+
@_insert_into_injector("response")
320+
def convert_response(self, response, *, spec, operation):
321+
converted = _get_properties(response, {"description"}, vendor_extensions=True)
322+
323+
# OAS 3 expects an explicitly specified mimetype in the response. It's
324+
# not clear what to do if OAS 2 'produces' is not defined. Let's start
325+
# with a glob pattern and figure out what a better option could be
326+
# later on.
327+
produces = operation.get("produces") or spec.get("produces") or ["*/*"]
328+
schema = response.get("schema")
329+
examples = response.get("examples")
330+
331+
if schema or examples:
332+
content = converted.setdefault("content", {})
333+
334+
if schema is not None:
335+
for mimetype in produces:
336+
content.setdefault(mimetype, {})["schema"] = schema
337+
338+
if examples is not None:
339+
# According to OAS2, mimetypes in 'examples' property MUST be
340+
# one of the operation's 'produces'.
341+
for mimetype, example in examples.items():
342+
content.setdefault(mimetype, {})["example"] = example
343+
344+
if "headers" in response:
345+
converted["headers"] = {
346+
key: dict(
347+
_get_properties(value, "description", vendor_extensions=True),
348+
schema=_get_schema_properties(value, except_for={"description"}),
349+
)
350+
for key, value in response["headers"].items()
351+
}
352+
353+
return converted
354+
355+
def convert_servers(self, spec):
356+
"""Convert OAS2 '(host, basePath, schemes)' triplet into OAS3 'servers' node."""
357+
358+
host = spec.get("host", "")
359+
basepath = spec.get("basePath", "")
360+
schemes = self._schemes.union(spec.get("schemes", set()))
361+
362+
# Since 'host', 'basePath' and 'schemes' are optional in OAS 2, there
363+
# may be the case when they aren't set. If that's happened it means
364+
# there's nothing to convert, and thus we simply return an empty list.
365+
if not host and not basepath and not schemes:
366+
return []
367+
368+
if not schemes:
369+
# If 'host' is not set, the url will contain a bare basePath.
370+
# According to OAS 3, it's a valid URL, and both the host and the
371+
# scheme must be assumed to be the same as the server that shared
372+
# this OAS 3 spec.
373+
return [{"url": urllib.parse.urljoin(host, basepath)}]
374+
375+
return [
376+
{"url": urllib.parse.urlunsplit([scheme, host, basepath, None, None])}
377+
for scheme in sorted(schemes)
378+
]

tests/lib2to3/conftest.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import textwrap
2+
3+
import pytest
4+
import yaml
5+
6+
7+
@pytest.fixture(scope="function")
8+
def oas_fragment():
9+
def oas_fragment(fragment):
10+
return yaml.safe_load(textwrap.dedent(fragment))
11+
12+
return oas_fragment

0 commit comments

Comments
 (0)