Skip to content

Commit 0a0cb88

Browse files
committed
feat(parser): Detect OpenAPI documents of incorrect versions (closes #281)
1 parent aede9be commit 0a0cb88

37 files changed

+186
-69
lines changed

CHANGELOG.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8-
## Unreleased
8+
## 0.8.0 - Unreleased
99

1010
### Additions
1111

@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1414
- `setup` will generate a pyproject.toml with no Poetry information, and instead create a `setup.py` with the
1515
project info.
1616
- `none` will not create a project folder at all, only the inner package folder (which won't be inner anymore)
17+
- Attempt to detect and alert users if they are using an unsupported version of OpenAPI (#281).
1718

1819
### Changes
1920

openapi_python_client/parser/openapi.py

+12-2
Original file line numberDiff line numberDiff line change
@@ -260,12 +260,22 @@ class GeneratorData:
260260
enums: Dict[str, EnumProperty]
261261

262262
@staticmethod
263-
def from_dict(d: Dict[str, Dict[str, Any]]) -> Union["GeneratorData", GeneratorError]:
263+
def from_dict(d: Dict[str, Any]) -> Union["GeneratorData", GeneratorError]:
264264
""" Create an OpenAPI from dict """
265265
try:
266266
openapi = oai.OpenAPI.parse_obj(d)
267267
except ValidationError as e:
268-
return GeneratorError(header="Failed to parse OpenAPI document", detail=str(e))
268+
detail = str(e)
269+
if "swagger" in d:
270+
detail = (
271+
"You may be trying to use a Swagger document; this is not supported by this project.\n\n" + detail
272+
)
273+
return GeneratorError(header="Failed to parse OpenAPI document", detail=detail)
274+
if openapi.openapi.major != 3:
275+
return GeneratorError(
276+
header="openapi-python-client only supports OpenAPI 3.x",
277+
detail=f"The version of the provided document was {openapi.openapi}",
278+
)
269279
if openapi.components is None or openapi.components.schemas is None:
270280
schemas = Schemas()
271281
else:
+37-56
Original file line numberDiff line numberDiff line change
@@ -1,69 +1,50 @@
1-
"""
2-
OpenAPI v3.0.3 schema types, created according to the specification:
3-
https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.3.md
4-
5-
The type orders are according to the contents of the specification:
6-
https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.3.md#table-of-contents
7-
"""
8-
91
__all__ = [
10-
"Components",
11-
"Contact",
12-
"Discriminator",
13-
"Encoding",
14-
"Example",
15-
"ExternalDocumentation",
16-
"Header",
17-
"Info",
18-
"License",
19-
"Link",
202
"MediaType",
21-
"OAuthFlow",
22-
"OAuthFlows",
233
"OpenAPI",
244
"Operation",
255
"Parameter",
266
"PathItem",
27-
"Paths",
287
"Reference",
298
"RequestBody",
309
"Response",
3110
"Responses",
3211
"Schema",
33-
"SecurityRequirement",
34-
"SecurityScheme",
35-
"Server",
36-
"ServerVariable",
37-
"Tag",
38-
"XML",
3912
]
4013

41-
from .components import Components
42-
from .contact import Contact
43-
from .discriminator import Discriminator
44-
from .encoding import Encoding
45-
from .example import Example
46-
from .external_documentation import ExternalDocumentation
47-
from .header import Header
48-
from .info import Info
49-
from .license import License
50-
from .link import Link
51-
from .media_type import MediaType
52-
from .oauth_flow import OAuthFlow
53-
from .oauth_flows import OAuthFlows
54-
from .open_api import OpenAPI
55-
from .operation import Operation
56-
from .parameter import Parameter
57-
from .path_item import PathItem
58-
from .paths import Paths
59-
from .reference import Reference
60-
from .request_body import RequestBody
61-
from .response import Response
62-
from .responses import Responses
63-
from .schema import Schema
64-
from .security_requirement import SecurityRequirement
65-
from .security_scheme import SecurityScheme
66-
from .server import Server
67-
from .server_variable import ServerVariable
68-
from .tag import Tag
69-
from .xml import XML
14+
15+
import re
16+
from typing import Callable, Iterator
17+
18+
from .openapi_schema_pydantic import MediaType
19+
from .openapi_schema_pydantic import OpenAPI as _OpenAPI
20+
from .openapi_schema_pydantic import Operation, Parameter, PathItem, Reference, RequestBody, Response, Responses, Schema
21+
22+
regex = re.compile(r"(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)")
23+
24+
25+
class SemVer:
26+
def __init__(self, str_value: str) -> None:
27+
self.str_value = str_value
28+
if not isinstance(str_value, str):
29+
raise TypeError("string required")
30+
m = regex.fullmatch(str_value)
31+
if not m:
32+
raise ValueError("invalid semantic versioning format")
33+
self.major = int(m.group(1))
34+
self.minor = int(m.group(2))
35+
self.patch = int(m.group(3))
36+
37+
@classmethod
38+
def __get_validators__(cls) -> Iterator[Callable[[str], "SemVer"]]:
39+
yield cls.validate
40+
41+
@classmethod
42+
def validate(cls, v: str) -> "SemVer":
43+
return cls(v)
44+
45+
def __str__(self) -> str:
46+
return self.str_value
47+
48+
49+
class OpenAPI(_OpenAPI):
50+
openapi: SemVer

openapi_python_client/schema/README.md renamed to openapi_python_client/schema/openapi_schema_pydantic/README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# OpenAPI v3.0.3 schema classes
1+
Everything in this directory (including the rest of this file after this paragraph) is a vendored copy of [openapi-schem-pydantic](https://github.com/kuimono/openapi-schema-pydantic) and is licensed under the LICENSE file in this directory.
22

33
## Alias
44

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"""
2+
OpenAPI v3.0.3 schema types, created according to the specification:
3+
https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.3.md
4+
5+
The type orders are according to the contents of the specification:
6+
https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.3.md#table-of-contents
7+
"""
8+
9+
__all__ = [
10+
"Components",
11+
"Contact",
12+
"Discriminator",
13+
"Encoding",
14+
"Example",
15+
"ExternalDocumentation",
16+
"Header",
17+
"Info",
18+
"License",
19+
"Link",
20+
"MediaType",
21+
"OAuthFlow",
22+
"OAuthFlows",
23+
"OpenAPI",
24+
"Operation",
25+
"Parameter",
26+
"PathItem",
27+
"Paths",
28+
"Reference",
29+
"RequestBody",
30+
"Response",
31+
"Responses",
32+
"Schema",
33+
"SecurityRequirement",
34+
"SecurityScheme",
35+
"Server",
36+
"ServerVariable",
37+
"Tag",
38+
"XML",
39+
]
40+
41+
from .components import Components
42+
from .contact import Contact
43+
from .discriminator import Discriminator
44+
from .encoding import Encoding
45+
from .example import Example
46+
from .external_documentation import ExternalDocumentation
47+
from .header import Header
48+
from .info import Info
49+
from .license import License
50+
from .link import Link
51+
from .media_type import MediaType
52+
from .oauth_flow import OAuthFlow
53+
from .oauth_flows import OAuthFlows
54+
from .open_api import OpenAPI
55+
from .operation import Operation
56+
from .parameter import Parameter
57+
from .path_item import PathItem
58+
from .paths import Paths
59+
from .reference import Reference
60+
from .request_body import RequestBody
61+
from .response import Response
62+
from .responses import Responses
63+
from .schema import Schema
64+
from .security_requirement import SecurityRequirement
65+
from .security_scheme import SecurityScheme
66+
from .server import Server
67+
from .server_variable import ServerVariable
68+
from .tag import Tag
69+
from .xml import XML

openapi_python_client/schema/open_api.py renamed to openapi_python_client/schema/openapi_schema_pydantic/open_api.py

-8
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,6 @@
1414
class OpenAPI(BaseModel):
1515
"""This is the root document object of the OpenAPI document."""
1616

17-
openapi: str = "3.0.3"
18-
"""
19-
**REQUIRED**. This string MUST be the [semantic version number](https://semver.org/spec/v2.0.0.html)
20-
of the [OpenAPI Specification version](#versions) that the OpenAPI document uses.
21-
The `openapi` field SHOULD be used by tooling specifications and clients to interpret the OpenAPI document.
22-
This is *not* related to the API [`info.version`](#infoVersion) string.
23-
"""
24-
2517
info: Info
2618
"""
2719
**REQUIRED**. Provides metadata about the API. The metadata MAY be used by tooling as required.

tests/test_parser/test_openapi.py

+49-1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ def test_from_dict(self, mocker):
1414
EndpointCollection.from_data.return_value = (endpoints_collections_by_tag, schemas)
1515
OpenAPI = mocker.patch(f"{MODULE_NAME}.oai.OpenAPI")
1616
openapi = OpenAPI.parse_obj.return_value
17+
openapi.openapi = mocker.MagicMock(major=3)
1718

1819
in_dict = mocker.MagicMock()
1920

@@ -54,16 +55,63 @@ def test_from_dict_invalid_schema(self, mocker):
5455
assert generator_data == GeneratorError(
5556
header="Failed to parse OpenAPI document",
5657
detail=(
57-
"2 validation errors for OpenAPI\n"
58+
"3 validation errors for OpenAPI\n"
5859
"info\n"
5960
" field required (type=value_error.missing)\n"
6061
"paths\n"
62+
" field required (type=value_error.missing)\n"
63+
"openapi\n"
6164
" field required (type=value_error.missing)"
6265
),
6366
)
6467
Schemas.build.assert_not_called()
6568
Schemas.assert_not_called()
6669

70+
def test_swagger_document_invalid_schema(self, mocker):
71+
Schemas = mocker.patch(f"{MODULE_NAME}.Schemas")
72+
73+
in_dict = {"swagger": "2.0"}
74+
75+
from openapi_python_client.parser.openapi import GeneratorData
76+
77+
generator_data = GeneratorData.from_dict(in_dict)
78+
79+
assert generator_data == GeneratorError(
80+
header="Failed to parse OpenAPI document",
81+
detail=(
82+
"You may be trying to use a Swagger document; this is not supported by this project.\n\n"
83+
"3 validation errors for OpenAPI\n"
84+
"info\n"
85+
" field required (type=value_error.missing)\n"
86+
"paths\n"
87+
" field required (type=value_error.missing)\n"
88+
"openapi\n"
89+
" field required (type=value_error.missing)"
90+
),
91+
)
92+
Schemas.build.assert_not_called()
93+
Schemas.assert_not_called()
94+
95+
def test_from_dict_invalid_version(self, mocker):
96+
Schemas = mocker.patch(f"{MODULE_NAME}.Schemas")
97+
98+
OpenAPI = mocker.patch(f"{MODULE_NAME}.oai.OpenAPI")
99+
openapi = OpenAPI.parse_obj.return_value
100+
openapi.openapi = oai.SemVer("2.1.3")
101+
102+
in_dict = mocker.MagicMock()
103+
104+
from openapi_python_client.parser.openapi import GeneratorData
105+
106+
generator_data = GeneratorData.from_dict(in_dict)
107+
108+
assert generator_data == GeneratorError(
109+
header="openapi-python-client only supports OpenAPI 3.x",
110+
detail="The version of the provided document was 2.1.3",
111+
)
112+
Schemas.build.assert_not_called()
113+
Schemas.assert_not_called()
114+
67115

68116
class TestEndpoint:
69117
def test_parse_request_form_body(self, mocker):

tests/test_schema/test_open_api.py

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import pytest
2+
from pydantic import ValidationError
3+
4+
from openapi_python_client.schema import OpenAPI
5+
6+
7+
@pytest.mark.parametrize(
8+
"version, valid", [("abc", False), ("1", False), ("2.0", False), ("3.0.0", True), ("3.1.0-b.3", False), (1, False)]
9+
)
10+
def test_validate_version(version, valid):
11+
data = {"openapi": version, "info": {"title": "test", "version": ""}, "paths": {}}
12+
if valid:
13+
OpenAPI.parse_obj(data)
14+
else:
15+
with pytest.raises(ValidationError):
16+
OpenAPI.parse_obj(data)

0 commit comments

Comments
 (0)