From f00ae0219eb575b2a9db5dfa2ce62232d98cc5a9 Mon Sep 17 00:00:00 2001 From: "danut.t" Date: Thu, 4 Apr 2024 17:29:28 +0200 Subject: [PATCH] feat: update error messages (#30) * feat: update error messages * feat: use json.dumps whenever we have json content types * feat: support OAS 3.1 list of types --------- Co-authored-by: danut.turta1 --- openapi_tester/clients.py | 114 ++++++++++++++++ openapi_tester/config.py | 2 +- openapi_tester/constants.py | 10 +- openapi_tester/schema_tester.py | 123 +++++++++++++----- openapi_tester/validators.py | 21 ++- test_project/api/serializers.py | 4 +- tests/conftest.py | 5 + tests/schema_converter.py | 3 + .../schemas/openapi_v3_reference_schema.yaml | 6 +- tests/test_clients.py | 98 ++++++-------- tests/test_django_ninja.py | 4 +- tests/test_errors.py | 46 ++++--- tests/test_schema_tester.py | 34 +++-- 13 files changed, 339 insertions(+), 131 deletions(-) diff --git a/openapi_tester/clients.py b/openapi_tester/clients.py index 61c60f6b..a830fc56 100644 --- a/openapi_tester/clients.py +++ b/openapi_tester/clients.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json from typing import TYPE_CHECKING from rest_framework.test import APIClient @@ -33,6 +34,111 @@ def request(self, **kwargs) -> Response: # type: ignore[override] self.schema_tester.validate_response(response) return response + # pylint: disable=W0622 + def post( + self, + path, + data=None, + format=None, + content_type="application/json", + follow=False, + **extra, + ): + if data and content_type == "application/json": + data = self._serialize(data) + return super().post( + path, + data=data, + format=format, + content_type=content_type, + follow=follow, + **extra, + ) + + # pylint: disable=W0622 + def put( + self, + path, + data=None, + format=None, + content_type="application/json", + follow=False, + **extra, + ): + if data and content_type == "application/json": + data = self._serialize(data) + return super().put( + path, + data=data, + format=format, + content_type=content_type, + follow=follow, + **extra, + ) + + # 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) + return super().patch( + path, + data=data, + format=format, + content_type=content_type, + follow=follow, + **extra, + ) + + # pylint: disable=W0622 + def delete( + self, + path, + data=None, + format=None, + content_type="application/json", + follow=False, + **extra, + ): + if data and content_type == "application/json": + data = self._serialize(data) + return super().delete( + path, + data=data, + format=format, + content_type=content_type, + follow=follow, + **extra, + ) + + # pylint: disable=W0622 + def options( + self, + path, + data=None, + format=None, + content_type="application/json", + follow=False, + **extra, + ): + if data and content_type == "application/json": + data = self._serialize(data) + return super().options( + path, + data=data, + format=format, + content_type=content_type, + follow=follow, + **extra, + ) + @staticmethod def _is_successful_response(response: Response) -> bool: return response.status_code < 400 @@ -41,3 +147,11 @@ def _is_successful_response(response: Response) -> bool: def _schema_tester_factory() -> SchemaTester: """Factory of default ``SchemaTester`` instances.""" return SchemaTester() + + @staticmethod + def _serialize(data): + try: + return json.dumps(data) + except (TypeError, OverflowError): + # Data is already serialized + return data diff --git a/openapi_tester/config.py b/openapi_tester/config.py index 84777ed4..9a69e4ef 100644 --- a/openapi_tester/config.py +++ b/openapi_tester/config.py @@ -13,5 +13,5 @@ class OpenAPITestConfig: case_tester: Optional[Callable[[str], None]] = None ignore_case: Optional[List[str]] = None validators: Any = None - reference: str = "init" + reference: str = "root" http_message: str = "response" diff --git a/openapi_tester/constants.py b/openapi_tester/constants.py index 1c121163..0243dd74 100644 --- a/openapi_tester/constants.py +++ b/openapi_tester/constants.py @@ -36,11 +36,15 @@ VALIDATE_MINIMUM_NUMBER_OF_PROPERTIES_ERROR = "The number of properties in {data} is fewer than the specified minimum number of properties of {min_length}" VALIDATE_MAXIMUM_NUMBER_OF_PROPERTIES_ERROR = "The number of properties in {data} exceeds the specified maximum number of properties of {max_length}" VALIDATE_UNIQUE_ITEMS_ERROR = "The array {data} must contain unique items only" -VALIDATE_NONE_ERROR = "Received a null value for a non-nullable schema object" +VALIDATE_NONE_ERROR = "A property received a null value in the {http_message} data, but is a non-nullable object in the schema definition" VALIDATE_MISSING_KEY_ERROR = ( - 'The following property is missing in the {http_message} data: "{missing_key}"' + "The following property was found in the schema definition, " + 'but is missing from the {http_message} data: "{missing_key}"' +) +VALIDATE_EXCESS_KEY_ERROR = ( + "The following property was found in the {http_message} data, " + 'but is missing from the schema definition: "{excess_key}"' ) -VALIDATE_EXCESS_KEY_ERROR = 'The following property was found in the {http_message}, but is missing from the schema definition: "{excess_key}"' VALIDATE_WRITE_ONLY_RESPONSE_KEY_ERROR = 'The following property was found in the response, but is documented as being "writeOnly": "{write_only_key}"' VALIDATE_ONE_OF_ERROR = "Expected data to match one and only one of the oneOf schema types; found {matches} matches" VALIDATE_ANY_OF_ERROR = "Expected data to match one or more of the documented anyOf schema types, but found no matches" diff --git a/openapi_tester/schema_tester.py b/openapi_tester/schema_tester.py index b7ca6453..19933b1a 100644 --- a/openapi_tester/schema_tester.py +++ b/openapi_tester/schema_tester.py @@ -2,7 +2,9 @@ from __future__ import annotations +import json import re +from copy import copy from itertools import chain from typing import TYPE_CHECKING, Any, Callable, cast @@ -169,7 +171,7 @@ def get_paths_object(self) -> dict[str, Any]: return paths_object def get_response_schema_section( - self, response_handler: ResponseHandler + self, response_handler: ResponseHandler, test_config: OpenAPITestConfig ) -> dict[str, Any]: """ Fetches the response section of a schema, wrt. the route, method, status code, and schema version. @@ -190,15 +192,19 @@ def get_response_schema_section( route_object = self.get_key_value( paths_object, parameterized_path, - f"\n\nUndocumented route {parameterized_path}.\n\nDocumented routes: " - + "\n\t• ".join(paths_object.keys()), + ( + f"\n\n{test_config.reference}\n\nUndocumented route {parameterized_path}.\n\nDocumented routes: " + + "\n\t• ".join(paths_object.keys()) + ), ) method_object = self.get_key_value( route_object, response_method, ( - f"\n\nUndocumented method: {response_method}.\n\nDocumented methods: " + f"\n\n{test_config.reference}" + f"\n\nUndocumented method: {response_method}." + "\n\nDocumented methods: " f"{[method.lower() for method in route_object.keys() if method.lower() != 'parameters']}." ), ) @@ -208,8 +214,9 @@ def get_response_schema_section( responses_object, response.status_code, ( - f"\n\nUndocumented status code: {response.status_code}.\n\n" - f"Documented status codes: {list(responses_object.keys())}. " + f"\n\n{test_config.reference}" + f"\n\nUndocumented status code: {response.status_code}." + f"\n\nDocumented status codes: {list(responses_object.keys())}. " ), ) @@ -221,12 +228,16 @@ def get_response_schema_section( content_object = self.get_key_value( status_code_object, "content", - f"\n\nNo content documented for method: {response_method}, path: {parameterized_path}", + ( + f"\n\n{test_config.reference}" + f"\n\nNo content documented for method: {response_method}, path: {parameterized_path}" + ), ) json_object = self.get_key_value( content_object, r"^application\/.*json$", ( + f"\n\n{test_config.reference}" "\n\nNo `application/json` responses documented for method: " f"{response_method}, path: {parameterized_path}" ), @@ -239,6 +250,7 @@ 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}" ), ) @@ -246,7 +258,7 @@ def get_response_schema_section( return {} def get_request_body_schema_section( - self, request: dict[str, Any] + self, request: dict[str, Any], test_config: OpenAPITestConfig ) -> dict[str, Any]: """ Fetches the request section of a schema. @@ -264,15 +276,19 @@ def get_request_body_schema_section( route_object = self.get_key_value( paths_object, parameterized_path, - f"\n\nUndocumented route {parameterized_path}.\n\nDocumented routes: " - + "\n\t• ".join(paths_object.keys()), + ( + f"\n\n{test_config.reference}\n\nUndocumented route {parameterized_path}.\n\nDocumented routes: " + + "\n\t• ".join(paths_object.keys()) + ), ) method_object = self.get_key_value( route_object, request_method, ( - f"\n\nUndocumented method: {request_method}.\n\nDocumented methods: " + f"\n\n{test_config.reference}" + f"\n\nUndocumented method: {request_method}." + "\n\nDocumented methods: " f"{[method.lower() for method in route_object.keys() if method.lower() != 'parameters']}." ), ) @@ -286,17 +302,24 @@ def get_request_body_schema_section( request_body_object = self.get_key_value( method_object, "requestBody", - f"\n\nNo request body documented for method: {request_method}, path: {parameterized_path}", + ( + f"\n\n{test_config.reference}" + f"\n\nNo request body documented for method: {request_method}, path: {parameterized_path}" + ), ) content_object = self.get_key_value( request_body_object, "content", - f"\n\nNo content documented for method: {request_method}, path: {parameterized_path}", + ( + f"\n\n{test_config.reference}" + f"\n\nNo content documented for method: {request_method}, path: {parameterized_path}" + ), ) json_object = self.get_key_value( content_object, r"^application\/.*json$", ( + f"\n\n{test_config.reference}" "\n\nNo `application/json` requests documented for method: " f"{request_method}, path: {parameterized_path}" ), @@ -376,6 +399,7 @@ def test_is_nullable(schema_item: dict) -> bool: """ openapi_schema_3_nullable = "nullable" swagger_2_nullable = "x-nullable" + openapi_schema_3_1_type_nullable = "null" if "oneOf" in schema_item: one_of: list[dict[str, Any]] = schema_item.get("oneOf", []) return any( @@ -390,6 +414,10 @@ def test_is_nullable(schema_item: dict) -> bool: for schema in any_of for nullable_key in [openapi_schema_3_nullable, swagger_2_nullable] ) + if "type" in schema_item and isinstance(schema_item["type"], list): + types: list[str] = schema_item["type"] + return openapi_schema_3_1_type_nullable in types + return any( nullable_key in schema_item and schema_item[nullable_key] for nullable_key in [openapi_schema_3_nullable, swagger_2_nullable] @@ -421,9 +449,11 @@ def test_schema_section( # If data is None and nullable, we return early return raise DocumentationError( - f"{VALIDATE_NONE_ERROR}\n\n" - f"Reference: {test_config.reference}\n\n" - "Hint: Return a valid type, or document the value as nullable" + f"{VALIDATE_NONE_ERROR.format(http_message=test_config.http_message)}" + "\n\nReference:" + f"\n\n{test_config.reference}" + f"\n\nSchema description:\n {json.dumps(schema_section, indent=4)}" + "\n\nHint: Return a valid type, or document the value as nullable" ) schema_section = normalize_schema_section(schema_section) if "oneOf" in schema_section: @@ -471,7 +501,11 @@ def test_schema_section( error = validator(schema_section, data) if error: raise DocumentationError( - f"\n\n{error}\n\nReference: {test_config.reference}" + f"\n\n{error}" + "\n\nReference: " + f"\n\n{test_config.reference}" + f"\n\n {test_config.http_message.capitalize()} value:\n {data}" + f"\n Schema description:\n {schema_section}" ) if schema_section_type == "object": @@ -519,7 +553,10 @@ def test_openapi_object( raise DocumentationError( f"{VALIDATE_MISSING_KEY_ERROR.format(missing_key=key, http_message=test_config.http_message)}" "\n\nReference:" - f" {test_config.reference}.object:key:{key}\n\nHint: Remove the key from your OpenAPI docs, or" + f"\n\n{test_config.reference} > {key}" + f"\n\n{test_config.http_message.capitalize()} body:\n {json.dumps(data, indent=4)}" + f"\nSchema section:\n {json.dumps(properties, indent=4)}" + "\n\nHint: Remove the key from your OpenAPI docs, or" f" include it in your API {test_config.http_message}" ) for key in request_response_keys: @@ -528,42 +565,50 @@ def test_openapi_object( raise DocumentationError( f"{VALIDATE_EXCESS_KEY_ERROR.format(excess_key=key, http_message=test_config.http_message)}" "\n\nReference:" - f" {test_config.reference}.object:key:{key}\n\nHint: Remove the key from your API" + f"\n\n{test_config.reference} > {key}" + f"\n\n{test_config.http_message.capitalize()} body:\n {json.dumps(data, indent=4)}" + f"\n\nSchema section:\n {json.dumps(properties, indent=4)}" + "\n\nHint: Remove the key from your API" f" {test_config.http_message}, or include it in your OpenAPI docs" ) if key in write_only_properties: raise DocumentationError( f"{VALIDATE_WRITE_ONLY_RESPONSE_KEY_ERROR.format(write_only_key=key)}\n\nReference:" - f" {test_config.reference}.object:key:{key}\n\nHint:" - f" Remove the key from your API {test_config.http_message}, or" + f"\n\n{test_config.reference} > {key}" + f"\n\n{test_config.http_message.capitalize()} body:\n {json.dumps(data, indent=4)}" + f"\nSchema section:\n {json.dumps(properties, indent=4)}" + f"\n\nHint: Remove the key from your API {test_config.http_message}, or" ' remove the "WriteOnly" restriction' ) for key, value in data.items(): if key in properties: - test_config.reference = f"{test_config.reference}.object:key:{key}" + drill_down_test_config = copy(test_config) + drill_down_test_config.reference = f"{test_config.reference} > {key}" self.test_schema_section( schema_section=properties[key], data=value, - test_config=test_config, + test_config=drill_down_test_config, ) elif isinstance(additional_properties, dict): - test_config.reference = f"{test_config.reference}.object:key:{key}" + drill_down_test_config = copy(test_config) + drill_down_test_config.reference = f"{test_config.reference} > {key}" self.test_schema_section( schema_section=additional_properties, data=value, - test_config=test_config, + test_config=drill_down_test_config, ) def test_openapi_array( self, schema_section: dict[str, Any], data: dict, test_config: OpenAPITestConfig ) -> None: - for datum in data: - test_config.reference = f"{test_config.reference}.array.item" + for array_item in data: + array_item_test_config = copy(test_config) + array_item_test_config.reference = f"{test_config.reference}" self.test_schema_section( # the items keyword is required in arrays schema_section=schema_section["items"], - data=datum, - test_config=test_config, + data=array_item, + test_config=array_item_test_config, ) def validate_request( @@ -585,11 +630,17 @@ def validate_request( 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") - request_body_schema = self.get_request_body_schema_section(response.request) # type: ignore + test_config = OpenAPITestConfig( + http_message="request", + reference=f"{request['REQUEST_METHOD']} {request['PATH_INFO']} > request", + ) + request_body_schema = self.get_request_body_schema_section( + request, test_config=test_config + ) if request_body_schema: self.test_schema_section( @@ -618,8 +669,14 @@ def validate_response( if test_config: test_config.http_message = "response" else: - test_config = OpenAPITestConfig(http_message="response") - response_schema = self.get_response_schema_section(response_handler) + request = response.request # type: ignore + test_config = OpenAPITestConfig( + http_message="response", + reference=f"{request['REQUEST_METHOD']} {request['PATH_INFO']} > response > {response.status_code}", + ) + response_schema = self.get_response_schema_section( + response_handler, test_config=test_config + ) self.test_schema_section( schema_section=response_schema, data=response_handler.data, diff --git a/openapi_tester/validators.py b/openapi_tester/validators.py index e86a95a9..4001fcd5 100644 --- a/openapi_tester/validators.py +++ b/openapi_tester/validators.py @@ -91,12 +91,23 @@ def wrapped(value: Any) -> bool: def validate_type(schema_section: dict[str, Any], data: Any) -> str | None: - schema_type: str = schema_section.get("type", "object") - if not VALIDATOR_MAP[schema_type](data): - an_articles = ["integer", "object", "array"] + an_articles = ["integer", "object", "array"] + schema_types: str = schema_section.get("type", "object") + if isinstance(schema_types, list): + has_type = False + for schema_type in schema_types: + if VALIDATOR_MAP[schema_type](data): + return None + if not has_type: + return VALIDATE_TYPE_ERROR.format( + article="a" if schema_types not in an_articles else "an", + type=schema_types, + received=f'"{data}"' if isinstance(data, str) else data, + ) + if not VALIDATOR_MAP[schema_types](data): return VALIDATE_TYPE_ERROR.format( - article="a" if schema_type not in an_articles else "an", - type=schema_type, + article="a" if schema_types not in an_articles else "an", + type=schema_types, received=f'"{data}"' if isinstance(data, str) else data, ) return None diff --git a/test_project/api/serializers.py b/test_project/api/serializers.py index 8dcac254..fcbbae05 100644 --- a/test_project/api/serializers.py +++ b/test_project/api/serializers.py @@ -9,8 +9,8 @@ class Meta: class PetsSerializer(serializers.Serializer): - name = serializers.CharField(max_length=254) - tag = serializers.CharField(max_length=254, required=False) + name = serializers.CharField(max_length=254, required=False, allow_null=True) + tag = serializers.CharField(max_length=254, required=False, allow_null=True) class ItemSerializer(serializers.Serializer): diff --git a/tests/conftest.py b/tests/conftest.py index ae28fe1d..e3be2e33 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -24,6 +24,11 @@ def pets_api_schema_prefix_in_server() -> Path: return TEST_ROOT / "schemas" / "openapi_v3_prefix_in_server.yaml" +@pytest.fixture +def cars_api_schema() -> Path: + return TEST_ROOT / "schemas" / "spectactular_reference_schema.yaml" + + @pytest.fixture def pets_post_request(): request_body = MagicMock() diff --git a/tests/schema_converter.py b/tests/schema_converter.py index aa22006a..75ac4aab 100644 --- a/tests/schema_converter.py +++ b/tests/schema_converter.py @@ -82,6 +82,9 @@ def schema_type_to_mock_value(self, schema_object: dict[str, Any]) -> Any: minimum: int | float | None = schema_object.get("minimum") maximum: int | float | None = schema_object.get("maximum") enum: list | None = schema_object.get("enum") + if isinstance(schema_type, list): + for s_type in schema_type: + return faker_handler_map[s_type]() if enum: return random.sample(enum, 1)[0] if schema_type in ["integer", "number"] and ( diff --git a/tests/schemas/openapi_v3_reference_schema.yaml b/tests/schemas/openapi_v3_reference_schema.yaml index 460f4dfc..ca7b8b90 100644 --- a/tests/schemas/openapi_v3_reference_schema.yaml +++ b/tests/schemas/openapi_v3_reference_schema.yaml @@ -1,4 +1,4 @@ -openapi: "3.0.0" +openapi: "3.1.0" info: version: 1.0.0 title: Swagger Petstore @@ -153,7 +153,9 @@ components: - name properties: name: - type: string + type: + - string + - 'null' tag: type: string diff --git a/tests/test_clients.py b/tests/test_clients.py index 8e18f7c7..f27a5796 100644 --- a/tests/test_clients.py +++ b/tests/test_clients.py @@ -33,78 +33,60 @@ def test_init_schema_tester_passed(): assert client.schema_tester is schema_tester -@pytest.mark.parametrize( - ("generic_kwargs", "expected_status_code"), - [ - ( - {"method": "GET", "path": "/api/v1/cars/correct"}, - status.HTTP_200_OK, - ), - ( - { - "method": "POST", - "path": "/api/v1/vehicles", - "data": json.dumps({"vehicle_type": "suv"}), - "content_type": "application/json", - }, - status.HTTP_201_CREATED, - ), - ], -) -def test_request(openapi_client, generic_kwargs, expected_status_code): - """Ensure ``SchemaTester`` doesn't raise exception when response valid.""" - response = openapi_client.generic(**generic_kwargs) +def test_get_request(cars_api_schema: "Path"): + schema_tester = SchemaTester(schema_file_path=str(cars_api_schema)) + openapi_client = OpenAPIClient(schema_tester=schema_tester) + response = openapi_client.get(path="/api/v1/cars/correct") - assert response.status_code == expected_status_code + assert response.status_code == status.HTTP_200_OK -@pytest.mark.parametrize( - ("generic_kwargs", "expected_status_code"), - [ - ( - { - "method": "POST", - "path": "/api/pets", - "data": json.dumps({"name": "doggie"}), - "content_type": "application/json", - }, - status.HTTP_201_CREATED, - ), - ( - { - "method": "POST", - "path": "/api/pets", - "data": json.dumps({"tag": "doggie"}), - "content_type": "application/json", - }, - status.HTTP_400_BAD_REQUEST, - ), - ], -) -def test_request_body(generic_kwargs, expected_status_code, pets_api_schema: "Path"): - """Ensure ``SchemaTester`` doesn't raise exception when request valid. - Additionally, request validation should be performed only in successful responses.""" +def test_post_request(openapi_client): + response = openapi_client.post( + path="/api/v1/vehicles", data={"vehicle_type": "suv"} + ) + + assert response.status_code == status.HTTP_201_CREATED + + +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.generic(**generic_kwargs) + response = openapi_client.post(path="/api/pets", data={"name": False}) - assert response.status_code == expected_status_code + assert response.status_code == status.HTTP_400_BAD_REQUEST def test_request_body_extra_non_documented_field(pets_api_schema: "Path"): - """Ensure ``SchemaTester`` raises exception when request is successfull but an + """Ensure ``SchemaTester`` raises exception when request is successful but an extra field non-documented was sent.""" schema_tester = SchemaTester(schema_file_path=str(pets_api_schema)) openapi_client = OpenAPIClient(schema_tester=schema_tester) - kwargs = { - "method": "POST", - "path": "/api/pets", - "data": json.dumps({"name": "doggie", "age": 1}), - "content_type": "application/json", - } with pytest.raises(DocumentationError): - openapi_client.generic(**kwargs) # type: ignore + openapi_client.post(path="/api/pets", data={"name": "doggie", "age": 1}) + + +def test_request_body_non_null_fields(pets_api_schema: "Path"): + schema_tester = SchemaTester(schema_file_path=str(pets_api_schema)) + openapi_client = OpenAPIClient(schema_tester=schema_tester) + + with pytest.raises(DocumentationError): + openapi_client.post(path="/api/pets", data={"name": "doggie", "tag": None}) + + +def test_request_multiple_types_supported(pets_api_schema: "Path"): + schema_tester = SchemaTester(schema_file_path=str(pets_api_schema)) + openapi_client = OpenAPIClient(schema_tester=schema_tester) + + openapi_client.post(path="/api/pets", data={"name": "doggie", "tag": "pet"}) + + +def test_request_multiple_types_null_type_allowed(pets_api_schema: "Path"): + schema_tester = SchemaTester(schema_file_path=str(pets_api_schema)) + openapi_client = OpenAPIClient(schema_tester=schema_tester) + + openapi_client.post(path="/api/pets", data={"name": None, "tag": "pet"}) def test_request_on_empty_list(openapi_client): diff --git a/tests/test_django_ninja.py b/tests/test_django_ninja.py index 22aea887..bb61e494 100644 --- a/tests/test_django_ninja.py +++ b/tests/test_django_ninja.py @@ -42,7 +42,7 @@ def test_create_user(client: OpenAPIClient): } response = client.post( path="/ninja_api/users/", - data=json.dumps(payload), + data=payload, content_type="application/json", ) assert response.status_code == 201 @@ -57,7 +57,7 @@ def test_update_user(client: OpenAPIClient): } response = client.put( path="/ninja_api/users/1", - data=json.dumps(payload), + data=payload, content_type="application/json", ) assert response.status_code == 200 diff --git a/tests/test_errors.py b/tests/test_errors.py index b3885788..fb7c9b4e 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -175,57 +175,71 @@ def test_validate_type_error(self): class TestTestOpenAPIObjectErrors: def test_missing_response_key_error(self): expected_error_message = ( - 'The following property is missing in the response data: "one"\n\n' - "Reference: init.object:key:one\n\n" - "Hint: Remove the key from your OpenAPI docs, or include it in your API response" + 'The following property was found in the schema definition, but is missing from the response data: "one"' + "\n\nReference:\n\nPOST /endpoint > response > one" + '\n\nResponse body:\n {\n "two": 2\n}' + '\nSchema section:\n {\n "one": {\n "type": "int"\n }\n}' + "\n\nHint: Remove the key from your OpenAPI docs, or include it in your API response" ) tester = SchemaTester() with pytest.raises(DocumentationError, match=expected_error_message): tester.test_openapi_object( {"required": ["one"], "properties": {"one": {"type": "int"}}}, {"two": 2}, - OpenAPITestConfig(reference="init"), + OpenAPITestConfig(reference="POST /endpoint > response"), ) def test_missing_schema_key_error(self): expected_error_message = ( - 'The following property was found in the response, but is missing from the schema definition: "two"\n\n' - "Reference: init.object:key:two\n\n" - "Hint: Remove the key from your API response, or include it in your OpenAPI docs" + 'The following property was found in the response data, but is missing from the schema definition: "two"' + "\n\nReference:" + "\n\nPOST /endpoint > response > two" + '\n\nResponse body:\n {\n "one": 1,\n "two": 2\n}' + '\n\nSchema section:\n {\n "one": {\n "type": "int"\n }\n}' + "\n\nHint: Remove the key from your API response, or include it in your OpenAPI docs" ) tester = SchemaTester() with pytest.raises(DocumentationError, match=expected_error_message): tester.test_openapi_object( {"required": ["one"], "properties": {"one": {"type": "int"}}}, {"one": 1, "two": 2}, - OpenAPITestConfig(reference="init"), + OpenAPITestConfig(reference="POST /endpoint > response"), ) def test_key_in_write_only_properties_error(self): expected_error_message = ( - 'The following property was found in the response, but is documented as being "writeOnly": "one"\n\n' - "Reference: init.object:key:one\n\n" - 'Hint: Remove the key from your API response, or remove the "WriteOnly" restriction' + 'The following property was found in the response, but is documented as being "writeOnly": "one"' + "\n\nReference:" + "\n\nPOST /endpoint > response > one" + '\n\nResponse body:\n {\n "one": 1\n}' + '\nSchema section:\n {\n "one": {\n "type": "int",\n "writeOnly": true\n }\n}' + '\n\nHint: Remove the key from your API response, or remove the "WriteOnly" restriction' ) tester = SchemaTester() with pytest.raises(DocumentationError, match=expected_error_message): tester.test_openapi_object( {"properties": {"one": {"type": "int", "writeOnly": True}}}, {"one": 1}, - OpenAPITestConfig(reference="init"), + OpenAPITestConfig(reference="POST /endpoint > response"), ) def test_null_error(): expected_error_message = ( - "Received a null value for a non-nullable schema object\n\n" - "Reference: init\n\n" - "Hint: Return a valid type, or document the value as nullable" + "A property received a null value in the response data, but is a non-nullable object in the schema definition" + "\n\nReference:" + "\n\nPOST /endpoint > response > nonNullableObject" + '\n\nSchema description:\n {\n "type": "object"\n}' + "\n\nHint: Return a valid type, or document the value as nullable" ) tester = SchemaTester() with pytest.raises(DocumentationError, match=expected_error_message): tester.test_schema_section( - {"type": "object"}, None, OpenAPITestConfig(reference="init") + {"type": "object"}, + None, + OpenAPITestConfig( + reference="POST /endpoint > response > nonNullableObject" + ), ) diff --git a/tests/test_schema_tester.py b/tests/test_schema_tester.py index ad23875b..6e92d62f 100644 --- a/tests/test_schema_tester.py +++ b/tests/test_schema_tester.py @@ -193,7 +193,10 @@ def test_example_schemas(filename): schema_tester.validate_response(response) response_handler = ResponseHandlerFactory.create(response) assert sorted( - schema_tester.get_response_schema_section(response_handler) + schema_tester.get_response_schema_section( + response_handler, + test_config=OpenAPITestConfig(case_tester=is_pascal_case), + ) ) == sorted(schema_section) @@ -204,7 +207,10 @@ def test_validate_response_failure_scenario_with_predefined_data(client): assert response.json() == item["expected_response"] with pytest.raises( DocumentationError, - match='The following property is missing in the response data: "width"', + match=( + "The following property was found in the schema definition, " + 'but is missing from the response data: "width"' + ), ): tester.validate_response(response) @@ -268,8 +274,9 @@ 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\n" - f"No `content` defined for this response: {method}, path: {parameterized_path}" + "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}" ), ): tester.validate_response(response) @@ -388,12 +395,15 @@ def test_is_openapi_schema_false(): def test_get_request_body_schema_section( pets_post_request: dict[str, Any], 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) + schema_section = schema_tester.get_request_body_schema_section( + pets_post_request, test_config=test_config + ) assert schema_section == { "type": "object", "required": ["name"], - "properties": {"name": {"type": "string"}, "tag": {"type": "string"}}, + "properties": {"name": {"type": ["string", "null"]}, "tag": {"type": "string"}}, } @@ -401,17 +411,23 @@ def test_get_request_body_schema_section_content_type_no_application_json( pets_post_request: dict[str, Any], 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" - schema_section = schema_tester.get_request_body_schema_section(pets_post_request) + schema_section = schema_tester.get_request_body_schema_section( + 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 ): + test_config = OpenAPITestConfig(case_tester=is_pascal_case) schema_tester = SchemaTester(schema_file_path=str(pets_api_schema)) del pets_post_request["wsgi.input"] - schema_section = schema_tester.get_request_body_schema_section(pets_post_request) + schema_section = schema_tester.get_request_body_schema_section( + pets_post_request, test_config=test_config + ) assert schema_section == {} @@ -468,7 +484,7 @@ def test_nullable_validation(): with pytest.raises( DocumentationError, match=VALIDATE_NONE_ERROR.format( - expected=OPENAPI_PYTHON_MAPPING[schema["type"]] + expected=OPENAPI_PYTHON_MAPPING[schema["type"]], http_message="response" ), ): tester.test_schema_section(schema, None)