diff --git a/openapi_spec_validator/handlers/compat.py b/openapi_spec_validator/handlers/compat.py index 534e746..9451a7d 100644 --- a/openapi_spec_validator/handlers/compat.py +++ b/openapi_spec_validator/handlers/compat.py @@ -4,5 +4,36 @@ except ImportError: from yaml import SafeLoader +from jsonschema.exceptions import ValidationError -__all__ = ['SafeLoader', ] + +class UniqueSchemasLoader(SafeLoader): + """Loader that checks that schemas definitions are unique""" + + POSSIBLE_SCHEMAS_YAML_PATHS = ["definitions", "components.schemas"] + + def construct_mapping(self, node, deep=True): + self._check_for_duplicate_schemas_definitions(node, deep, self.POSSIBLE_SCHEMAS_YAML_PATHS) + return super().construct_mapping(node, deep) + + def _check_for_duplicate_schemas_definitions(self, node, deep, possible_schemas_yaml_paths): + for schemas_yaml_path in possible_schemas_yaml_paths: + keys = [] + for key_node, value_node in node.value: + if schemas_yaml_path: + if key_node.value == schemas_yaml_path.split(".")[0]: + return self._check_for_duplicate_schemas_definitions( + value_node, + deep, + possible_schemas_yaml_paths=[ + ".".join(schemas_yaml_path.split(".")[1:]) + ], + ) + else: + key = self.construct_object(key_node, deep=deep) + if key in keys: + raise ValidationError(f"Duplicate definition for {key} schema.") + keys.append(key) + + +__all__ = ["SafeLoader", "UniqueSchemasLoader"] diff --git a/openapi_spec_validator/handlers/file.py b/openapi_spec_validator/handlers/file.py index 7c3ea65..f46a4d0 100644 --- a/openapi_spec_validator/handlers/file.py +++ b/openapi_spec_validator/handlers/file.py @@ -5,14 +5,14 @@ from yaml import load from openapi_spec_validator.handlers.base import BaseHandler -from openapi_spec_validator.handlers.compat import SafeLoader +from openapi_spec_validator.handlers.compat import UniqueSchemasLoader from openapi_spec_validator.handlers.utils import uri_to_path class FileObjectHandler(BaseHandler): """OpenAPI spec validator file-like object handler.""" - def __init__(self, loader=SafeLoader): + def __init__(self, loader=UniqueSchemasLoader): self.loader = loader def __call__(self, f): diff --git a/tests/integration/data/v2.0/petstore_duplicate_schemas_def.yaml b/tests/integration/data/v2.0/petstore_duplicate_schemas_def.yaml new file mode 100644 index 0000000..4206c54 --- /dev/null +++ b/tests/integration/data/v2.0/petstore_duplicate_schemas_def.yaml @@ -0,0 +1,113 @@ +swagger: "2.0" +info: + version: 1.0.0 + title: Swagger Petstore + license: + name: MIT +host: petstore.swagger.io +basePath: /v1 +schemes: + - http +consumes: + - application/json +produces: + - application/json +paths: + /pets: + get: + summary: List all pets + operationId: listPets + tags: + - pets + parameters: + - name: limit + in: query + description: How many items to return at one time (max 100) + required: false + type: integer + format: int32 + responses: + 200: + description: A paged array of pets + headers: + x-next: + type: string + description: A link to the next page of responses + schema: + $ref: '#/definitions/Pets' + default: + description: unexpected error + schema: + $ref: '#/definitions/Error' + post: + summary: Create a pet + operationId: createPets + tags: + - pets + responses: + '201': + description: Null response + default: + description: unexpected error + schema: + $ref: '#/definitions/Error' + /pets/{petId}: + get: + summary: Info for a specific pet + operationId: showPetById + tags: + - pets + parameters: + - name: petId + in: path + required: true + description: The id of the pet to retrieve + type: string + responses: + '200': + description: Expected response to a valid request + schema: + $ref: '#/definitions/Pets' + default: + description: unexpected error + schema: + $ref: '#/definitions/Error' +definitions: + Pet: + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + tag: + type: string + Pet: + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + tag: + type: string + Pets: + type: array + items: + $ref: '#/definitions/Pet' + Error: + required: + - code + - message + properties: + code: + type: integer + format: int32 + message: + type: string diff --git a/tests/integration/data/v3.0/petstore_duplicate_schemas_def.yaml b/tests/integration/data/v3.0/petstore_duplicate_schemas_def.yaml new file mode 100644 index 0000000..4452bf3 --- /dev/null +++ b/tests/integration/data/v3.0/petstore_duplicate_schemas_def.yaml @@ -0,0 +1,115 @@ +openapi: "3.0.0" +info: + version: 1.0.0 + title: Swagger Petstore + license: + name: MIT +servers: + - url: http://petstore.swagger.io/v1 +paths: + /pets: + get: + summary: List all pets + operationId: listPets + tags: + - pets + parameters: + - name: limit + in: query + description: How many items to return at one time (max 100) + required: false + schema: + type: integer + format: int32 + responses: + 200: + description: An paged array of pets + headers: + x-next: + description: A link to the next page of responses + schema: + type: string + content: + application/json: + schema: + $ref: "#/components/schemas/Pets" + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + post: + summary: Create a pet + operationId: createPets + tags: + - pets + responses: + '201': + description: Null response + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /pets/{petId}: + get: + summary: Info for a specific pet + operationId: showPetById + tags: + - pets + parameters: + - name: petId + in: path + required: true + description: The id of the pet to retrieve + schema: + type: string + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + $ref: "#/components/schemas/Pets" + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" +components: + schemas: + Pet: + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + tag: + type: string + $ref: + type: string + Pets: + type: array + items: + $ref: "#/components/schemas/Pet" + Pets: + type: array + items: + $ref: "#/components/schemas/Pet" + Error: + required: + - code + - message + properties: + code: + type: integer + format: int32 + message: + type: string diff --git a/tests/integration/data/v3.1/petstore_duplicate_schemas_def.yaml b/tests/integration/data/v3.1/petstore_duplicate_schemas_def.yaml new file mode 100644 index 0000000..2fe2ff8 --- /dev/null +++ b/tests/integration/data/v3.1/petstore_duplicate_schemas_def.yaml @@ -0,0 +1,122 @@ +openapi: "3.1.0" +info: + version: 1.0.0 + title: Swagger Petstore + license: + name: MIT License + identifier: MIT +servers: + - url: http://petstore.swagger.io/v1 +paths: + /pets: + get: + summary: List all pets + operationId: listPets + tags: + - pets + parameters: + - name: limit + in: query + description: How many items to return at one time (max 100) + required: false + schema: + type: integer + format: int32 + responses: + 200: + description: An paged array of pets + headers: + x-next: + description: A link to the next page of responses + schema: + type: string + content: + application/json: + schema: + $ref: "#/components/schemas/Pets" + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + post: + summary: Create a pet + operationId: createPets + tags: + - pets + responses: + '201': + description: Null response + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /pets/{petId}: + get: + summary: Info for a specific pet + operationId: showPetById + tags: + - pets + parameters: + - name: petId + in: path + required: true + description: The id of the pet to retrieve + schema: + type: string + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + $ref: "#/components/schemas/Pets" + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" +components: + schemas: + Pet: + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + tag: + type: string + $ref: + type: string + Pets: + type: array + items: + $ref: "#/components/schemas/Pet" + Error: + required: + - code + - message + properties: + code: + type: integer + format: int32 + message: + type: string + Error: + required: + - code + - message + properties: + code: + type: integer + format: int32 + message: + type: string diff --git a/tests/integration/test_main.py b/tests/integration/test_main.py index a99f4f7..15236e2 100644 --- a/tests/integration/test_main.py +++ b/tests/integration/test_main.py @@ -1,51 +1,45 @@ -import pytest from io import StringIO +from unittest import mock -from openapi_spec_validator.__main__ import main +import pytest -from unittest import mock +from openapi_spec_validator.__main__ import main def test_schema_default(): """Test default schema is 3.1.0""" - testargs = ['./tests/integration/data/v3.1/petstore.yaml'] + testargs = ["./tests/integration/data/v3.1/petstore.yaml"] main(testargs) def test_schema_v31(): """No errors when calling proper v3.1 file.""" - testargs = ['--schema', '3.1.0', - './tests/integration/data/v3.1/petstore.yaml'] + testargs = ["--schema", "3.1.0", "./tests/integration/data/v3.1/petstore.yaml"] main(testargs) def test_schema_v30(): """No errors when calling proper v3.0 file.""" - testargs = ['--schema', '3.0.0', - './tests/integration/data/v3.0/petstore.yaml'] + testargs = ["--schema", "3.0.0", "./tests/integration/data/v3.0/petstore.yaml"] main(testargs) def test_schema_v2(): """No errors when calling with proper v2 file.""" - testargs = ['--schema', '2.0', - './tests/integration/data/v2.0/petstore.yaml'] + testargs = ["--schema", "2.0", "./tests/integration/data/v2.0/petstore.yaml"] main(testargs) def test_errors_on_missing_description_best(capsys): """An error is obviously printed given an empty schema.""" - testargs = [ - './tests/integration/data/v3.0/missing-description.yaml', - '--schema=3.0.0' - ] + testargs = ["./tests/integration/data/v3.0/missing-description.yaml", "--schema=3.0.0"] with pytest.raises(SystemExit): main(testargs) out, err = capsys.readouterr() assert "Failed validating" in out assert "'description' is a required property" in out assert "'$ref' is a required property" not in out - assert '1 more subschemas errors' in out + assert "1 more subschemas errors" in out def test_errors_on_missing_description_full(capsys): @@ -61,51 +55,63 @@ def test_errors_on_missing_description_full(capsys): assert "Failed validating" in out assert "'description' is a required property" in out assert "'$ref' is a required property" in out - assert '1 more subschema error' not in out + assert "1 more subschema error" not in out def test_schema_unknown(): """Errors on running with unknown schema.""" - testargs = ['--schema', 'x.x', - './tests/integration/data/v2.0/petstore.yaml'] + testargs = ["--schema", "x.x", "./tests/integration/data/v2.0/petstore.yaml"] with pytest.raises(SystemExit): main(testargs) def test_validation_error(): """SystemExit on running with ValidationError.""" - testargs = ['--schema', '3.0.0', - './tests/integration/data/v2.0/petstore.yaml'] + testargs = ["--schema", "3.0.0", "./tests/integration/data/v2.0/petstore.yaml"] with pytest.raises(SystemExit): main(testargs) @mock.patch( - 'openapi_spec_validator.__main__.openapi_v30_spec_validator.validate', + "openapi_spec_validator.__main__.openapi_v30_spec_validator.validate", side_effect=Exception, ) def test_unknown_error(m_validate): """SystemExit on running with unknown error.""" - testargs = ['--schema', '3.0.0', - './tests/integration/data/v2.0/petstore.yaml'] + testargs = ["--schema", "3.0.0", "./tests/integration/data/v2.0/petstore.yaml"] with pytest.raises(SystemExit): main(testargs) def test_nonexisting_file(): """Calling with non-existing file should sys.exit.""" - testargs = ['i_dont_exist.yaml'] + testargs = ["i_dont_exist.yaml"] with pytest.raises(SystemExit): main(testargs) def test_schema_stdin(): """Test schema from STDIN""" - spes_path = './tests/integration/data/v3.0/petstore.yaml' - with open(spes_path, 'r') as spec_file: + spes_path = "./tests/integration/data/v3.0/petstore.yaml" + with open(spes_path, "r") as spec_file: spec_lines = spec_file.readlines() spec_io = StringIO("".join(spec_lines)) - testargs = ['--schema', '3.0.0', '-'] - with mock.patch('openapi_spec_validator.__main__.sys.stdin', spec_io): + testargs = ["--schema", "3.0.0", "-"] + with mock.patch("openapi_spec_validator.__main__.sys.stdin", spec_io): + main(testargs) + + +@pytest.mark.parametrize( + "version,spec_path", + [ + ("3.1.0", "./tests/integration/data/v3.1/petstore_duplicate_schemas_def.yaml"), + ("3.0.0", "./tests/integration/data/v3.0/petstore_duplicate_schemas_def.yaml"), + ("2.0", "./tests/integration/data/v2.0/petstore_duplicate_schemas_def.yaml"), + ], +) +def test_duplicate_schemas_definition(version, spec_path): + """Test exception when Swagger has duplicate schema definitions""" + testargs = ["--schema", version, spec_path] + with pytest.raises(SystemExit): main(testargs)