Skip to content

feat(parser): Detect OpenAPI documents of incorrect versions (closes #281) #302

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jan 19, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Unreleased
## 0.8.0 - Unreleased

### Additions

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

### Changes

Expand Down
14 changes: 12 additions & 2 deletions openapi_python_client/parser/openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,12 +260,22 @@ class GeneratorData:
enums: Dict[str, EnumProperty]

@staticmethod
def from_dict(d: Dict[str, Dict[str, Any]]) -> Union["GeneratorData", GeneratorError]:
def from_dict(d: Dict[str, Any]) -> Union["GeneratorData", GeneratorError]:
""" Create an OpenAPI from dict """
try:
openapi = oai.OpenAPI.parse_obj(d)
except ValidationError as e:
return GeneratorError(header="Failed to parse OpenAPI document", detail=str(e))
detail = str(e)
if "swagger" in d:
detail = (
"You may be trying to use a Swagger document; this is not supported by this project.\n\n" + detail
)
Comment on lines +269 to +272
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not just check for this or the openapi field before attempting to parse the dict?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. I don't think it's illegal to have extra fields (e.g. swagger) so I don't want to disallow that if the spec is otherwise valid
  2. Validating the format of openapi seems like a task for Pydantic since the semver requirement is part of the OpenAPI spec.

return GeneratorError(header="Failed to parse OpenAPI document", detail=detail)
if openapi.openapi.major != 3:
return GeneratorError(
header="openapi-python-client only supports OpenAPI 3.x",
detail=f"The version of the provided document was {openapi.openapi}",
)
if openapi.components is None or openapi.components.schemas is None:
schemas = Schemas()
else:
Expand Down
93 changes: 37 additions & 56 deletions openapi_python_client/schema/__init__.py
Original file line number Diff line number Diff line change
@@ -1,69 +1,50 @@
"""
OpenAPI v3.0.3 schema types, created according to the specification:
https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.3.md

The type orders are according to the contents of the specification:
https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.3.md#table-of-contents
"""

__all__ = [
"Components",
"Contact",
"Discriminator",
"Encoding",
"Example",
"ExternalDocumentation",
"Header",
"Info",
"License",
"Link",
"MediaType",
"OAuthFlow",
"OAuthFlows",
"OpenAPI",
"Operation",
"Parameter",
"PathItem",
"Paths",
"Reference",
"RequestBody",
"Response",
"Responses",
"Schema",
"SecurityRequirement",
"SecurityScheme",
"Server",
"ServerVariable",
"Tag",
"XML",
]

from .components import Components
from .contact import Contact
from .discriminator import Discriminator
from .encoding import Encoding
from .example import Example
from .external_documentation import ExternalDocumentation
from .header import Header
from .info import Info
from .license import License
from .link import Link
from .media_type import MediaType
from .oauth_flow import OAuthFlow
from .oauth_flows import OAuthFlows
from .open_api import OpenAPI
from .operation import Operation
from .parameter import Parameter
from .path_item import PathItem
from .paths import Paths
from .reference import Reference
from .request_body import RequestBody
from .response import Response
from .responses import Responses
from .schema import Schema
from .security_requirement import SecurityRequirement
from .security_scheme import SecurityScheme
from .server import Server
from .server_variable import ServerVariable
from .tag import Tag
from .xml import XML

import re
from typing import Callable, Iterator

from .openapi_schema_pydantic import MediaType
from .openapi_schema_pydantic import OpenAPI as _OpenAPI
from .openapi_schema_pydantic import Operation, Parameter, PathItem, Reference, RequestBody, Response, Responses, Schema

regex = re.compile(r"(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)")


class SemVer:
def __init__(self, str_value: str) -> None:
self.str_value = str_value
if not isinstance(str_value, str):
raise TypeError("string required")
m = regex.fullmatch(str_value)
if not m:
raise ValueError("invalid semantic versioning format")
self.major = int(m.group(1))
self.minor = int(m.group(2))
self.patch = int(m.group(3))

@classmethod
def __get_validators__(cls) -> Iterator[Callable[[str], "SemVer"]]:
yield cls.validate

@classmethod
def validate(cls, v: str) -> "SemVer":
return cls(v)

def __str__(self) -> str:
return self.str_value


class OpenAPI(_OpenAPI):
openapi: SemVer
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# OpenAPI v3.0.3 schema classes
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.

## Alias

Expand Down
69 changes: 69 additions & 0 deletions openapi_python_client/schema/openapi_schema_pydantic/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"""
OpenAPI v3.0.3 schema types, created according to the specification:
https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.3.md

The type orders are according to the contents of the specification:
https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.3.md#table-of-contents
"""

__all__ = [
"Components",
"Contact",
"Discriminator",
"Encoding",
"Example",
"ExternalDocumentation",
"Header",
"Info",
"License",
"Link",
"MediaType",
"OAuthFlow",
"OAuthFlows",
"OpenAPI",
"Operation",
"Parameter",
"PathItem",
"Paths",
"Reference",
"RequestBody",
"Response",
"Responses",
"Schema",
"SecurityRequirement",
"SecurityScheme",
"Server",
"ServerVariable",
"Tag",
"XML",
]

from .components import Components
from .contact import Contact
from .discriminator import Discriminator
from .encoding import Encoding
from .example import Example
from .external_documentation import ExternalDocumentation
from .header import Header
from .info import Info
from .license import License
from .link import Link
from .media_type import MediaType
from .oauth_flow import OAuthFlow
from .oauth_flows import OAuthFlows
from .open_api import OpenAPI
from .operation import Operation
from .parameter import Parameter
from .path_item import PathItem
from .paths import Paths
from .reference import Reference
from .request_body import RequestBody
from .response import Response
from .responses import Responses
from .schema import Schema
from .security_requirement import SecurityRequirement
from .security_scheme import SecurityScheme
from .server import Server
from .server_variable import ServerVariable
from .tag import Tag
from .xml import XML
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,6 @@
class OpenAPI(BaseModel):
"""This is the root document object of the OpenAPI document."""

openapi: str = "3.0.3"
"""
**REQUIRED**. This string MUST be the [semantic version number](https://semver.org/spec/v2.0.0.html)
of the [OpenAPI Specification version](#versions) that the OpenAPI document uses.
The `openapi` field SHOULD be used by tooling specifications and clients to interpret the OpenAPI document.
This is *not* related to the API [`info.version`](#infoVersion) string.
"""

info: Info
"""
**REQUIRED**. Provides metadata about the API. The metadata MAY be used by tooling as required.
Expand Down
50 changes: 49 additions & 1 deletion tests/test_parser/test_openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ def test_from_dict(self, mocker):
EndpointCollection.from_data.return_value = (endpoints_collections_by_tag, schemas)
OpenAPI = mocker.patch(f"{MODULE_NAME}.oai.OpenAPI")
openapi = OpenAPI.parse_obj.return_value
openapi.openapi = mocker.MagicMock(major=3)

in_dict = mocker.MagicMock()

Expand Down Expand Up @@ -54,16 +55,63 @@ def test_from_dict_invalid_schema(self, mocker):
assert generator_data == GeneratorError(
header="Failed to parse OpenAPI document",
detail=(
"2 validation errors for OpenAPI\n"
"3 validation errors for OpenAPI\n"
"info\n"
" field required (type=value_error.missing)\n"
"paths\n"
" field required (type=value_error.missing)\n"
"openapi\n"
" field required (type=value_error.missing)"
),
)
Schemas.build.assert_not_called()
Schemas.assert_not_called()

def test_swagger_document_invalid_schema(self, mocker):
Schemas = mocker.patch(f"{MODULE_NAME}.Schemas")

in_dict = {"swagger": "2.0"}

from openapi_python_client.parser.openapi import GeneratorData

generator_data = GeneratorData.from_dict(in_dict)

assert generator_data == GeneratorError(
header="Failed to parse OpenAPI document",
detail=(
"You may be trying to use a Swagger document; this is not supported by this project.\n\n"
"3 validation errors for OpenAPI\n"
"info\n"
" field required (type=value_error.missing)\n"
"paths\n"
" field required (type=value_error.missing)\n"
"openapi\n"
" field required (type=value_error.missing)"
),
)
Schemas.build.assert_not_called()
Schemas.assert_not_called()

def test_from_dict_invalid_version(self, mocker):
Schemas = mocker.patch(f"{MODULE_NAME}.Schemas")

OpenAPI = mocker.patch(f"{MODULE_NAME}.oai.OpenAPI")
openapi = OpenAPI.parse_obj.return_value
openapi.openapi = oai.SemVer("2.1.3")

in_dict = mocker.MagicMock()

from openapi_python_client.parser.openapi import GeneratorData

generator_data = GeneratorData.from_dict(in_dict)

assert generator_data == GeneratorError(
header="openapi-python-client only supports OpenAPI 3.x",
detail="The version of the provided document was 2.1.3",
)
Schemas.build.assert_not_called()
Schemas.assert_not_called()


class TestEndpoint:
def test_parse_request_form_body(self, mocker):
Expand Down
16 changes: 16 additions & 0 deletions tests/test_schema/test_open_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import pytest
from pydantic import ValidationError

from openapi_python_client.schema import OpenAPI


@pytest.mark.parametrize(
"version, valid", [("abc", False), ("1", False), ("2.0", False), ("3.0.0", True), ("3.1.0-b.3", False), (1, False)]
)
def test_validate_version(version, valid):
data = {"openapi": version, "info": {"title": "test", "version": ""}, "paths": {}}
if valid:
OpenAPI.parse_obj(data)
else:
with pytest.raises(ValidationError):
OpenAPI.parse_obj(data)