diff --git a/README.md b/README.md index 68e1e085..ff7b0124 100644 --- a/README.md +++ b/README.md @@ -256,6 +256,30 @@ class MySimpleTestCase(SimpleTestCase): This will ensure you all newly implemented views will be validated against the OpenAPI schema. + +### Django Ninja Test Client + +In case you are using `Django Ninja` and its corresponding [test client](https://github.com/vitalik/django-ninja/blob/master/ninja/testing/client.py#L159), you can use the `OpenAPINinjaClient`, which extends from it, in the same way as the `OpenAPIClient`: + +```python +schema_tester = SchemaTester() +client = OpenAPINinjaClient( + router_or_app=router, + schema_tester=schema_tester, + ) +response = client.get('/api/v1/tests/123/') +``` + +Given that the Django Ninja test client works separately from the django url resolver, you can pass the `path_prefix` argument to the `OpenAPINinjaClient` to specify the prefix of the path that should be used to look into the OpenAPI schema. + +```python +client = OpenAPINinjaClient( + router_or_app=router, + path_prefix='/api/v1', + schema_tester=schema_tester, + ) +``` + ## Known Issues * We are using [prance](https://github.com/jfinkhaeuser/prance) as a schema resolver, and it has some issues with the diff --git a/openapi_tester/clients.py b/openapi_tester/clients.py index a830fc56..e84e10d5 100644 --- a/openapi_tester/clients.py +++ b/openapi_tester/clients.py @@ -2,12 +2,17 @@ from __future__ import annotations -import json +import http from typing import TYPE_CHECKING +# pylint: disable=import-error +from ninja import NinjaAPI, Router +from ninja.testing import TestClient from rest_framework.test import APIClient +from .response_handler_factory import ResponseHandlerFactory from .schema_tester import SchemaTester +from .utils import serialize_json if TYPE_CHECKING: from rest_framework.response import Response @@ -26,132 +31,125 @@ def __init__( super().__init__(*args, **kwargs) self.schema_tester = schema_tester or self._schema_tester_factory() - def request(self, **kwargs) -> Response: # type: ignore[override] + def request(self, *args, **kwargs) -> Response: # type: ignore[override] """Validate fetched response against given OpenAPI schema.""" response = super().request(**kwargs) + response_handler = ResponseHandlerFactory.create( + *args, response=response, **kwargs + ) if self._is_successful_response(response): - self.schema_tester.validate_request(response) - self.schema_tester.validate_response(response) + self.schema_tester.validate_request(response_handler=response_handler) + self.schema_tester.validate_response(response_handler=response_handler) return response # pylint: disable=W0622 + @serialize_json def post( self, - path, - data=None, - format=None, + *args, content_type="application/json", - follow=False, - **extra, + **kwargs, ): - if data and content_type == "application/json": - data = self._serialize(data) return super().post( - path, - data=data, - format=format, + *args, content_type=content_type, - follow=follow, - **extra, + **kwargs, ) # pylint: disable=W0622 + @serialize_json def put( self, - path, - data=None, - format=None, + *args, content_type="application/json", - follow=False, - **extra, + **kwargs, ): - if data and content_type == "application/json": - data = self._serialize(data) return super().put( - path, - data=data, - format=format, + *args, content_type=content_type, - follow=follow, - **extra, + **kwargs, ) # pylint: disable=W0622 - def patch( - self, - path, - data=None, - format=None, - content_type="application/json", - follow=False, - **extra, - ): - if data and content_type == "application/json": - data = self._serialize(data) + @serialize_json + def patch(self, *args, content_type="application/json", **kwargs): return super().patch( - path, - data=data, - format=format, + *args, content_type=content_type, - follow=follow, - **extra, + **kwargs, ) # pylint: disable=W0622 + @serialize_json def delete( self, - path, - data=None, - format=None, + *args, content_type="application/json", - follow=False, - **extra, + **kwargs, ): - if data and content_type == "application/json": - data = self._serialize(data) return super().delete( - path, - data=data, - format=format, + *args, content_type=content_type, - follow=follow, - **extra, + **kwargs, ) # pylint: disable=W0622 + @serialize_json def options( self, - path, - data=None, - format=None, + *args, content_type="application/json", - follow=False, - **extra, + **kwargs, ): - if data and content_type == "application/json": - data = self._serialize(data) return super().options( - path, - data=data, - format=format, + *args, content_type=content_type, - follow=follow, - **extra, + **kwargs, ) @staticmethod def _is_successful_response(response: Response) -> bool: - return response.status_code < 400 + return response.status_code < http.HTTPStatus.BAD_REQUEST @staticmethod def _schema_tester_factory() -> SchemaTester: """Factory of default ``SchemaTester`` instances.""" return SchemaTester() + +# pylint: disable=R0903 +class OpenAPINinjaClient(TestClient): + """``APINinjaClient`` validating responses against OpenAPI schema.""" + + def __init__( + self, + *args, + router_or_app: NinjaAPI | Router, + path_prefix: str = "", + schema_tester: SchemaTester | None = None, + **kwargs, + ) -> None: + """Initialize ``OpenAPIClient`` instance.""" + super().__init__(*args, router_or_app=router_or_app, **kwargs) + self.schema_tester = schema_tester or self._schema_tester_factory() + self._ninja_path_prefix = path_prefix + + def request(self, *args, **kwargs) -> Response: + """Validate fetched response against given OpenAPI schema.""" + response = super().request(*args, **kwargs) + response_handler = ResponseHandlerFactory.create( + *args, response=response, path_prefix=self._ninja_path_prefix, **kwargs + ) + if self._is_successful_response(response): + self.schema_tester.validate_request(response_handler=response_handler) + self.schema_tester.validate_response(response_handler) + return response + + @staticmethod + def _is_successful_response(response: Response) -> bool: + return response.status_code < http.HTTPStatus.BAD_REQUEST + @staticmethod - def _serialize(data): - try: - return json.dumps(data) - except (TypeError, OverflowError): - # Data is already serialized - return data + def _schema_tester_factory() -> SchemaTester: + """Factory of default ``SchemaTester`` instances.""" + return SchemaTester() diff --git a/openapi_tester/response_handler.py b/openapi_tester/response_handler.py index 5c1e7830..e720287e 100644 --- a/openapi_tester/response_handler.py +++ b/openapi_tester/response_handler.py @@ -3,14 +3,26 @@ """ import json -from typing import TYPE_CHECKING, Optional, Union +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, Optional, Union if TYPE_CHECKING: from django.http.response import HttpResponse from rest_framework.response import Response -class ResponseHandler: +@dataclass +class GenericRequest: + """Generic request class for both DRF and Django Ninja.""" + + path: str + method: str + data: dict = field(default_factory=dict) + headers: dict = field(default_factory=dict) + + +class ResponseHandler(ABC): """ This class is used to handle the response and request data from both DRF and Django HTTP (Django Ninja) responses. @@ -24,10 +36,12 @@ def response(self) -> Union["Response", "HttpResponse"]: return self._response @property - def data(self) -> Optional[dict]: ... + @abstractmethod + def request(self) -> GenericRequest: ... @property - def request_data(self) -> Optional[dict]: ... + @abstractmethod + def data(self) -> Optional[dict]: ... class DRFResponseHandler(ResponseHandler): @@ -43,8 +57,13 @@ def data(self) -> Optional[dict]: return self.response.json() if self.response.data is not None else None # type: ignore[attr-defined] @property - def request_data(self) -> Optional[dict]: - return self.response.renderer_context["request"].data # type: ignore[attr-defined] + def request(self) -> GenericRequest: + return GenericRequest( + path=self.response.renderer_context["request"].path, # type: ignore[attr-defined] + method=self.response.renderer_context["request"].method, # type: ignore[attr-defined] + data=self.response.renderer_context["request"].data, # type: ignore[attr-defined] + headers=self.response.renderer_context["request"].headers, # type: ignore[attr-defined] + ) class DjangoNinjaResponseHandler(ResponseHandler): @@ -52,14 +71,30 @@ class DjangoNinjaResponseHandler(ResponseHandler): Handles the response and request data from Django Ninja responses. """ - def __init__(self, response: "HttpResponse") -> None: + def __init__( + self, *request_args, response: "HttpResponse", path_prefix: str = "", **kwargs + ) -> None: super().__init__(response) + self._request_method = request_args[0] + self._request_path = f"{path_prefix}{request_args[1]}" + self._request_data = self._build_request_data(request_args[2]) + self._request_headers = kwargs @property def data(self) -> Optional[dict]: return self.response.json() if self.response.content else None # type: ignore[attr-defined] @property - def request_data(self) -> Optional[dict]: - response_body = self.response.wsgi_request.body # type: ignore[attr-defined] - return json.loads(response_body) if response_body else None + def request(self) -> GenericRequest: + return GenericRequest( + path=self._request_path, + method=self._request_method, + data=self._request_data, + headers=self._request_headers, + ) + + def _build_request_data(self, request_data: Any) -> dict: + try: + return json.loads(request_data) + except (json.JSONDecodeError, TypeError, ValueError): + return {} diff --git a/openapi_tester/response_handler_factory.py b/openapi_tester/response_handler_factory.py index fd894160..c3423a02 100644 --- a/openapi_tester/response_handler_factory.py +++ b/openapi_tester/response_handler_factory.py @@ -25,7 +25,9 @@ class ResponseHandlerFactory: """ @staticmethod - def create(response: Union[Response, "HttpResponse"]) -> "ResponseHandler": + def create( + *request_args, response: Union[Response, "HttpResponse"], **kwargs + ) -> "ResponseHandler": if isinstance(response, Response): - return DRFResponseHandler(response) - return DjangoNinjaResponseHandler(response) + return DRFResponseHandler(response=response) + return DjangoNinjaResponseHandler(*request_args, response=response, **kwargs) diff --git a/openapi_tester/schema_tester.py b/openapi_tester/schema_tester.py index 18f1b6bb..ba4b3c37 100644 --- a/openapi_tester/schema_tester.py +++ b/openapi_tester/schema_tester.py @@ -34,7 +34,6 @@ StaticSchemaLoader, UrlStaticSchemaLoader, ) -from openapi_tester.response_handler_factory import ResponseHandlerFactory from openapi_tester.utils import ( lazy_combinations, normalize_schema_section, @@ -60,10 +59,7 @@ if TYPE_CHECKING: from typing import Optional - from django.http.response import HttpResponse - from rest_framework.response import Response - - from openapi_tester.response_handler import ResponseHandler + from openapi_tester.response_handler import GenericRequest, ResponseHandler class SchemaTester: @@ -186,9 +182,9 @@ def get_response_schema_section( response = response_handler.response schema = self.loader.get_schema() - response_method = response.request["REQUEST_METHOD"].lower() # type: ignore + response_method = response_handler.request.method.lower() parameterized_path, _ = self.loader.resolve_path( - response.request["PATH_INFO"], # type: ignore + response_handler.request.path, method=response_method, ) paths_object = self.get_paths_object() @@ -254,7 +250,6 @@ def get_response_schema_section( UNDOCUMENTED_SCHEMA_SECTION_ERROR.format( key="content", error_addon=( - f"\n\n{test_config.reference}" f"\n\nNo `content` defined for this response: {response_method}, path: {parameterized_path}" ), ) @@ -262,7 +257,7 @@ def get_response_schema_section( return {} def get_request_body_schema_section( - self, request: dict[str, Any], test_config: OpenAPITestConfig + self, request: GenericRequest, test_config: OpenAPITestConfig ) -> dict[str, Any]: """ Fetches the request section of a schema. @@ -270,11 +265,12 @@ def get_request_body_schema_section( :param response: DRF Request Instance :return dict """ - request_method = request["REQUEST_METHOD"].lower() + request_method = request.method.lower() # request["REQUEST_METHOD"].lower() parameterized_path, _ = self.loader.resolve_path( - request["PATH_INFO"], method=request_method + request.path, method=request_method ) + paths_object = self.get_paths_object() route_object = self.get_key_value( @@ -297,10 +293,11 @@ def get_request_body_schema_section( ), ) - if all( - key in request for key in ["CONTENT_LENGTH", "CONTENT_TYPE", "wsgi.input"] - ): - if request["CONTENT_TYPE"] != "application/json": + if request.data: + if not any( + "application/json" == request.headers.get(content_type) + for content_type in ["content_type", "CONTENT_TYPE", "Content-Type"] + ): return {} request_body_object = self.get_key_value( @@ -311,6 +308,7 @@ def get_request_body_schema_section( f"\n\nNo request body documented for method: {request_method}, path: {parameterized_path}" ), ) + content_object = self.get_key_value( request_body_object, "content", @@ -319,6 +317,7 @@ def get_request_body_schema_section( f"\n\nNo content documented for method: {request_method}, path: {parameterized_path}" ), ) + json_object = self.get_key_value( content_object, r"^application\/.*json$", @@ -329,6 +328,7 @@ def get_request_body_schema_section( ), use_regex=True, ) + return self.get_key_value(json_object, "schema") return {} @@ -617,7 +617,7 @@ def test_openapi_array( def validate_request( self, - response: Response | HttpResponse, + response_handler: ResponseHandler, test_config: OpenAPITestConfig | None = None, ) -> None: """ @@ -631,31 +631,30 @@ def validate_request( :raises: ``openapi_tester.exceptions.DocumentationError`` for inconsistencies in the API response and schema. ``openapi_tester.exceptions.CaseError`` for case errors. """ - response_handler = ResponseHandlerFactory.create(response) if self.is_openapi_schema(): # TODO: Implement for other schema types - request = response.request # type: ignore if test_config: test_config.http_message = "request" else: test_config = OpenAPITestConfig( http_message="request", - reference=f"{request['REQUEST_METHOD']} {request['PATH_INFO']} > request", + reference=f"{response_handler.request.method} {response_handler.request.path} > request", ) + request_body_schema = self.get_request_body_schema_section( - request, test_config=test_config + response_handler.request, test_config=test_config ) if request_body_schema: self.test_schema_section( schema_section=request_body_schema, - data=response_handler.request_data, + data=response_handler.request.data, test_config=test_config, ) def validate_response( self, - response: Response | HttpResponse, + response_handler: ResponseHandler, test_config: OpenAPITestConfig | None = None, ) -> None: """ @@ -668,15 +667,13 @@ def validate_response( :raises: ``openapi_tester.exceptions.DocumentationError`` for inconsistencies in the API response and schema. ``openapi_tester.exceptions.CaseError`` for case errors. """ - response_handler = ResponseHandlerFactory.create(response) - if test_config: test_config.http_message = "response" else: - request = response.request # type: ignore + request = response_handler.request test_config = OpenAPITestConfig( http_message="response", - reference=f"{request['REQUEST_METHOD']} {request['PATH_INFO']} > response > {response.status_code}", + reference=f"{request.method} {request.path} > response > {response_handler.response.status_code}", ) response_schema = self.get_response_schema_section( response_handler, test_config=test_config diff --git a/openapi_tester/utils.py b/openapi_tester/utils.py index dedc0b55..9c661476 100644 --- a/openapi_tester/utils.py +++ b/openapi_tester/utils.py @@ -67,3 +67,18 @@ def lazy_combinations(options_list: Sequence[dict[str, Any]]) -> Iterator[dict]: for i in range(2, len(options_list) + 1): for combination in combinations(options_list, i): yield merge_objects(combination) + + +def serialize_json(func): + def wrapper(*args, **kwargs): + # import pdb; pdb.set_trace() + data = kwargs.get("data") + content_type = kwargs.get("content_type") + if data and content_type == "application/json": + try: + kwargs["data"] = json.dumps(data) + except (TypeError, OverflowError): + kwargs["data"] = data + return func(*args, **kwargs) + + return wrapper diff --git a/pyproject.toml b/pyproject.toml index 585be8fd..a2ef7c72 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "django-contract-tester" -version = "1.3.2" +version = "1.4.0" description = "Test utility for validating OpenAPI response documentation" authors =["Matías Cárdenas ", "Sondre Lillebø Gundersen ", "Na'aman Hirschfeld "] license = "BSD-4-Clause" diff --git a/test_project/api/serializers.py b/test_project/api/serializers.py index fcbbae05..2d8b4d99 100644 --- a/test_project/api/serializers.py +++ b/test_project/api/serializers.py @@ -9,7 +9,7 @@ class Meta: class PetsSerializer(serializers.Serializer): - name = serializers.CharField(max_length=254, required=False, allow_null=True) + name = serializers.CharField(max_length=254, required=True, allow_null=True) tag = serializers.CharField(max_length=254, required=False, allow_null=True) diff --git a/tests/conftest.py b/tests/conftest.py index e3be2e33..96dde23f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,6 +7,7 @@ import pytest from rest_framework.response import Response +from openapi_tester.response_handler import GenericRequest from tests.schema_converter import SchemaToPythonConverter from tests.utils import TEST_ROOT @@ -33,32 +34,24 @@ def cars_api_schema() -> Path: def pets_post_request(): request_body = MagicMock() request_body.read.return_value = b'{"name": "doggie", "tag": "dog"}' - return { - "PATH_INFO": "/api/pets", - "REQUEST_METHOD": "POST", - "SERVER_PORT": "80", - "wsgi.url_scheme": "http", - "CONTENT_LENGTH": "70", - "CONTENT_TYPE": "application/json", - "wsgi.input": request_body, - "QUERY_STRING": "", - } + return GenericRequest( + method="post", + path="/api/pets", + data={"name": "doggie", "tag": "dog"}, + headers={"Content-Type": "application/json"}, + ) @pytest.fixture def invalid_pets_post_request(): request_body = MagicMock() request_body.read.return_value = b'{"surname": "doggie", "species": "dog"}' - return { - "PATH_INFO": "/api/pets", - "REQUEST_METHOD": "POST", - "SERVER_PORT": "80", - "wsgi.url_scheme": "http", - "CONTENT_LENGTH": "70", - "CONTENT_TYPE": "application/json", - "wsgi.input": request_body, - "QUERY_STRING": "", - } + return GenericRequest( + method="post", + path="/api/pets", + data={"surname": "doggie", "species": "dog"}, + headers={"Content-Type": "application/json"}, + ) @pytest.fixture @@ -68,20 +61,40 @@ def response( url_fragment: str, method: str, status_code: int | str = 200, - response_body: dict | None = None, + response_body: str | None = None, + request: GenericRequest | None = None, ) -> Response: converted_schema = None if schema: converted_schema = SchemaToPythonConverter(deepcopy(schema)).result response = Response(status=int(status_code), data=converted_schema) response.request = {"REQUEST_METHOD": method, "PATH_INFO": url_fragment} # type: ignore + + if request: + response.renderer_context = { # type: ignore[attr-defined] + "request": MagicMock( + path=request.path, + method=request.method, + data=request.data, + headers=request.headers, + ) + } + else: + response.renderer_context = { # type: ignore[attr-defined] + "request": MagicMock( + path=url_fragment, + method=method, + data={}, + headers={}, + ) + } + if schema: response.json = lambda: converted_schema # type: ignore elif response_body: response.request["CONTENT_LENGTH"] = len(response_body) # type: ignore response.request["CONTENT_TYPE"] = "application/json" # type: ignore response.request["wsgi.input"] = response_body # type: ignore - response.renderer_context = {"request": MagicMock(data=response_body)} # type: ignore return response return response diff --git a/tests/test_clients.py b/tests/test_clients.py index f27a5796..f0cbae29 100644 --- a/tests/test_clients.py +++ b/tests/test_clients.py @@ -43,7 +43,9 @@ def test_get_request(cars_api_schema: "Path"): def test_post_request(openapi_client): response = openapi_client.post( - path="/api/v1/vehicles", data={"vehicle_type": "suv"} + path="/api/v1/vehicles", + data={"vehicle_type": "suv"}, + content_type="application/json", ) assert response.status_code == status.HTTP_201_CREATED @@ -52,7 +54,7 @@ def test_post_request(openapi_client): def test_request_validation_is_not_triggered_for_bad_requests(pets_api_schema: "Path"): schema_tester = SchemaTester(schema_file_path=str(pets_api_schema)) openapi_client = OpenAPIClient(schema_tester=schema_tester) - response = openapi_client.post(path="/api/pets", data={"name": False}) + response = openapi_client.post(path="/api/pets", data={}) assert response.status_code == status.HTTP_400_BAD_REQUEST @@ -64,7 +66,11 @@ def test_request_body_extra_non_documented_field(pets_api_schema: "Path"): openapi_client = OpenAPIClient(schema_tester=schema_tester) with pytest.raises(DocumentationError): - openapi_client.post(path="/api/pets", data={"name": "doggie", "age": 1}) + openapi_client.post( + path="/api/pets", + data={"name": "doggie", "age": 1}, + content_type="application/json", + ) def test_request_body_non_null_fields(pets_api_schema: "Path"): @@ -72,7 +78,11 @@ def test_request_body_non_null_fields(pets_api_schema: "Path"): openapi_client = OpenAPIClient(schema_tester=schema_tester) with pytest.raises(DocumentationError): - openapi_client.post(path="/api/pets", data={"name": "doggie", "tag": None}) + openapi_client.post( + path="/api/pets", + data={"name": "doggie", "tag": None}, + content_type="application/json", + ) def test_request_multiple_types_supported(pets_api_schema: "Path"): diff --git a/tests/test_django_framework.py b/tests/test_django_framework.py index 84088b54..c287e0ba 100644 --- a/tests/test_django_framework.py +++ b/tests/test_django_framework.py @@ -6,6 +6,7 @@ from rest_framework.test import APITestCase from openapi_tester import SchemaTester +from openapi_tester.response_handler_factory import ResponseHandlerFactory from tests.utils import TEST_ROOT if TYPE_CHECKING: @@ -22,7 +23,8 @@ class BaseAPITestCase(APITestCase): @staticmethod def assertResponse(response: Response, **kwargs) -> None: """helper to run validate_response and pass kwargs to it""" - schema_tester.validate_response(response=response, **kwargs) + response_handler = ResponseHandlerFactory.create(response=response) + schema_tester.validate_response(response_handler=response_handler, **kwargs) class PetsAPITests(BaseAPITestCase): diff --git a/tests/test_django_ninja.py b/tests/test_django_ninja.py index bb61e494..faa03fbf 100644 --- a/tests/test_django_ninja.py +++ b/tests/test_django_ninja.py @@ -4,7 +4,9 @@ import pytest from openapi_tester import OpenAPIClient, SchemaTester +from openapi_tester.clients import OpenAPINinjaClient from openapi_tester.exceptions import UndocumentedSchemaSectionError +from test_project.api.ninja.api import router from tests.utils import TEST_ROOT if TYPE_CHECKING: @@ -18,18 +20,20 @@ def users_ninja_api_schema() -> "Path": @pytest.fixture def client(users_ninja_api_schema: "Path") -> OpenAPIClient: - return OpenAPIClient( - schema_tester=SchemaTester(schema_file_path=str(users_ninja_api_schema)) + return OpenAPINinjaClient( + router_or_app=router, + path_prefix="/ninja_api/users", + schema_tester=SchemaTester(schema_file_path=str(users_ninja_api_schema)), ) def test_get_users(client: OpenAPIClient): - response = client.get("/ninja_api/users/") + response = client.get("/") assert response.status_code == 200 def test_get_user(client: OpenAPIClient): - response = client.get("/ninja_api/users/1") + response = client.get("/1") assert response.status_code == 200 @@ -41,8 +45,8 @@ def test_create_user(client: OpenAPIClient): "is_active": True, } response = client.post( - path="/ninja_api/users/", - data=payload, + path="/", + data=json.dumps(payload), content_type="application/json", ) assert response.status_code == 201 @@ -56,8 +60,8 @@ def test_update_user(client: OpenAPIClient): "is_active": True, } response = client.put( - path="/ninja_api/users/1", - data=payload, + path="/1", + data=json.dumps(payload), content_type="application/json", ) assert response.status_code == 200 @@ -65,7 +69,7 @@ def test_update_user(client: OpenAPIClient): def test_delete_user(client: OpenAPIClient): response = client.delete( - path="/ninja_api/users/1", + path="/1", ) assert response.status_code == 204 @@ -76,7 +80,7 @@ def test_patch_user_undocumented_path(client: OpenAPIClient): } with pytest.raises(UndocumentedSchemaSectionError): client.patch( - path="/ninja_api/users/1", + path="/1", data=json.dumps(payload), content_type="application/json", ) diff --git a/tests/test_schema_tester.py b/tests/test_schema_tester.py index 6e92d62f..cd90c234 100644 --- a/tests/test_schema_tester.py +++ b/tests/test_schema_tester.py @@ -32,6 +32,7 @@ UndocumentedSchemaSectionError, ) from openapi_tester.loaders import UrlStaticSchemaLoader +from openapi_tester.response_handler import GenericRequest from openapi_tester.response_handler_factory import ResponseHandlerFactory from openapi_tester.schema_tester import OpenAPITestConfig from test_project.models import Names @@ -163,7 +164,7 @@ def test_drf_coerced_model_primary_key(client, url): Names.objects.create(custom_id_field=name_id) schema_tester = SchemaTester() response = client.get(url) - schema_tester.validate_response(response) + schema_tester.validate_response(ResponseHandlerFactory.create(response=response)) @pytest.mark.parametrize( @@ -190,11 +191,11 @@ def test_example_schemas(filename): "resolve_path", side_effect=lambda *args, **kwargs: (url_fragment, None), # noqa ): - schema_tester.validate_response(response) - response_handler = ResponseHandlerFactory.create(response) + response_handler = ResponseHandlerFactory.create(response=response) + schema_tester.validate_response(response_handler=response_handler) assert sorted( schema_tester.get_response_schema_section( - response_handler, + response_handler=response_handler, test_config=OpenAPITestConfig(case_tester=is_pascal_case), ) ) == sorted(schema_section) @@ -212,7 +213,7 @@ def test_validate_response_failure_scenario_with_predefined_data(client): 'but is missing from the response data: "width"' ), ): - tester.validate_response(response) + tester.validate_response(ResponseHandlerFactory.create(response=response)) def test_validate_response_failure_scenario_undocumented_path( @@ -229,7 +230,7 @@ def test_validate_response_failure_scenario_undocumented_path( UndocumentedSchemaSectionError, match=f"Unsuccessfully tried to index the OpenAPI schema by `{parameterized_path}`.", ): - tester.validate_response(response) + tester.validate_response(ResponseHandlerFactory.create(response=response)) def test_validate_response_failure_scenario_undocumented_method( @@ -246,7 +247,7 @@ def test_validate_response_failure_scenario_undocumented_method( UndocumentedSchemaSectionError, match=f"Unsuccessfully tried to index the OpenAPI schema by `{method}`.", ): - tester.validate_response(response) + tester.validate_response(ResponseHandlerFactory.create(response=response)) def test_validate_response_failure_scenario_undocumented_status_code( @@ -263,7 +264,7 @@ def test_validate_response_failure_scenario_undocumented_status_code( UndocumentedSchemaSectionError, match=f"Unsuccessfully tried to index the OpenAPI schema by `{status}`.", ): - tester.validate_response(response) + tester.validate_response(ResponseHandlerFactory.create(response=response)) def test_validate_response_failure_scenario_undocumented_content(client, monkeypatch): @@ -274,12 +275,10 @@ def test_validate_response_failure_scenario_undocumented_content(client, monkeyp with pytest.raises( UndocumentedSchemaSectionError, match=( - "Error: Unsuccessfully tried to index the OpenAPI schema by `content`. " - "\n\nGET /api/v1/cars/correct > response > 200" - f"\n\nNo `content` defined for this response: {method}, path: {parameterized_path}" + "Error: Unsuccessfully tried to index the OpenAPI schema by `content`. \n\nNo `content` defined for this response: get, path: /api/{version}/cars/correct" ), ): - tester.validate_response(response) + tester.validate_response(ResponseHandlerFactory.create(response=response)) def test_validate_request( @@ -293,7 +292,7 @@ def test_validate_request( status_code=201, response_body={"name": "doggie", "tag": "dog"}, ) - schema_tester.validate_request(response) + schema_tester.validate_request(ResponseHandlerFactory.create(response=response)) def test_validate_request_with_config( @@ -308,29 +307,30 @@ def test_validate_request_with_config( response_body={"name": "doggie", "tag": "dog"}, ) schema_tester.validate_request( - response, + ResponseHandlerFactory.create(response=response), OpenAPITestConfig(case_tester=is_pascal_case, ignore_case=["name", "tag"]), ) def test_validate_request_invalid( - response_factory, pets_api_schema: Path, pets_post_request: dict[str, Any] + response_factory, pets_api_schema: Path, pets_post_request: GenericRequest ): schema_tester = SchemaTester(schema_file_path=str(pets_api_schema)) + pets_post_request.data.pop("name") response = response_factory( schema=None, url_fragment="/api/pets", method="POST", status_code=201, - response_body={"tag": "dog"}, + request=pets_post_request, ) - + response_handler = ResponseHandlerFactory.create(response=response) with pytest.raises(DocumentationError): - schema_tester.validate_request(response) + schema_tester.validate_request(response_handler=response_handler) def test_validate_request_no_application_json( - response_factory, pets_api_schema: Path, pets_post_request: dict[str, Any] + response_factory, pets_api_schema: Path, pets_post_request: GenericRequest ): schema_tester = SchemaTester(schema_file_path=str(pets_api_schema)) response = response_factory( @@ -340,8 +340,8 @@ def test_validate_request_no_application_json( status_code=201, response_body={"tag": "dog"}, ) - response.request["CONTENT_TYPE"] = "application/xml" - schema_tester.validate_request(response) + response.renderer_context["request"].headers["CONTENT_TYPE"] = "application/xml" + schema_tester.validate_request(ResponseHandlerFactory.create(response=response)) def test_validate_request_schema_with_prefix_in_server( @@ -359,7 +359,7 @@ def test_validate_request_schema_with_prefix_in_server( ) with pytest.raises(UndocumentedSchemaSectionError) as error: - schema_tester.validate_request(response) + schema_tester.validate_request(ResponseHandlerFactory.create(response=response)) assert "Undocumented route /api/pets" in str(error.value) @@ -379,7 +379,7 @@ def test_validate_request_schema_with_prefix_in_server_path_prefix( status_code=201, response_body={"name": "Doggie"}, ) - schema_tester.validate_request(response) + schema_tester.validate_request(ResponseHandlerFactory.create(response=response)) def test_is_openapi_schema(pets_api_schema: Path): @@ -393,12 +393,12 @@ def test_is_openapi_schema_false(): def test_get_request_body_schema_section( - pets_post_request: dict[str, Any], pets_api_schema: Path + pets_post_request: GenericRequest, pets_api_schema: Path ): test_config = OpenAPITestConfig(case_tester=is_pascal_case) schema_tester = SchemaTester(schema_file_path=str(pets_api_schema)) schema_section = schema_tester.get_request_body_schema_section( - pets_post_request, test_config=test_config + request=pets_post_request, test_config=test_config ) assert schema_section == { "type": "object", @@ -408,25 +408,25 @@ def test_get_request_body_schema_section( def test_get_request_body_schema_section_content_type_no_application_json( - pets_post_request: dict[str, Any], pets_api_schema: Path + pets_post_request: GenericRequest, pets_api_schema: Path ): schema_tester = SchemaTester(schema_file_path=str(pets_api_schema)) test_config = OpenAPITestConfig(case_tester=is_pascal_case) - pets_post_request["CONTENT_TYPE"] = "application/xml" + pets_post_request.headers["Content-Type"] = "application/xml" schema_section = schema_tester.get_request_body_schema_section( - pets_post_request, test_config=test_config + request=pets_post_request, test_config=test_config ) assert schema_section == {} def test_get_request_body_schema_section_no_content_request( - pets_post_request: dict[str, Any], pets_api_schema: Path + pets_post_request: GenericRequest, pets_api_schema: Path ): test_config = OpenAPITestConfig(case_tester=is_pascal_case) schema_tester = SchemaTester(schema_file_path=str(pets_api_schema)) - del pets_post_request["wsgi.input"] + pets_post_request.data = {} schema_section = schema_tester.get_request_body_schema_section( - pets_post_request, test_config=test_config + request=pets_post_request, test_config=test_config ) assert schema_section == {} @@ -434,7 +434,9 @@ def test_get_request_body_schema_section_no_content_request( def test_validate_response_global_case_tester(client): response = client.get(de_parameterized_path) with pytest.raises(CaseError, match="is not properly PascalCased"): - SchemaTester(case_tester=is_pascal_case).validate_response(response=response) + SchemaTester(case_tester=is_pascal_case).validate_response( + ResponseHandlerFactory.create(response=response) + ) @pytest.mark.parametrize("empty_schema", [None, {}]) @@ -445,7 +447,7 @@ def test_validate_response_empty_content( del schema["paths"][parameterized_path][method]["responses"][status]["content"] monkeypatch.setattr(tester.loader, "get_schema", mock_schema(schema)) response = response_factory(empty_schema, de_parameterized_path, method, status) - tester.validate_response(response) + tester.validate_response(ResponseHandlerFactory.create(response=response)) def test_validate_response_global_ignored_case(client): @@ -453,7 +455,7 @@ def test_validate_response_global_ignored_case(client): SchemaTester( case_tester=is_pascal_case, ignore_case=["name", "color", "height", "width", "length"], - ).validate_response(response=response) + ).validate_response(ResponseHandlerFactory.create(response=response)) def test_validate_response_passed_in_case_tester(client): @@ -463,14 +465,15 @@ def test_validate_response_passed_in_case_tester(client): match="The response key `name` is not properly PascalCased. Expected value: Name", ): tester.validate_response( - response=response, test_config=OpenAPITestConfig(case_tester=is_pascal_case) + response_handler=ResponseHandlerFactory.create(response=response), + test_config=OpenAPITestConfig(case_tester=is_pascal_case), ) def test_validate_response_passed_in_ignored_case(client): response = client.get(de_parameterized_path) tester.validate_response( - response=response, + response_handler=ResponseHandlerFactory.create(response=response), test_config=OpenAPITestConfig( case_tester=is_pascal_case, ignore_case=["name", "color", "height", "width", "length"], @@ -502,7 +505,7 @@ def test_nullable_validation(): def test_write_only_validation(): - test_schema_section: dict[str, Any] = { + test_schema_section = { "type": "object", "properties": { "test": { diff --git a/tests/utils.py b/tests/utils.py index 8d371d21..9c107323 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -4,6 +4,7 @@ from copy import deepcopy from pathlib import Path from typing import TYPE_CHECKING +from unittest.mock import MagicMock from rest_framework.response import Response @@ -23,6 +24,14 @@ def response_factory( converted_schema = SchemaToPythonConverter(deepcopy(schema)).result response = Response(status=int(status_code), data=converted_schema) response.request = {"REQUEST_METHOD": method, "PATH_INFO": url_fragment} # type: ignore + response.renderer_context = { # type: ignore[attr-defined] + "request": MagicMock( + path=url_fragment, + method=method, + data={}, + headers={}, + ) + } if schema: response.json = lambda: converted_schema # type: ignore return response