From 638c3f9faaf3335146d2420a6bfba13dd4e0ebc9 Mon Sep 17 00:00:00 2001 From: Matias Cardenas Date: Tue, 12 Mar 2024 22:40:18 +0100 Subject: [PATCH 1/3] feat: introducing path prefix support for SchemaTester --- README.md | 2 +- openapi_tester/schema_tester.py | 16 +++++++++++++--- pyproject.toml | 4 ++-- tests/test_schema_tester.py | 14 ++++++++++++++ 4 files changed, 30 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 335f8865..68e1e085 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ # Django Contract Tester -This is a test utility to validate DRF (Django REST Framework) and Django Ninja test requests & responses against OpenAPI 2 and 3 schema. +This is a test utility to validate `DRF (Django REST Framework)` and `Django Ninja` test requests & responses against `OpenAPI` versions 2.x and 3.x schemas. It has built-in support for: diff --git a/openapi_tester/schema_tester.py b/openapi_tester/schema_tester.py index c93fea95..dab4b89a 100644 --- a/openapi_tester/schema_tester.py +++ b/openapi_tester/schema_tester.py @@ -68,6 +68,7 @@ def __init__( schema_file_path: str | None = None, validators: list[Callable[[dict, Any], str | None]] | None = None, field_key_map: dict[str, str] | None = None, + path_prefix: str | None = None, ) -> None: """ Iterates through an OpenAPI schema object and API response to check that they match at every level. @@ -75,9 +76,11 @@ def __init__( :param case_tester: An optional callable that validates schema and response keys :param ignore_case: An optional list of keys for the case_tester to ignore :schema_file_path: The file path to an OpenAPI yaml or json file. Only passed when using a static schema loader + :param path_prefix: An optional string to prefix the path of the schema file :raises: openapi_tester.exceptions.DocumentationError or ImproperlyConfigured """ self.case_tester = case_tester + self._path_prefix = path_prefix self.ignore_case = ignore_case or [] self.validators = validators or [] @@ -132,6 +135,14 @@ def get_schema_type(schema: dict[str, str]) -> str | None: return "object" return None + def get_paths_object(self) -> dict[str, Any]: + schema = self.loader.get_schema() + paths_object = self.get_key_value(schema, "paths") + if self._path_prefix: + paths_object = {f"{self._path_prefix}{key}": value for key, value in paths_object.items()} + + return paths_object + def get_response_schema_section(self, response_handler: ResponseHandler) -> dict[str, Any]: """ Fetches the response section of a schema, wrt. the route, method, status code, and schema version. @@ -146,7 +157,7 @@ def get_response_schema_section(self, response_handler: ResponseHandler) -> dict parameterized_path, _ = self.loader.resolve_path( response.request["PATH_INFO"], method=response_method # type: ignore ) - paths_object = self.get_key_value(schema, "paths") + paths_object = self.get_paths_object() route_object = self.get_key_value( paths_object, @@ -212,11 +223,10 @@ def get_request_body_schema_section(self, request: dict[str, Any]) -> dict[str, :param response: DRF Request Instance :return dict """ - schema = self.loader.get_schema() request_method = request["REQUEST_METHOD"].lower() parameterized_path, _ = self.loader.resolve_path(request["PATH_INFO"], method=request_method) - paths_object = self.get_key_value(schema, "paths") + paths_object = self.get_paths_object() route_object = self.get_key_value( paths_object, diff --git a/pyproject.toml b/pyproject.toml index 7850a7fe..76cc158b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "django-contract-tester" -version = "1.2.0" +version = "1.3.0" description = "Test utility for validating OpenAPI response documentation" authors =["Matías Cárdenas ", "Sondre Lillebø Gundersen ", "Na'aman Hirschfeld "] license = "BSD-4-Clause" @@ -18,7 +18,6 @@ classifiers = [ "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", @@ -98,6 +97,7 @@ disable = """ import-outside-toplevel, fixme, line-too-long, + too-many-arguments, """ enable = "useless-suppression" diff --git a/tests/test_schema_tester.py b/tests/test_schema_tester.py index 66bf454c..2d5fbf71 100644 --- a/tests/test_schema_tester.py +++ b/tests/test_schema_tester.py @@ -558,3 +558,17 @@ def uuid_1_validator(schema_section: dict, data: Any) -> str | None: # pragma: tester_with_custom_validator.test_schema_section( uid1_schema, uid4, test_config=OpenAPITestConfig(validators=[uuid_1_validator]) ) + + +def test_get_paths_object(): + schema = tester.loader.get_schema() + paths = tester.get_paths_object() + assert paths == schema["paths"] + + +def test_get_paths_object_path_prefix(pets_api_schema: Path): + path_prefix = "/path/prefix" + schema_tester = SchemaTester(schema_file_path=str(pets_api_schema), path_prefix=path_prefix) + paths_object = schema_tester.get_paths_object() + + assert list(paths_object.keys()) == [f"{path_prefix}/api/pets", f"{path_prefix}/api/pets/{{id}}"] From 2ba91b5ec2f00cfe9af9a7d3d52666d601c9838d Mon Sep 17 00:00:00 2001 From: Matias Cardenas Date: Thu, 14 Mar 2024 17:36:54 +0100 Subject: [PATCH 2/3] feat: adding tests --- openapi_tester/__init__.py | 1 + openapi_tester/case_testers.py | 1 + openapi_tester/clients.py | 1 + openapi_tester/constants.py | 1 + openapi_tester/loaders.py | 1 + openapi_tester/response_handler.py | 1 + openapi_tester/schema_tester.py | 1 + openapi_tester/utils.py | 1 + openapi_tester/validators.py | 1 + tests/conftest.py | 5 + tests/schema_converter.py | 1 + .../schemas/openapi_v3_prefix_in_server.yaml | 170 ++++++++++++++++++ tests/test_schema_tester.py | 47 ++++- 13 files changed, 228 insertions(+), 4 deletions(-) create mode 100644 tests/schemas/openapi_v3_prefix_in_server.yaml diff --git a/openapi_tester/__init__.py b/openapi_tester/__init__.py index ae84d2c3..187133ce 100644 --- a/openapi_tester/__init__.py +++ b/openapi_tester/__init__.py @@ -1,4 +1,5 @@ """ Django OpenAPI Schema Tester """ + from .case_testers import is_camel_case, is_kebab_case, is_pascal_case, is_snake_case from .clients import OpenAPIClient from .loaders import BaseSchemaLoader, DrfSpectacularSchemaLoader, DrfYasgSchemaLoader, StaticSchemaLoader diff --git a/openapi_tester/case_testers.py b/openapi_tester/case_testers.py index 097c9471..f13eeccc 100644 --- a/openapi_tester/case_testers.py +++ b/openapi_tester/case_testers.py @@ -1,4 +1,5 @@ """ Case testers - this module includes helper functions to test key casing """ + from __future__ import annotations from typing import TYPE_CHECKING diff --git a/openapi_tester/clients.py b/openapi_tester/clients.py index 2727f13c..61c60f6b 100644 --- a/openapi_tester/clients.py +++ b/openapi_tester/clients.py @@ -1,4 +1,5 @@ """Subclass of ``APIClient`` using ``SchemaTester`` to validate responses.""" + from __future__ import annotations from typing import TYPE_CHECKING diff --git a/openapi_tester/constants.py b/openapi_tester/constants.py index c6419e14..ce4808b4 100644 --- a/openapi_tester/constants.py +++ b/openapi_tester/constants.py @@ -1,4 +1,5 @@ """ Constants module """ + OPENAPI_PYTHON_MAPPING = { "boolean": bool.__name__, "string": str.__name__, diff --git a/openapi_tester/loaders.py b/openapi_tester/loaders.py index b1e8f115..df718ff6 100644 --- a/openapi_tester/loaders.py +++ b/openapi_tester/loaders.py @@ -1,4 +1,5 @@ """ Loaders Module """ + from __future__ import annotations import difflib diff --git a/openapi_tester/response_handler.py b/openapi_tester/response_handler.py index ba0342db..9fdc0796 100644 --- a/openapi_tester/response_handler.py +++ b/openapi_tester/response_handler.py @@ -1,6 +1,7 @@ """ This module contains the concrete response handlers for both DRF and Django Ninja responses. """ + import json from typing import TYPE_CHECKING, Optional, Union diff --git a/openapi_tester/schema_tester.py b/openapi_tester/schema_tester.py index dab4b89a..b54a0e32 100644 --- a/openapi_tester/schema_tester.py +++ b/openapi_tester/schema_tester.py @@ -1,4 +1,5 @@ """ Schema Tester """ + from __future__ import annotations import re diff --git a/openapi_tester/utils.py b/openapi_tester/utils.py index e07c7517..52f5b619 100644 --- a/openapi_tester/utils.py +++ b/openapi_tester/utils.py @@ -1,6 +1,7 @@ """ Utils Module - this file contains utility functions used in multiple places. """ + from __future__ import annotations from copy import deepcopy diff --git a/openapi_tester/validators.py b/openapi_tester/validators.py index 4e0af329..c0093308 100644 --- a/openapi_tester/validators.py +++ b/openapi_tester/validators.py @@ -1,4 +1,5 @@ """ Schema Validators """ + from __future__ import annotations import base64 diff --git a/tests/conftest.py b/tests/conftest.py index bb8c32ef..d811c055 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,6 +19,11 @@ def pets_api_schema() -> Path: return TEST_ROOT / "schemas" / "openapi_v3_reference_schema.yaml" +@pytest.fixture() +def pets_api_schema_prefix_in_server() -> Path: + return TEST_ROOT / "schemas" / "openapi_v3_prefix_in_server.yaml" + + @pytest.fixture() def pets_post_request(): request_body = MagicMock() diff --git a/tests/schema_converter.py b/tests/schema_converter.py index 5f07c4b9..58842884 100644 --- a/tests/schema_converter.py +++ b/tests/schema_converter.py @@ -1,4 +1,5 @@ """ Schema to Python converter """ + from __future__ import annotations import base64 diff --git a/tests/schemas/openapi_v3_prefix_in_server.yaml b/tests/schemas/openapi_v3_prefix_in_server.yaml new file mode 100644 index 00000000..4a86ceb0 --- /dev/null +++ b/tests/schemas/openapi_v3_prefix_in_server.yaml @@ -0,0 +1,170 @@ +openapi: "3.0.0" +info: + version: 1.0.0 + title: Swagger Petstore + description: A sample API that uses a petstore as an example to demonstrate features in the OpenAPI 3.0 specification + termsOfService: http://swagger.io/terms/ + contact: + name: Swagger API Team + email: apiteam@swagger.io + url: http://swagger.io + license: + name: Apache 2.0 + url: https://www.apache.org/licenses/LICENSE-2.0.html +servers: + - url: http://petstore.swagger.io/api +paths: + /pets: + get: + description: | + Returns all pets from the system that the user has access to + Nam sed condimentum est. Maecenas tempor sagittis sapien, nec rhoncus sem sagittis sit amet. Aenean at gravida augue, ac iaculis sem. Curabitur odio lorem, ornare eget elementum nec, cursus id lectus. Duis mi turpis, pulvinar ac eros ac, tincidunt varius justo. In hac habitasse platea dictumst. Integer at adipiscing ante, a sagittis ligula. Aenean pharetra tempor ante molestie imperdiet. Vivamus id aliquam diam. Cras quis velit non tortor eleifend sagittis. Praesent at enim pharetra urna volutpat venenatis eget eget mauris. In eleifend fermentum facilisis. Praesent enim enim, gravida ac sodales sed, placerat id erat. Suspendisse lacus dolor, consectetur non augue vel, vehicula interdum libero. Morbi euismod sagittis libero sed lacinia. + + Sed tempus felis lobortis leo pulvinar rutrum. Nam mattis velit nisl, eu condimentum ligula luctus nec. Phasellus semper velit eget aliquet faucibus. In a mattis elit. Phasellus vel urna viverra, condimentum lorem id, rhoncus nibh. Ut pellentesque posuere elementum. Sed a varius odio. Morbi rhoncus ligula libero, vel eleifend nunc tristique vitae. Fusce et sem dui. Aenean nec scelerisque tortor. Fusce malesuada accumsan magna vel tempus. Quisque mollis felis eu dolor tristique, sit amet auctor felis gravida. Sed libero lorem, molestie sed nisl in, accumsan tempor nisi. Fusce sollicitudin massa ut lacinia mattis. Sed vel eleifend lorem. Pellentesque vitae felis pretium, pulvinar elit eu, euismod sapien. + operationId: findPets + parameters: + - name: tags + in: query + description: tags to filter by + required: false + style: form + schema: + type: array + items: + type: string + - name: limit + in: query + description: maximum number of results to return + required: false + schema: + type: integer + format: int32 + responses: + '200': + description: pet response + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + default: + description: unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + post: + description: Creates a new pet in the store. Duplicates are allowed + operationId: addPet + requestBody: + description: Pet to add to the store + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NewPet' + responses: + '201': + description: pet response + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + '400': + description: Bad Request + content: + application/json: + schema: + type: object + properties: + error: + type: string + message: + type: string + additionalProperties: true + default: + description: unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /pets/{id}: + get: + description: Returns a user based on a single ID, if the user does not have access to the pet + operationId: find pet by id + parameters: + - name: id + in: path + description: ID of pet to fetch + required: true + schema: + type: integer + format: int64 + responses: + '200': + description: pet response + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + default: + description: unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + delete: + description: deletes a single pet based on the ID supplied + operationId: deletePet + parameters: + - name: id + in: path + description: ID of pet to delete + required: true + schema: + type: integer + format: int64 + responses: + '204': + description: pet deleted + default: + description: unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' +components: + schemas: + Pet: + allOf: + - $ref: '#/components/schemas/NewPet' + - type: object + required: + - id + properties: + id: + type: integer + format: int64 + + NewPet: + type: object + required: + - name + properties: + name: + type: string + tag: + type: string + + Error: + type: object + required: + - code + - message + properties: + code: + type: integer + format: int32 + message: + type: string diff --git a/tests/test_schema_tester.py b/tests/test_schema_tester.py index 2d5fbf71..a052b82b 100644 --- a/tests/test_schema_tester.py +++ b/tests/test_schema_tester.py @@ -271,6 +271,38 @@ def test_validate_request_no_application_json( schema_tester.validate_request(response) +def test_validate_request_schema_with_prefix_in_server( + response_factory, pets_api_schema_prefix_in_server: Path, pets_post_request: dict[str, Any] +): + schema_tester = SchemaTester(schema_file_path=str(pets_api_schema_prefix_in_server)) + response = response_factory( + schema=None, + url_fragment="/api/pets", + method="POST", + status_code=201, + response_body={"name": "Doggie"}, + ) + + with pytest.raises(UndocumentedSchemaSectionError) as error: + schema_tester.validate_request(response) + + assert "Undocumented route /api/pets" in str(error.value) + + +def test_validate_request_schema_with_prefix_in_server_path_prefix( + response_factory, pets_api_schema_prefix_in_server: Path, pets_post_request: dict[str, Any] +): + schema_tester = SchemaTester(schema_file_path=str(pets_api_schema_prefix_in_server), path_prefix="/api") + response = response_factory( + schema=None, + url_fragment="/api/pets", + method="POST", + status_code=201, + response_body={"name": "Doggie"}, + ) + schema_tester.validate_request(response) + + def test_is_openapi_schema(pets_api_schema: Path): schema_tester = SchemaTester(schema_file_path=str(pets_api_schema)) assert schema_tester.is_openapi_schema() is True @@ -566,9 +598,16 @@ def test_get_paths_object(): assert paths == schema["paths"] -def test_get_paths_object_path_prefix(pets_api_schema: Path): - path_prefix = "/path/prefix" - schema_tester = SchemaTester(schema_file_path=str(pets_api_schema), path_prefix=path_prefix) +def test_get_paths_object_no_path_prefix(pets_api_schema: Path): + schema_tester = SchemaTester(schema_file_path=str(pets_api_schema)) + paths_object = schema_tester.get_paths_object() + + assert list(paths_object.keys()) == ["/api/pets", "/api/pets/{id}"] + + +def test_get_paths_object_path_prefix(pets_api_schema_prefix_in_server: Path): + path_prefix = "/api" + schema_tester = SchemaTester(schema_file_path=str(pets_api_schema_prefix_in_server), path_prefix=path_prefix) paths_object = schema_tester.get_paths_object() - assert list(paths_object.keys()) == [f"{path_prefix}/api/pets", f"{path_prefix}/api/pets/{{id}}"] + assert list(paths_object.keys()) == ["/api/pets", "/api/pets/{id}"] From 99694a971244236382fe0de4b47f444eea784126 Mon Sep 17 00:00:00 2001 From: Matias Cardenas Date: Thu, 14 Mar 2024 17:41:55 +0100 Subject: [PATCH 3/3] chore: adding django ninja endpoint for testing undocumented path --- test_project/api/ninja/api.py | 5 +++++ tests/test_django_ninja.py | 13 +++++++++++++ 2 files changed, 18 insertions(+) diff --git a/test_project/api/ninja/api.py b/test_project/api/ninja/api.py index 0d5110ec..723eda3e 100644 --- a/test_project/api/ninja/api.py +++ b/test_project/api/ninja/api.py @@ -43,4 +43,9 @@ def get_users(request): ] +@router.patch("/{user_id}", response={200: UserOut}) +def patch_user(request, user_id: int, user: UserIn): + return {"id": 1, "name": "John Doe", "email": "john.doe@example.com"} + + ninja_api.add_router("/users", router) diff --git a/tests/test_django_ninja.py b/tests/test_django_ninja.py index 48711eda..a7260c19 100644 --- a/tests/test_django_ninja.py +++ b/tests/test_django_ninja.py @@ -4,6 +4,7 @@ import pytest from openapi_tester import OpenAPIClient, SchemaTester +from openapi_tester.exceptions import UndocumentedSchemaSectionError from tests.utils import TEST_ROOT if TYPE_CHECKING: @@ -65,3 +66,15 @@ def test_delete_user(client: OpenAPIClient): path="/ninja_api/users/1", ) assert response.status_code == 204 + + +def test_patch_user_undocumented_path(client: OpenAPIClient): + payload = { + "name": "John Doe", + } + with pytest.raises(UndocumentedSchemaSectionError): + client.patch( + path="/ninja_api/users/1", + data=json.dumps(payload), + content_type="application/json", + )