From 7817a6482d8809ecc0a15a6baf251185c66f3a3b Mon Sep 17 00:00:00 2001 From: p1c2u Date: Tue, 8 Jun 2021 08:55:33 +0100 Subject: [PATCH] Django2 support drop --- docs/integrations.rst | 23 +- openapi_core/contrib/django/__init__.py | 6 +- openapi_core/contrib/django/backports.py | 27 -- openapi_core/contrib/django/compat.py | 22 -- openapi_core/contrib/django/handlers.py | 45 +++ openapi_core/contrib/django/middlewares.py | 60 +++ openapi_core/contrib/django/requests.py | 68 ++-- openapi_core/contrib/django/responses.py | 27 +- requirements_dev.txt | 2 +- setup.cfg | 3 +- tests/integration/contrib/django/conftest.py | 22 -- .../data/djangoproject/testapp/views.py | 44 --- .../contrib/django/data/openapi.yaml | 32 -- .../data/{ => v3.0}/djangoproject/__init__.py | 0 .../django/data/v3.0/djangoproject/auth.py | 17 + .../djangoproject/pets}/__init__.py | 0 .../pets}/migrations/__init__.py | 0 .../data/v3.0/djangoproject/pets/views.py | 81 ++++ .../data/{ => v3.0}/djangoproject/settings.py | 25 +- .../data/{ => v3.0}/djangoproject/urls.py | 13 +- .../contrib/django/test_django_project.py | 350 ++++++++++++++++++ .../test_django_rest_framework_apiview.py | 18 - tests/integration/data/v3.0/petstore.yaml | 2 +- tests/integration/validation/test_petstore.py | 28 +- .../integration/validation/test_validators.py | 20 +- .../contrib/django}/test_django.py | 40 +- 26 files changed, 696 insertions(+), 279 deletions(-) delete mode 100644 openapi_core/contrib/django/backports.py delete mode 100644 openapi_core/contrib/django/compat.py create mode 100644 openapi_core/contrib/django/handlers.py create mode 100644 openapi_core/contrib/django/middlewares.py delete mode 100644 tests/integration/contrib/django/conftest.py delete mode 100644 tests/integration/contrib/django/data/djangoproject/testapp/views.py delete mode 100644 tests/integration/contrib/django/data/openapi.yaml rename tests/integration/contrib/django/data/{ => v3.0}/djangoproject/__init__.py (100%) create mode 100644 tests/integration/contrib/django/data/v3.0/djangoproject/auth.py rename tests/integration/contrib/django/data/{djangoproject/testapp => v3.0/djangoproject/pets}/__init__.py (100%) rename tests/integration/contrib/django/data/{djangoproject/testapp => v3.0/djangoproject/pets}/migrations/__init__.py (100%) create mode 100644 tests/integration/contrib/django/data/v3.0/djangoproject/pets/views.py rename tests/integration/contrib/django/data/{ => v3.0}/djangoproject/settings.py (79%) rename tests/integration/contrib/django/data/{ => v3.0}/djangoproject/urls.py (78%) create mode 100644 tests/integration/contrib/django/test_django_project.py delete mode 100644 tests/integration/contrib/django/test_django_rest_framework_apiview.py rename tests/{integration/contrib => unit/contrib/django}/test_django.py (79%) diff --git a/docs/integrations.rst b/docs/integrations.rst index fbbcaf1c..73ba3d2f 100644 --- a/docs/integrations.rst +++ b/docs/integrations.rst @@ -11,8 +11,29 @@ Django ------ This section describes integration with `Django `__ web framework. +The integration supports Django from version 3.0 and above. -For Django 2.2 you can use DjangoOpenAPIRequest a Django request factory: +Middleware +~~~~~~~~~~ + +Django can be integrated by middleware. Add `DjangoOpenAPIMiddleware` to your `MIDDLEWARE` list and define `OPENAPI_SPEC` + +.. code-block:: python + + # settings.py + from openapi_core import create_spec + + MIDDLEWARE = [ + # ... + 'openapi_core.contrib.django.middlewares.DjangoOpenAPIMiddleware', + ] + + OPENAPI_SPEC = create_spec(spec_dict) + +Low level +~~~~~~~~~ + +For Django you can use DjangoOpenAPIRequest a Django request factory: .. code-block:: python diff --git a/openapi_core/contrib/django/__init__.py b/openapi_core/contrib/django/__init__.py index dbbd8f0b..93ef7cfc 100644 --- a/openapi_core/contrib/django/__init__.py +++ b/openapi_core/contrib/django/__init__.py @@ -1,9 +1,9 @@ +"""OpenAPI core contrib django module""" from openapi_core.contrib.django.requests import DjangoOpenAPIRequestFactory from openapi_core.contrib.django.responses import DjangoOpenAPIResponseFactory -# backward compatibility -DjangoOpenAPIRequest = DjangoOpenAPIRequestFactory.create -DjangoOpenAPIResponse = DjangoOpenAPIResponseFactory.create +DjangoOpenAPIRequest = DjangoOpenAPIRequestFactory().create +DjangoOpenAPIResponse = DjangoOpenAPIResponseFactory().create __all__ = [ 'DjangoOpenAPIRequestFactory', 'DjangoOpenAPIResponseFactory', diff --git a/openapi_core/contrib/django/backports.py b/openapi_core/contrib/django/backports.py deleted file mode 100644 index 050512a9..00000000 --- a/openapi_core/contrib/django/backports.py +++ /dev/null @@ -1,27 +0,0 @@ -"""OpenAPI core contrib django backports module""" - - -class HttpHeaders(dict): - HTTP_PREFIX = 'HTTP_' - # PEP 333 gives two headers which aren't prepended with HTTP_. - UNPREFIXED_HEADERS = {'CONTENT_TYPE', 'CONTENT_LENGTH'} - - def __init__(self, environ): - headers = {} - for header, value in list(environ.items()): - name = self.parse_header_name(header) - if name: - headers[name] = value - super().__init__(headers) - - @classmethod - def parse_header_name(cls, header): - if header.startswith(cls.HTTP_PREFIX): - header = header[len(cls.HTTP_PREFIX):] - elif header not in cls.UNPREFIXED_HEADERS: - return None - return header.replace('_', '-').title() - - -def request_current_scheme_host(req): - return f'{req.scheme}://{req.get_host()}' diff --git a/openapi_core/contrib/django/compat.py b/openapi_core/contrib/django/compat.py deleted file mode 100644 index 3df701f0..00000000 --- a/openapi_core/contrib/django/compat.py +++ /dev/null @@ -1,22 +0,0 @@ -"""OpenAPI core contrib django compat module""" -from openapi_core.contrib.django.backports import ( - HttpHeaders, request_current_scheme_host, -) - - -def get_request_headers(req): - # in Django 1 headers is not defined - return req.headers if hasattr(req, 'headers') else \ - HttpHeaders(req.META) - - -def get_response_headers(resp): - # in Django 2 headers is not defined - return resp.headers if hasattr(resp, 'headers') else \ - dict(list(resp._headers.values())) - - -def get_current_scheme_host(req): - # in Django 1 _current_scheme_host is not defined - return req._current_scheme_host if hasattr(req, '_current_scheme_host') \ - else request_current_scheme_host(req) diff --git a/openapi_core/contrib/django/handlers.py b/openapi_core/contrib/django/handlers.py new file mode 100644 index 00000000..664ae6a1 --- /dev/null +++ b/openapi_core/contrib/django/handlers.py @@ -0,0 +1,45 @@ +"""OpenAPI core contrib django handlers module""" +from django.http import JsonResponse + +from openapi_core.exceptions import MissingRequiredParameter +from openapi_core.templating.media_types.exceptions import MediaTypeNotFound +from openapi_core.templating.paths.exceptions import ( + ServerNotFound, OperationNotFound, PathNotFound, +) +from openapi_core.validation.exceptions import InvalidSecurity + + +class DjangoOpenAPIErrorsHandler: + + OPENAPI_ERROR_STATUS = { + MissingRequiredParameter: 400, + ServerNotFound: 400, + InvalidSecurity: 403, + OperationNotFound: 405, + PathNotFound: 404, + MediaTypeNotFound: 415, + } + + @classmethod + def handle(cls, errors, req, resp=None): + data_errors = [ + cls.format_openapi_error(err) + for err in errors + ] + data = { + 'errors': data_errors, + } + data_error_max = max(data_errors, key=cls.get_error_status) + return JsonResponse(data, status=data_error_max['status']) + + @classmethod + def format_openapi_error(cls, error): + return { + 'title': str(error), + 'status': cls.OPENAPI_ERROR_STATUS.get(error.__class__, 400), + 'class': str(type(error)), + } + + @classmethod + def get_error_status(cls, error): + return error['status'] diff --git a/openapi_core/contrib/django/middlewares.py b/openapi_core/contrib/django/middlewares.py new file mode 100644 index 00000000..fc2fb23e --- /dev/null +++ b/openapi_core/contrib/django/middlewares.py @@ -0,0 +1,60 @@ +"""OpenAPI core contrib django middlewares module""" +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured + +from openapi_core.contrib.django.handlers import DjangoOpenAPIErrorsHandler +from openapi_core.contrib.django.requests import DjangoOpenAPIRequestFactory +from openapi_core.contrib.django.responses import DjangoOpenAPIResponseFactory +from openapi_core.validation.processors import OpenAPIProcessor +from openapi_core.validation.request.validators import RequestValidator +from openapi_core.validation.response.validators import ResponseValidator + + +class DjangoOpenAPIMiddleware: + + request_factory = DjangoOpenAPIRequestFactory() + response_factory = DjangoOpenAPIResponseFactory() + errors_handler = DjangoOpenAPIErrorsHandler() + + def __init__(self, get_response): + self.get_response = get_response + + if not hasattr(settings, 'OPENAPI_SPEC'): + raise ImproperlyConfigured("OPENAPI_SPEC not defined in settings") + + request_validator = RequestValidator(settings.OPENAPI_SPEC) + response_validator = ResponseValidator(settings.OPENAPI_SPEC) + self.validation_processor = OpenAPIProcessor( + request_validator, response_validator) + + def __call__(self, request): + openapi_request = self._get_openapi_request(request) + req_result = self.validation_processor.process_request(openapi_request) + if req_result.errors: + return self._handle_request_errors(req_result, request) + + request.openapi = req_result + + response = self.get_response(request) + + openapi_response = self._get_openapi_response(response) + resp_result = self.validation_processor.process_response( + openapi_request, openapi_response) + if resp_result.errors: + return self._handle_response_errors(resp_result, request, response) + + return response + + def _handle_request_errors(self, request_result, req): + return self.errors_handler.handle( + request_result.errors, req, None) + + def _handle_response_errors(self, response_result, req, resp): + return self.errors_handler.handle( + response_result.errors, req, resp) + + def _get_openapi_request(self, request): + return self.request_factory.create(request) + + def _get_openapi_response(self, response): + return self.response_factory.create(response) diff --git a/openapi_core/contrib/django/requests.py b/openapi_core/contrib/django/requests.py index d067ce55..7cc59152 100644 --- a/openapi_core/contrib/django/requests.py +++ b/openapi_core/contrib/django/requests.py @@ -4,9 +4,6 @@ from werkzeug.datastructures import ImmutableMultiDict, Headers -from openapi_core.contrib.django.compat import ( - get_request_headers, get_current_scheme_host, -) from openapi_core.validation.request.datatypes import ( RequestParameters, OpenAPIRequest, ) @@ -28,14 +25,40 @@ class DjangoOpenAPIRequestFactory: path_regex = re.compile(PATH_PARAMETER_PATTERN) - @classmethod - def create(cls, request): - method = request.method.lower() + def create(self, request): + return OpenAPIRequest( + full_url_pattern=self._get_full_url_pattern(request), + method=self._get_method(request), + parameters=self._get_parameters(request), + body=self._get_body(request), + mimetype=self._get_mimetype(request), + ) + + def _get_parameters(self, request): + return RequestParameters( + path=self._get_path(request), + query=self._get_query(request), + header=self._get_header(request), + cookie=self._get_cookie(request), + ) + + def _get_path(self, request): + return request.resolver_match and request.resolver_match.kwargs or {} + + def _get_query(self, request): + return ImmutableMultiDict(request.GET) + + def _get_header(self, request): + return Headers(request.headers.items()) + def _get_cookie(self, request): + return ImmutableMultiDict(dict(request.COOKIES)) + + def _get_full_url_pattern(self, request): if request.resolver_match is None: path_pattern = request.path else: - route = cls.path_regex.sub( + route = self.path_regex.sub( r'{\1}', request.resolver_match.route) # Delete start and end marker to allow concatenation. if route[:1] == "^": @@ -44,23 +67,14 @@ def create(cls, request): route = route[:-1] path_pattern = '/' + route - request_headers = get_request_headers(request) - path = request.resolver_match and request.resolver_match.kwargs or {} - query = ImmutableMultiDict(request.GET) - header = Headers(request_headers.items()) - cookie = ImmutableMultiDict(dict(request.COOKIES)) - parameters = RequestParameters( - path=path, - query=query, - header=header, - cookie=cookie, - ) - current_scheme_host = get_current_scheme_host(request) - full_url_pattern = urljoin(current_scheme_host, path_pattern) - return OpenAPIRequest( - full_url_pattern=full_url_pattern, - method=method, - parameters=parameters, - body=request.body, - mimetype=request.content_type, - ) + current_scheme_host = request._current_scheme_host + return urljoin(current_scheme_host, path_pattern) + + def _get_method(self, request): + return request.method.lower() + + def _get_body(self, request): + return request.body + + def _get_mimetype(self, request): + return request.content_type diff --git a/openapi_core/contrib/django/responses.py b/openapi_core/contrib/django/responses.py index d32c7566..9dbb448a 100644 --- a/openapi_core/contrib/django/responses.py +++ b/openapi_core/contrib/django/responses.py @@ -1,20 +1,27 @@ """OpenAPI core contrib django responses module""" from werkzeug.datastructures import Headers -from openapi_core.contrib.django.compat import get_response_headers from openapi_core.validation.response.datatypes import OpenAPIResponse class DjangoOpenAPIResponseFactory: - @classmethod - def create(cls, response): - mimetype = response["Content-Type"] - headers = get_response_headers(response) - header = Headers(headers.items()) + def create(self, response): return OpenAPIResponse( - data=response.content, - status_code=response.status_code, - headers=header, - mimetype=mimetype, + data=self._get_data(response), + status_code=self._get_status_code(response), + headers=self._get_header(response), + mimetype=self._get_mimetype(response), ) + + def _get_data(self, response): + return response.content + + def _get_status_code(self, response): + return response.status_code + + def _get_header(self, response): + return Headers(response.headers.items()) + + def _get_mimetype(self, response): + return response["Content-Type"] diff --git a/requirements_dev.txt b/requirements_dev.txt index bc341175..77363781 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -3,7 +3,7 @@ pytest-flake8 pytest-cov==2.5.1 falcon==3.0.1 flask -django==2.2.24 +django==3.2.4 djangorestframework==3.11.2 requests==2.22.0 responses==0.10.12 diff --git a/setup.cfg b/setup.cfg index 1d775e51..0e4b92b0 100644 --- a/setup.cfg +++ b/setup.cfg @@ -37,6 +37,7 @@ tests_require = pytest>=5.0.0 pytest-flake8 pytest-cov + django>=3.0 falcon>=3.0 flask responses @@ -47,7 +48,7 @@ exclude = tests [options.extras_require] -django = django>=2.2 +django = django>=3.0 falcon = falcon>=3.0 flask = flask requests = requests diff --git a/tests/integration/contrib/django/conftest.py b/tests/integration/contrib/django/conftest.py deleted file mode 100644 index dc172d00..00000000 --- a/tests/integration/contrib/django/conftest.py +++ /dev/null @@ -1,22 +0,0 @@ -import os -import sys -from unittest import mock - -import pytest - - -@pytest.fixture(autouse=True, scope='module') -def django_setup(): - directory = os.path.abspath(os.path.dirname(__file__)) - django_project_dir = os.path.join(directory, 'data') - sys.path.insert(0, django_project_dir) - with mock.patch.dict( - os.environ, - { - 'DJANGO_SETTINGS_MODULE': 'djangoproject.settings', - } - ): - import django - django.setup() - yield - sys.path.remove(django_project_dir) diff --git a/tests/integration/contrib/django/data/djangoproject/testapp/views.py b/tests/integration/contrib/django/data/djangoproject/testapp/views.py deleted file mode 100644 index e28a80af..00000000 --- a/tests/integration/contrib/django/data/djangoproject/testapp/views.py +++ /dev/null @@ -1,44 +0,0 @@ -import yaml - -from django.http import JsonResponse -from openapi_core import create_spec -from openapi_core.validation.request.validators import RequestValidator -from openapi_core.validation.response.validators import ResponseValidator -from openapi_core.contrib.django import ( - DjangoOpenAPIRequest, DjangoOpenAPIResponse, -) -from rest_framework.views import APIView - -from djangoproject import settings - - -class TestView(APIView): - - def get(self, request, pk): - with open(settings.OPENAPI_SPEC_PATH) as file: - spec_yaml = file.read() - spec_dict = yaml.load(spec_yaml) - spec = create_spec(spec_dict) - - openapi_request = DjangoOpenAPIRequest(request) - - request_validator = RequestValidator(spec) - result = request_validator.validate(openapi_request) - result.raise_for_errors() - - response_dict = { - "test": "test_val", - } - django_response = JsonResponse(response_dict) - django_response['X-Rate-Limit'] = '12' - - openapi_response = DjangoOpenAPIResponse(django_response) - validator = ResponseValidator(spec) - result = validator.validate(openapi_request, openapi_response) - result.raise_for_errors() - - return django_response - - @staticmethod - def get_extra_actions(): - return [] diff --git a/tests/integration/contrib/django/data/openapi.yaml b/tests/integration/contrib/django/data/openapi.yaml deleted file mode 100644 index 58c8ec57..00000000 --- a/tests/integration/contrib/django/data/openapi.yaml +++ /dev/null @@ -1,32 +0,0 @@ -openapi: '3.0.0' -info: - version: '0.0.1' - title: Test Service -paths: - '/test/{pk}': - get: - responses: - '200': - description: Default - content: - application/json: - schema: - type: object - properties: - test: - type: string - required: - - test - headers: - X-Rate-Limit: - description: Rate limit - schema: - type: integer - required: true - parameters: - - required: true - in: path - name: pk - schema: - type: integer - minimum: 1 diff --git a/tests/integration/contrib/django/data/djangoproject/__init__.py b/tests/integration/contrib/django/data/v3.0/djangoproject/__init__.py similarity index 100% rename from tests/integration/contrib/django/data/djangoproject/__init__.py rename to tests/integration/contrib/django/data/v3.0/djangoproject/__init__.py diff --git a/tests/integration/contrib/django/data/v3.0/djangoproject/auth.py b/tests/integration/contrib/django/data/v3.0/djangoproject/auth.py new file mode 100644 index 00000000..b8dd0b0c --- /dev/null +++ b/tests/integration/contrib/django/data/v3.0/djangoproject/auth.py @@ -0,0 +1,17 @@ +from django.contrib.auth.models import User +from rest_framework import authentication +from rest_framework import exceptions + + +class SimpleAuthentication(authentication.BaseAuthentication): + def authenticate(self, request): + username = request.META.get('X_USERNAME') + if not username: + return None + + try: + user = User.objects.get(username=username) + except User.DoesNotExist: + raise exceptions.AuthenticationFailed('No such user') + + return (user, None) diff --git a/tests/integration/contrib/django/data/djangoproject/testapp/__init__.py b/tests/integration/contrib/django/data/v3.0/djangoproject/pets/__init__.py similarity index 100% rename from tests/integration/contrib/django/data/djangoproject/testapp/__init__.py rename to tests/integration/contrib/django/data/v3.0/djangoproject/pets/__init__.py diff --git a/tests/integration/contrib/django/data/djangoproject/testapp/migrations/__init__.py b/tests/integration/contrib/django/data/v3.0/djangoproject/pets/migrations/__init__.py similarity index 100% rename from tests/integration/contrib/django/data/djangoproject/testapp/migrations/__init__.py rename to tests/integration/contrib/django/data/v3.0/djangoproject/pets/migrations/__init__.py diff --git a/tests/integration/contrib/django/data/v3.0/djangoproject/pets/views.py b/tests/integration/contrib/django/data/v3.0/djangoproject/pets/views.py new file mode 100644 index 00000000..de62b00c --- /dev/null +++ b/tests/integration/contrib/django/data/v3.0/djangoproject/pets/views.py @@ -0,0 +1,81 @@ +from django.http import HttpResponse, JsonResponse +from rest_framework.views import APIView + + +class PetListView(APIView): + + def get(self, request): + assert request.openapi + assert not request.openapi.errors + assert request.openapi.parameters.query == { + 'page': 1, + 'limit': 12, + 'search': '', + } + data = [ + { + 'id': 12, + 'name': 'Cat', + 'ears': { + 'healthy': True, + }, + }, + ] + response_dict = { + "data": data, + } + django_response = JsonResponse(response_dict) + django_response['X-Rate-Limit'] = '12' + + return django_response + + def post(self, request): + assert request.openapi + assert not request.openapi.errors + assert request.openapi.parameters.cookie == { + 'user': 1, + } + assert request.openapi.parameters.header == { + 'api-key': '12345', + } + assert request.openapi.body.__class__.__name__ == 'PetCreate' + assert request.openapi.body.name == 'Cat' + assert request.openapi.body.ears.__class__.__name__ == 'Ears' + assert request.openapi.body.ears.healthy is True + + django_response = HttpResponse(status=201) + django_response['X-Rate-Limit'] = '12' + + return django_response + + @staticmethod + def get_extra_actions(): + return [] + + +class PetDetailView(APIView): + + def get(self, request, petId): + assert request.openapi + assert not request.openapi.errors + assert request.openapi.parameters.path == { + 'petId': 12, + } + data = { + 'id': 12, + 'name': 'Cat', + 'ears': { + 'healthy': True, + }, + } + response_dict = { + "data": data, + } + django_response = JsonResponse(response_dict) + django_response['X-Rate-Limit'] = '12' + + return django_response + + @staticmethod + def get_extra_actions(): + return [] diff --git a/tests/integration/contrib/django/data/djangoproject/settings.py b/tests/integration/contrib/django/data/v3.0/djangoproject/settings.py similarity index 79% rename from tests/integration/contrib/django/data/djangoproject/settings.py rename to tests/integration/contrib/django/data/v3.0/djangoproject/settings.py index 1c16bcf6..1b231f58 100644 --- a/tests/integration/contrib/django/data/djangoproject/settings.py +++ b/tests/integration/contrib/django/data/v3.0/djangoproject/settings.py @@ -1,5 +1,5 @@ """ -Django settings for djangotest project. +Django settings for djangoproject project. Generated by 'django-admin startproject' using Django 2.2.18. @@ -10,7 +10,11 @@ https://docs.djangoproject.com/en/2.2/ref/settings/ """ +from pathlib import Path import os +import yaml + +from openapi_core import create_spec # Build paths inside the project like this: os.path.join(BASE_DIR, ...) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -25,7 +29,7 @@ # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True -ALLOWED_HOSTS = ['testserver'] +ALLOWED_HOSTS = ['petstore.swagger.io', 'staging.gigantic-server.com'] # Application definition @@ -48,9 +52,10 @@ 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'openapi_core.contrib.django.middlewares.DjangoOpenAPIMiddleware', ] -ROOT_URLCONF = 'djangotest.urls' +ROOT_URLCONF = 'djangoproject.urls' TEMPLATES = [ { @@ -68,7 +73,7 @@ }, ] -WSGI_APPLICATION = 'djangotest.wsgi.application' +WSGI_APPLICATION = 'djangoproject.wsgi.application' # Database @@ -107,4 +112,14 @@ STATIC_URL = '/static/' -OPENAPI_SPEC_PATH = os.path.join(BASE_DIR, 'openapi.yaml') +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'djangoproject.auth.SimpleAuthentication', + ] +} + +OPENAPI_SPEC_PATH = Path("tests/integration/data/v3.0/petstore.yaml") + +OPENAPI_SPEC_DICT = yaml.load(OPENAPI_SPEC_PATH.read_text(), yaml.Loader) + +OPENAPI_SPEC = create_spec(OPENAPI_SPEC_DICT) diff --git a/tests/integration/contrib/django/data/djangoproject/urls.py b/tests/integration/contrib/django/data/v3.0/djangoproject/urls.py similarity index 78% rename from tests/integration/contrib/django/data/djangoproject/urls.py rename to tests/integration/contrib/django/data/v3.0/djangoproject/urls.py index 09dfd99f..0661f5af 100644 --- a/tests/integration/contrib/django/data/djangoproject/urls.py +++ b/tests/integration/contrib/django/data/v3.0/djangoproject/urls.py @@ -15,7 +15,7 @@ """ from django.contrib import admin from django.urls import include, path -from djangotest.testapp import views +from djangoproject.pets import views urlpatterns = [ path('admin/', admin.site.urls), @@ -24,8 +24,13 @@ include('rest_framework.urls', namespace='rest_framework'), ), path( - 'test/', - views.TestView.as_view(), - name='test', + 'v1/pets', + views.PetListView.as_view(), + name='pet_list_view', + ), + path( + 'v1/pets/', + views.PetDetailView.as_view(), + name='pet_detail_view', ), ] diff --git a/tests/integration/contrib/django/test_django_project.py b/tests/integration/contrib/django/test_django_project.py new file mode 100644 index 00000000..fee23d01 --- /dev/null +++ b/tests/integration/contrib/django/test_django_project.py @@ -0,0 +1,350 @@ +from base64 import b64encode +from json import dumps +import os +import sys +from unittest import mock + +import pytest + + +class BaseTestDjangoProject: + + api_key = '12345' + + @property + def api_key_encoded(self): + api_key_bytes = self.api_key.encode('utf8') + api_key_bytes_enc = b64encode(api_key_bytes) + return str(api_key_bytes_enc, 'utf8') + + @pytest.fixture(autouse=True, scope='module') + def django_setup(self): + directory = os.path.abspath(os.path.dirname(__file__)) + django_project_dir = os.path.join(directory, 'data/v3.0') + sys.path.insert(0, django_project_dir) + with mock.patch.dict( + os.environ, + { + 'DJANGO_SETTINGS_MODULE': 'djangoproject.settings', + } + ): + import django + django.setup() + yield + sys.path.remove(django_project_dir) + + @pytest.fixture + def client(self): + from django.test import Client + return Client() + + +class TestPetListView(BaseTestDjangoProject): + + def test_get_no_required_param(self, client): + headers = { + 'HTTP_AUTHORIZATION': 'Basic testuser', + 'HTTP_HOST': 'petstore.swagger.io', + } + response = client.get('/v1/pets', **headers) + + expected_data = { + 'errors': [ + { + 'class': ( + "" + ), + 'status': 400, + 'title': 'Missing required parameter: limit', + } + ] + } + assert response.status_code == 400 + assert response.json() == expected_data + + def test_get_valid(self, client): + data_json = { + 'limit': 12, + } + headers = { + 'HTTP_AUTHORIZATION': 'Basic testuser', + 'HTTP_HOST': 'petstore.swagger.io', + } + response = client.get('/v1/pets', data_json, **headers) + + expected_data = { + 'data': [ + { + 'id': 12, + 'name': 'Cat', + 'ears': { + 'healthy': True, + }, + }, + ], + } + assert response.status_code == 200 + assert response.json() == expected_data + + def test_post_server_invalid(self, client): + headers = { + 'HTTP_HOST': 'petstore.swagger.io', + } + response = client.post('/v1/pets', **headers) + + expected_data = { + 'errors': [ + { + 'class': ( + "" + ), + 'status': 400, + 'title': ( + 'Server not found for ' + 'http://petstore.swagger.io/v1/pets' + ), + } + ] + } + assert response.status_code == 400 + assert response.json() == expected_data + + def test_post_required_header_param_missing(self, client): + client.cookies.load({'user': 1}) + pet_name = 'Cat' + pet_tag = 'cats' + pet_street = 'Piekna' + pet_city = 'Warsaw' + pet_healthy = False + data_json = { + 'name': pet_name, + 'tag': pet_tag, + 'position': 2, + 'address': { + 'street': pet_street, + 'city': pet_city, + }, + 'healthy': pet_healthy, + 'wings': { + 'healthy': pet_healthy, + } + } + content_type = 'application/json' + headers = { + 'HTTP_AUTHORIZATION': 'Basic testuser', + 'HTTP_HOST': 'staging.gigantic-server.com', + } + response = client.post( + '/v1/pets', data_json, content_type, secure=True, **headers) + + expected_data = { + 'errors': [ + { + 'class': ( + "" + ), + 'status': 400, + 'title': 'Missing required parameter: api-key', + } + ] + } + assert response.status_code == 400 + assert response.json() == expected_data + + def test_post_media_type_invalid(self, client): + client.cookies.load({'user': 1}) + data = 'data' + content_type = 'text/html' + headers = { + 'HTTP_AUTHORIZATION': 'Basic testuser', + 'HTTP_HOST': 'staging.gigantic-server.com', + 'HTTP_API_KEY': self.api_key_encoded, + } + response = client.post( + '/v1/pets', data, content_type, secure=True, **headers) + + expected_data = { + 'errors': [ + { + 'class': ( + "" + ), + 'status': 415, + 'title': ( + "Content for the following mimetype not found: " + "text/html. " + "Valid mimetypes: ['application/json', 'text/plain']" + ), + } + ] + } + assert response.status_code == 415 + assert response.json() == expected_data + + def test_post_required_cookie_param_missing(self, client): + data_json = { + 'id': 12, + 'name': 'Cat', + 'ears': { + 'healthy': True, + }, + } + content_type = 'application/json' + headers = { + 'HTTP_AUTHORIZATION': 'Basic testuser', + 'HTTP_HOST': 'staging.gigantic-server.com', + 'HTTP_API_KEY': self.api_key_encoded, + } + response = client.post( + '/v1/pets', data_json, content_type, secure=True, **headers) + + expected_data = { + 'errors': [ + { + 'class': ( + "" + ), + 'status': 400, + 'title': "Missing required parameter: user", + } + ] + } + assert response.status_code == 400 + assert response.json() == expected_data + + def test_post_valid(self, client): + client.cookies.load({'user': 1}) + content_type = 'application/json' + data_json = { + 'id': 12, + 'name': 'Cat', + 'ears': { + 'healthy': True, + }, + } + headers = { + 'HTTP_AUTHORIZATION': 'Basic testuser', + 'HTTP_HOST': 'staging.gigantic-server.com', + 'HTTP_API_KEY': self.api_key_encoded, + } + response = client.post( + '/v1/pets', data_json, content_type, secure=True, **headers) + + assert response.status_code == 201 + assert not response.content + + +class TestPetDetailView(BaseTestDjangoProject): + + def test_get_server_invalid(self, client): + response = client.get('/v1/pets/12') + + expected_data = ( + b"You may need to add 'testserver' to ALLOWED_HOSTS." + ) + assert response.status_code == 400 + assert expected_data in response.content + + def test_get_unauthorized(self, client): + headers = { + 'HTTP_HOST': 'petstore.swagger.io', + } + response = client.get('/v1/pets/12', **headers) + + expected_data = { + 'errors': [ + { + 'class': ( + "" + ), + 'status': 403, + 'title': 'Security not valid for any requirement', + } + ] + } + assert response.status_code == 403 + assert response.json() == expected_data + + def test_delete_method_invalid(self, client): + headers = { + 'HTTP_AUTHORIZATION': 'Basic testuser', + 'HTTP_HOST': 'petstore.swagger.io', + } + response = client.delete('/v1/pets/12', **headers) + + expected_data = { + 'errors': [ + { + 'class': ( + "" + ), + 'status': 405, + 'title': ( + 'Operation delete not found for ' + 'http://petstore.swagger.io/v1/pets/12' + ), + } + ] + } + assert response.status_code == 405 + assert response.json() == expected_data + + def test_get_valid(self, client): + headers = { + 'HTTP_AUTHORIZATION': 'Basic testuser', + 'HTTP_HOST': 'petstore.swagger.io', + } + response = client.get('/v1/pets/12', **headers) + + expected_data = { + 'data': { + 'id': 12, + 'name': 'Cat', + 'ears': { + 'healthy': True, + }, + }, + } + assert response.status_code == 200 + assert response.json() == expected_data + + +class BaseTestDRF(BaseTestDjangoProject): + + @pytest.fixture + def api_client(self): + from rest_framework.test import APIClient + return APIClient() + + +class TestDRFPetListView(BaseTestDRF): + + def test_post_valid(self, api_client): + api_client.cookies.load({'user': 1}) + content_type = 'application/json' + data_json = { + 'id': 12, + 'name': 'Cat', + 'ears': { + 'healthy': True, + }, + } + headers = { + 'HTTP_AUTHORIZATION': 'Basic testuser', + 'HTTP_HOST': 'staging.gigantic-server.com', + 'HTTP_API_KEY': self.api_key_encoded, + } + response = api_client.post( + '/v1/pets', dumps(data_json), content_type=content_type, + secure=True, **headers, + ) + + assert response.status_code == 201 + assert not response.content diff --git a/tests/integration/contrib/django/test_django_rest_framework_apiview.py b/tests/integration/contrib/django/test_django_rest_framework_apiview.py deleted file mode 100644 index 498d8526..00000000 --- a/tests/integration/contrib/django/test_django_rest_framework_apiview.py +++ /dev/null @@ -1,18 +0,0 @@ -import pytest - - -class TestDjangoRESTFrameworkAPIView: - - @pytest.fixture - def api_request_factory(self): - from rest_framework.test import APIRequestFactory - return APIRequestFactory() - - def test_get(self, api_request_factory): - from djangoproject.testapp.views import TestView - view = TestView.as_view() - request = api_request_factory.get('/test/4') - - response = view(request, pk='4') - - assert response.content == b'{"test": "test_val"}' diff --git a/tests/integration/data/v3.0/petstore.yaml b/tests/integration/data/v3.0/petstore.yaml index 0d65e957..e4beec6c 100644 --- a/tests/integration/data/v3.0/petstore.yaml +++ b/tests/integration/data/v3.0/petstore.yaml @@ -110,7 +110,7 @@ paths: tags: - pets parameters: - - name: api_key + - name: api-key in: header schema: type: string diff --git a/tests/integration/validation/test_petstore.py b/tests/integration/validation/test_petstore.py index e46ffe16..2a19dc62 100644 --- a/tests/integration/validation/test_petstore.py +++ b/tests/integration/validation/test_petstore.py @@ -518,7 +518,7 @@ def test_post_birds(self, spec, spec_dict): } data = json.dumps(data_json) headers = { - 'api_key': self.api_key_encoded, + 'api-key': self.api_key_encoded, } userdata = { 'name': 'user1', @@ -539,7 +539,7 @@ def test_post_birds(self, spec, spec_dict): assert parameters == Parameters( header={ - 'api_key': self.api_key, + 'api-key': self.api_key, }, cookie={ 'user': 123, @@ -590,7 +590,7 @@ def test_post_cats(self, spec, spec_dict): } data = json.dumps(data_json) headers = { - 'api_key': self.api_key_encoded, + 'api-key': self.api_key_encoded, } cookies = { 'user': '123', @@ -606,7 +606,7 @@ def test_post_cats(self, spec, spec_dict): assert parameters == Parameters( header={ - 'api_key': self.api_key, + 'api-key': self.api_key, }, cookie={ 'user': 123, @@ -650,7 +650,7 @@ def test_post_cats_boolean_string(self, spec, spec_dict): } data = json.dumps(data_json) headers = { - 'api_key': self.api_key_encoded, + 'api-key': self.api_key_encoded, } cookies = { 'user': '123', @@ -666,7 +666,7 @@ def test_post_cats_boolean_string(self, spec, spec_dict): assert parameters == Parameters( header={ - 'api_key': self.api_key, + 'api-key': self.api_key, }, cookie={ 'user': 123, @@ -698,7 +698,7 @@ def test_post_no_one_of_schema(self, spec, spec_dict): } data = json.dumps(data_json) headers = { - 'api_key': self.api_key_encoded, + 'api-key': self.api_key_encoded, } cookies = { 'user': '123', @@ -714,7 +714,7 @@ def test_post_no_one_of_schema(self, spec, spec_dict): assert parameters == Parameters( header={ - 'api_key': self.api_key, + 'api-key': self.api_key, }, cookie={ 'user': 123, @@ -737,7 +737,7 @@ def test_post_cats_only_required_body(self, spec, spec_dict): } data = json.dumps(data_json) headers = { - 'api_key': self.api_key_encoded, + 'api-key': self.api_key_encoded, } cookies = { 'user': '123', @@ -753,7 +753,7 @@ def test_post_cats_only_required_body(self, spec, spec_dict): assert parameters == Parameters( header={ - 'api_key': self.api_key, + 'api-key': self.api_key, }, cookie={ 'user': 123, @@ -778,7 +778,7 @@ def test_post_pets_raises_invalid_mimetype(self, spec): } data = json.dumps(data_json) headers = { - 'api_key': self.api_key_encoded, + 'api-key': self.api_key_encoded, } cookies = { 'user': '123', @@ -794,7 +794,7 @@ def test_post_pets_raises_invalid_mimetype(self, spec): assert parameters == Parameters( header={ - 'api_key': self.api_key, + 'api-key': self.api_key, }, cookie={ 'user': 123, @@ -817,7 +817,7 @@ def test_post_pets_missing_cookie(self, spec, spec_dict): } data = json.dumps(data_json) headers = { - 'api_key': self.api_key_encoded, + 'api-key': self.api_key_encoded, } request = MockRequest( @@ -881,7 +881,7 @@ def test_post_pets_raises_invalid_server_error(self, spec): } data = json.dumps(data_json) headers = { - 'api_key': '12345', + 'api-key': '12345', } cookies = { 'user': '123', diff --git a/tests/integration/validation/test_validators.py b/tests/integration/validation/test_validators.py index 0cd7150c..6d430735 100644 --- a/tests/integration/validation/test_validators.py +++ b/tests/integration/validation/test_validators.py @@ -142,7 +142,7 @@ def test_get_pets_webob(self, validator): def test_missing_body(self, validator): headers = { - 'api_key': self.api_key_encoded, + 'api-key': self.api_key_encoded, } cookies = { 'user': '123', @@ -160,7 +160,7 @@ def test_missing_body(self, validator): assert result.body is None assert result.parameters == Parameters( header={ - 'api_key': self.api_key, + 'api-key': self.api_key, }, cookie={ 'user': 123, @@ -170,7 +170,7 @@ def test_missing_body(self, validator): def test_invalid_content_type(self, validator): data = "csv,data" headers = { - 'api_key': self.api_key_encoded, + 'api-key': self.api_key_encoded, } cookies = { 'user': '123', @@ -188,7 +188,7 @@ def test_invalid_content_type(self, validator): assert result.body is None assert result.parameters == Parameters( header={ - 'api_key': self.api_key, + 'api-key': self.api_key, }, cookie={ 'user': 123, @@ -214,7 +214,7 @@ def test_invalid_complex_parameter(self, validator, spec_dict): } data = json.dumps(data_json) headers = { - 'api_key': self.api_key_encoded, + 'api-key': self.api_key_encoded, } userdata = { 'name': 1, @@ -236,7 +236,7 @@ def test_invalid_complex_parameter(self, validator, spec_dict): assert type(result.errors[0]) == InvalidSchemaValue assert result.parameters == Parameters( header={ - 'api_key': self.api_key, + 'api-key': self.api_key, }, cookie={ 'user': 123, @@ -274,7 +274,7 @@ def test_post_pets(self, validator, spec_dict): } data = json.dumps(data_json) headers = { - 'api_key': self.api_key_encoded, + 'api-key': self.api_key_encoded, } cookies = { 'user': '123', @@ -290,7 +290,7 @@ def test_post_pets(self, validator, spec_dict): assert result.errors == [] assert result.parameters == Parameters( header={ - 'api_key': self.api_key, + 'api-key': self.api_key, }, cookie={ 'user': 123, @@ -312,7 +312,7 @@ def test_post_pets(self, validator, spec_dict): def test_post_pets_plain_no_schema(self, validator, spec_dict): data = 'plain text' headers = { - 'api_key': self.api_key_encoded, + 'api-key': self.api_key_encoded, } cookies = { 'user': '123', @@ -329,7 +329,7 @@ def test_post_pets_plain_no_schema(self, validator, spec_dict): assert result.errors == [] assert result.parameters == Parameters( header={ - 'api_key': self.api_key, + 'api-key': self.api_key, }, cookie={ 'user': 123, diff --git a/tests/integration/contrib/test_django.py b/tests/unit/contrib/django/test_django.py similarity index 79% rename from tests/integration/contrib/test_django.py rename to tests/unit/contrib/django/test_django.py index 55e5d443..bd39d277 100644 --- a/tests/integration/contrib/test_django.py +++ b/tests/unit/contrib/django/test_django.py @@ -1,18 +1,12 @@ -import sys - import pytest from werkzeug.datastructures import Headers from openapi_core.contrib.django import ( DjangoOpenAPIRequest, DjangoOpenAPIResponse, ) -from openapi_core.shortcuts import create_spec from openapi_core.validation.request.datatypes import RequestParameters -from openapi_core.validation.request.validators import RequestValidator -from openapi_core.validation.response.validators import ResponseValidator -@pytest.mark.skipif(sys.version_info < (3, 0), reason="requires python3") class BaseTestDjango: @pytest.fixture(autouse=True, scope='module') @@ -23,9 +17,11 @@ def django_settings(self): from django.urls import path, re_path if settings.configured: - return + from django.utils.functional import empty + settings._wrapped = empty settings.configure( + SECRET_KEY='secretkey', ALLOWED_HOSTS=[ 'testserver', ], @@ -186,33 +182,3 @@ def test_redirect_response(self, response_factory): assert openapi_response.data == response.content assert openapi_response.status_code == response.status_code assert openapi_response.mimetype == response["Content-Type"] - - -class TestDjangoOpenAPIValidation(BaseTestDjango): - - @pytest.fixture - def django_spec(self, factory): - specfile = 'data/v3.0/django_factory.yaml' - return create_spec(factory.spec_from_file(specfile)) - - def test_response_validator_path_pattern( - self, django_spec, request_factory, response_factory): - from django.urls import resolve - validator = ResponseValidator(django_spec) - request = request_factory.get('/admin/auth/group/1/') - request.resolver_match = resolve('/admin/auth/group/1/') - openapi_request = DjangoOpenAPIRequest(request) - response = response_factory(b'Some item') - openapi_response = DjangoOpenAPIResponse(response) - result = validator.validate(openapi_request, openapi_response) - assert not result.errors - - def test_request_validator_path_pattern( - self, django_spec, request_factory): - from django.urls import resolve - validator = RequestValidator(django_spec) - request = request_factory.get('/admin/auth/group/1/') - request.resolver_match = resolve('/admin/auth/group/1/') - openapi_request = DjangoOpenAPIRequest(request) - result = validator.validate(openapi_request) - assert not result.errors