Skip to content

Commit

Permalink
Merge pull request #18 from maticardenas/14-add-support-for-django-ni…
Browse files Browse the repository at this point in the history
…nja-test-client

Django ninja support
  • Loading branch information
maticardenas committed Feb 29, 2024
2 parents 83924b6 + 6587d4b commit 9624522
Show file tree
Hide file tree
Showing 15 changed files with 966 additions and 406 deletions.
5 changes: 2 additions & 3 deletions .github/workflows/testing.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,11 @@ jobs:
path: |
.venv
~/.cache/pre-commit
key: venv-3 # increment to reset
key: ${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock') }}-6
- run: |
python -m venv .venv --upgrade-deps
source .venv/bin/activate
pip install pre-commit
if: steps.cache.outputs.cache-hit != 'true'
- run: |
source .venv/bin/activate
pre-commit run --all-files
Expand Down Expand Up @@ -61,7 +60,7 @@ jobs:
python -m venv .venv
source .venv/bin/activate
pip install wheel setuptools pip -U
poetry install --no-interaction --no-root --extras drf-spectacular --extras drf-yasg
poetry install --no-interaction --no-root --extras drf-spectacular --extras drf-yasg --extras django-ninja
if: steps.cache-venv.outputs.cache-hit != 'true'
- run: |
source .venv/bin/activate
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
[![PyPI](https://img.shields.io/pypi/v/django-contract-tester.svg)](https://pypi.org/project/django-contract-tester/)
[![Coverage](https://codecov.io/gh/maticardenas/django-contract-tester/graph/badge.svg)](https://app.codecov.io/gh/maticardenas/django-contract-tester)
[![Python versions](https://img.shields.io/badge/Python-3.7%2B-blue)](https://pypi.org/project/django-contract-tester/)
[![Django versions](https://img.shields.io/badge/Django-3.0%2B-blue)](https://pypi.org/project/django-contract-tester/)
[![Python versions](https://img.shields.io/badge/Python-3.8%2B-blue)](https://pypi.org/project/django-contract-tester/)
[![Django versions](https://img.shields.io/badge/Django-3.2%2B-blue)](https://pypi.org/project/django-contract-tester/)


# Django Contract Tester

This is a test utility to validate DRF (Django REST Framework) 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 2 and 3 schema.

It has built-in support for:

Expand Down
17 changes: 17 additions & 0 deletions openapi_tester/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"""
Configuration module for schema section test.
"""

from dataclasses import dataclass
from typing import Any, Callable, List, Optional


@dataclass
class OpenAPITestConfig:
"""Configuration dataclass for schema section test."""

case_tester: Optional[Callable[[str], None]] = None
ignore_case: Optional[List[str]] = None
validators: Any = None
reference: str = "init"
http_message: str = "response"
66 changes: 66 additions & 0 deletions openapi_tester/response_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""
This module contains the concrete response handlers for both DRF and Django Ninja responses.
"""
import json
from typing import TYPE_CHECKING, Optional, Union

if TYPE_CHECKING:
from django.http.response import HttpResponse
from rest_framework.response import Response


class ResponseHandler:
"""
This class is used to handle the response and request data
from both DRF and Django HTTP (Django Ninja) responses.
"""

def __init__(self, response: Union["Response", "HttpResponse"]) -> None:
self._response = response

@property
def response(self) -> Union["Response", "HttpResponse"]:
return self._response

@property
def data(self) -> Optional[dict]:
...

@property
def request_data(self) -> Optional[dict]:
...


class DRFResponseHandler(ResponseHandler):
"""
Handles the response and request data from DRF responses.
"""

def __init__(self, response: "Response") -> None:
super().__init__(response)

@property
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]


class DjangoNinjaResponseHandler(ResponseHandler):
"""
Handles the response and request data from Django Ninja responses.
"""

def __init__(self, response: "HttpResponse") -> None:
super().__init__(response)

@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
28 changes: 28 additions & 0 deletions openapi_tester/response_handler_factory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# pylint: disable=R0903
"""
Module that contains the factory to create response handlers.
"""

from typing import TYPE_CHECKING, Union

from rest_framework.response import Response

from openapi_tester.response_handler import DjangoNinjaResponseHandler, DRFResponseHandler

if TYPE_CHECKING:
from django.http.response import HttpResponse

from openapi_tester.response_handler import ResponseHandler


class ResponseHandlerFactory:
"""
Response Handler Factory: this class is used to create a response handler
instance for both DRF and Django HTTP (Django Ninja) responses.
"""

@staticmethod
def create(response: Union[Response, "HttpResponse"]) -> "ResponseHandler":
if isinstance(response, Response):
return DRFResponseHandler(response)
return DjangoNinjaResponseHandler(response)
36 changes: 17 additions & 19 deletions openapi_tester/schema_tester.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@
from __future__ import annotations

import re
from dataclasses import dataclass
from itertools import chain
from typing import TYPE_CHECKING, Any, Callable, cast

from django.conf import settings
from django.core.exceptions import ImproperlyConfigured, ValidationError
from django.core.validators import URLValidator

from openapi_tester.config import OpenAPITestConfig
from openapi_tester.constants import (
INIT_ERROR,
UNDOCUMENTED_SCHEMA_SECTION_ERROR,
Expand All @@ -27,6 +27,7 @@
StaticSchemaLoader,
UrlStaticSchemaLoader,
)
from openapi_tester.response_handler_factory import ResponseHandlerFactory
from openapi_tester.utils import lazy_combinations, normalize_schema_section
from openapi_tester.validators import (
validate_enum,
Expand All @@ -48,18 +49,10 @@
if TYPE_CHECKING:
from typing import Optional

from django.http.response import HttpResponse
from rest_framework.response import Response


@dataclass
class OpenAPITestConfig:
"""Configuration dataclass for schema section test."""

case_tester: Callable[[str], None] | None = None
ignore_case: list[str] | None = None
validators: list[Callable[[dict[str, Any], Any], str | None]] | None = None
reference: str = "init"
http_message: str = "response"
from openapi_tester.response_handler import ResponseHandler


class SchemaTester:
Expand Down Expand Up @@ -139,13 +132,14 @@ def get_schema_type(schema: dict[str, str]) -> str | None:
return "object"
return None

def get_response_schema_section(self, response: Response) -> dict[str, Any]:
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.
:param response: DRF Response Instance
:return dict
"""
response = response_handler.response
schema = self.loader.get_schema()

response_method = response.request["REQUEST_METHOD"].lower() # type: ignore
Expand Down Expand Up @@ -200,7 +194,7 @@ def get_response_schema_section(self, response: Response) -> dict[str, Any]:
)
return self.get_key_value(json_object, "schema")

if response.data and response.json(): # type: ignore
if response_handler.data:
raise UndocumentedSchemaSectionError(
UNDOCUMENTED_SCHEMA_SECTION_ERROR.format(
key="content",
Expand Down Expand Up @@ -361,7 +355,7 @@ def test_schema_section(
"""
test_config = test_config or OpenAPITestConfig()
if data is None:
if self.test_is_nullable(schema_section):
if self.test_is_nullable(schema_section) or not schema_section:
# If data is None and nullable, we return early
return
raise DocumentationError(
Expand Down Expand Up @@ -488,7 +482,7 @@ def test_openapi_array(self, schema_section: dict[str, Any], data: dict, test_co

def validate_request(
self,
response: Response,
response: Response | HttpResponse,
test_config: OpenAPITestConfig | None = None,
) -> None:
"""
Expand All @@ -502,23 +496,25 @@ 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
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

if request_body_schema:
self.test_schema_section(
schema_section=request_body_schema,
data=response.renderer_context["request"].data, # type: ignore
data=response_handler.request_data,
test_config=test_config,
)

def validate_response(
self,
response: Response,
response: Response | HttpResponse,
test_config: OpenAPITestConfig | None = None,
) -> None:
"""
Expand All @@ -531,13 +527,15 @@ 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:
test_config = OpenAPITestConfig(http_message="response")
response_schema = self.get_response_schema_section(response)
response_schema = self.get_response_schema_section(response_handler)
self.test_schema_section(
schema_section=response_schema,
data=response.json() if response.data is not None else {}, # type: ignore
data=response_handler.data,
test_config=test_config,
)

0 comments on commit 9624522

Please sign in to comment.