diff --git a/openapi_core/contrib/django/__init__.py b/openapi_core/contrib/django/__init__.py new file mode 100644 index 0000000..dbbd8f0 --- /dev/null +++ b/openapi_core/contrib/django/__init__.py @@ -0,0 +1,11 @@ +from openapi_core.contrib.django.requests import DjangoOpenAPIRequestFactory +from openapi_core.contrib.django.responses import DjangoOpenAPIResponseFactory + +# backward compatibility +DjangoOpenAPIRequest = DjangoOpenAPIRequestFactory.create +DjangoOpenAPIResponse = DjangoOpenAPIResponseFactory.create + +__all__ = [ + 'DjangoOpenAPIRequestFactory', 'DjangoOpenAPIResponseFactory', + 'DjangoOpenAPIRequest', 'DjangoOpenAPIResponse', +] diff --git a/openapi_core/contrib/django/requests.py b/openapi_core/contrib/django/requests.py new file mode 100644 index 0000000..5ed31f6 --- /dev/null +++ b/openapi_core/contrib/django/requests.py @@ -0,0 +1,51 @@ +"""OpenAPI core contrib django requests module""" +import re + +from openapi_core.validation.request.datatypes import ( + RequestParameters, OpenAPIRequest, +) + +# https://docs.djangoproject.com/en/2.2/topics/http/urls/ +# +# Currently unsupported are : +# - nested arguments, e.g.: ^comments/(?:page-(?P\d+)/)?$ +# - unnamed regex groups, e.g.: ^articles/([0-9]{4})/$ +# - multiple named parameters between a single pair of slashes +# e.g.: -/edit/ +# +# The regex matches everything, except a "/" until "<". Than only the name +# is exported, after which it matches ">" and everything until a "/". +PATH_PARAMETER_PATTERN = r'(?:[^\/]*?)<(?:(?:.*?:))*?(\w+)>(?:[^\/]*)' + + +class DjangoOpenAPIRequestFactory(object): + + path_regex = re.compile(PATH_PARAMETER_PATTERN) + + @classmethod + def create(cls, request): + method = request.method.lower() + + if request.resolver_match is None: + path_pattern = request.path + else: + route = cls.path_regex.sub( + r'{\1}', request.resolver_match.route) + path_pattern = '/' + route + + path = request.resolver_match and request.resolver_match.kwargs or {} + parameters = RequestParameters( + path=path, + query=request.GET, + header=request.headers, + cookie=request.COOKIES, + ) + return OpenAPIRequest( + host_url=request._current_scheme_host, + path=request.path, + method=method, + path_pattern=path_pattern, + parameters=parameters, + body=request.body, + mimetype=request.content_type, + ) diff --git a/openapi_core/contrib/django/responses.py b/openapi_core/contrib/django/responses.py new file mode 100644 index 0000000..efbe69d --- /dev/null +++ b/openapi_core/contrib/django/responses.py @@ -0,0 +1,14 @@ +"""OpenAPI core contrib django responses module""" +from openapi_core.validation.response.datatypes import OpenAPIResponse + + +class DjangoOpenAPIResponseFactory(object): + + @classmethod + def create(cls, response): + mimetype = response["Content-Type"] + return OpenAPIResponse( + data=response.content, + status_code=response.status_code, + mimetype=mimetype, + ) diff --git a/openapi_core/validation/request/datatypes.py b/openapi_core/validation/request/datatypes.py index 4158798..27c9c1b 100644 --- a/openapi_core/validation/request/datatypes.py +++ b/openapi_core/validation/request/datatypes.py @@ -21,6 +21,23 @@ class RequestParameters(object): @attr.s class OpenAPIRequest(object): + """OpenAPI request dataclass. + + Attributes: + path + Requested path as string. + path_pattern + The matched url pattern. + parameters + A RequestParameters object. + body + The request body, as string. + mimetype + Like content type, but without parameters (eg, without charset, + type etc.) and always lowercase. + For example if the content type is "text/HTML; charset=utf-8" + the mimetype would be "text/html". + """ host_url = attr.ib() path = attr.ib() diff --git a/openapi_core/validation/response/datatypes.py b/openapi_core/validation/response/datatypes.py index c41f5a2..f55fc17 100644 --- a/openapi_core/validation/response/datatypes.py +++ b/openapi_core/validation/response/datatypes.py @@ -6,6 +6,16 @@ from openapi_core.validation.datatypes import BaseValidationResult @attr.s class OpenAPIResponse(object): + """OpenAPI request dataclass. + + Attributes: + data + The response body, as string. + status_code + The status code as integer. + mimetype + Lowercase content type without charset. + """ data = attr.ib() status_code = attr.ib() diff --git a/openapi_core/wrappers/django.py b/openapi_core/wrappers/django.py deleted file mode 100644 index 01ec7a1..0000000 --- a/openapi_core/wrappers/django.py +++ /dev/null @@ -1,104 +0,0 @@ -"""OpenAPI core wrappers module""" -import re - -from openapi_core.wrappers.base import BaseOpenAPIRequest, BaseOpenAPIResponse - -# https://docs.djangoproject.com/en/2.2/topics/http/urls/ -# -# Currently unsupported are : -# - nested arguments, e.g.: ^comments/(?:page-(?P\d+)/)?$ -# - unnamed regex groups, e.g.: ^articles/([0-9]{4})/$ -# - multiple named parameters between a single pair of slashes e.g.: -/edit/ -# -# The regex matches everything, except a "/" until "<". Than only the name is exported, after which it matches ">" and -# everything until a "/". -PATH_PARAMETER_PATTERN = r'(?:[^\/]*?)<(?:(?:.*?:))*?(\w+)>(?:[^\/]*)' - - -class DjangoOpenAPIRequest(BaseOpenAPIRequest): - path_regex = re.compile(PATH_PARAMETER_PATTERN) - - def __init__(self, request): - self.request = request - - @property - def host_url(self): - """ - :return: The host with scheme as IRI. - """ - return self.request._current_scheme_host - - @property - def path(self): - """ - :return: Requested path as unicode. - """ - return self.request.path - - @property - def method(self): - """ - :return: The request method, in lowercase. - """ - return self.request.method.lower() - - @property - def path_pattern(self): - """ - :return: The matched url pattern. - """ - return self.path_regex.sub(r'{\1}', self.request.resolver_match.route) - - @property - def parameters(self): - """ - :return: A dictionary of all parameters. - """ - return { - 'path': self.request.resolver_match.kwargs, - 'query': self.request.GET, - 'header': self.request.headers, - 'cookie': self.request.COOKIES, - } - - @property - def body(self): - """ - :return: The request body, as string. - """ - return self.request.body - - @property - def mimetype(self): - """ - :return: Like content type, but without parameters (eg, without charset, type etc.) and always lowercase. - For example if the content type is "text/HTML; charset=utf-8" the mimetype would be "text/html". - """ - return self.request.content_type - - -class DjangoOpenAPIResponse(BaseOpenAPIResponse): - - def __init__(self, response): - self.response = response - - @property - def data(self): - """ - :return: The response body, as string. - """ - return self.response.content - - @property - def status_code(self): - """ - :return: The status code as integer. - """ - return self.response.status_code - - @property - def mimetype(self): - """ - :return: Lowercase content type without charset. - """ - return self.response["Content-Type"] diff --git a/requirements_dev.txt b/requirements_dev.txt index 7d4afaa..b711d43 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -2,4 +2,5 @@ mock==2.0.0 pytest==3.5.0 pytest-flake8 pytest-cov==2.5.1 -flask \ No newline at end of file +flask +django==2.2.6 diff --git a/setup.cfg b/setup.cfg index 44b4320..967a29d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -43,6 +43,7 @@ exclude = tests [options.extras_require] +django = django flask = werkzeug [tool:pytest] diff --git a/tests/integration/contrib/test_django.py b/tests/integration/contrib/test_django.py new file mode 100644 index 0000000..03672a5 --- /dev/null +++ b/tests/integration/contrib/test_django.py @@ -0,0 +1,180 @@ +from django.http import HttpResponse, HttpResponseRedirect +from django.test.client import RequestFactory +from django.urls import resolve +import pytest +from six import b + +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.fixture(autouse=True, scope='module') +def django_settings(): + import django + from django.conf import settings + from django.contrib import admin + from django.urls import path + settings.configure( + ALLOWED_HOSTS=[ + 'testserver', + ], + INSTALLED_APPS=[ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.messages', + 'django.contrib.sessions', + ], + MIDDLEWARE=[ + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + ] + ) + django.setup() + settings.ROOT_URLCONF = ( + path('admin/', admin.site.urls), + ) + + +class TestDjangoOpenAPIRequest(object): + + @pytest.fixture + def request_factory(self): + return RequestFactory() + + def test_no_resolver(self, request_factory): + request = request_factory.get('/admin/') + + openapi_request = DjangoOpenAPIRequest(request) + + path = {} + query = {} + headers = { + 'Cookie': '', + } + cookies = {} + assert openapi_request.parameters == RequestParameters( + path=path, + query=query, + header=headers, + cookie=cookies, + ) + assert openapi_request.host_url == request._current_scheme_host + assert openapi_request.path == request.path + assert openapi_request.method == request.method.lower() + assert openapi_request.path_pattern == request.path + assert openapi_request.body == request.body + assert openapi_request.mimetype == request.content_type + + def test_simple(self, request_factory): + request = request_factory.get('/admin/') + request.resolver_match = resolve('/admin/') + + openapi_request = DjangoOpenAPIRequest(request) + + path = {} + query = {} + headers = { + 'Cookie': '', + } + cookies = {} + assert openapi_request.parameters == RequestParameters( + path=path, + query=query, + header=headers, + cookie=cookies, + ) + assert openapi_request.host_url == request._current_scheme_host + assert openapi_request.path == request.path + assert openapi_request.method == request.method.lower() + assert openapi_request.path_pattern == request.path + assert openapi_request.body == request.body + assert openapi_request.mimetype == request.content_type + + def test_url_rule(self, request_factory): + request = request_factory.get('/admin/auth/group/1/') + request.resolver_match = resolve('/admin/auth/group/1/') + + openapi_request = DjangoOpenAPIRequest(request) + + path = { + 'object_id': '1', + } + query = {} + headers = { + 'Cookie': '', + } + cookies = {} + assert openapi_request.parameters == RequestParameters( + path=path, + query=query, + header=headers, + cookie=cookies, + ) + assert openapi_request.host_url == request._current_scheme_host + assert openapi_request.path == request.path + assert openapi_request.method == request.method.lower() + assert openapi_request.path_pattern == \ + "/admin/auth/group/{object_id}/" + assert openapi_request.body == request.body + assert openapi_request.mimetype == request.content_type + + +class TestDjangoOpenAPIResponse: + + def test_stream_response(self): + response = HttpResponse() + response.writelines(['foo\n', 'bar\n', 'baz\n']) + + openapi_response = DjangoOpenAPIResponse(response) + + assert openapi_response.data == b('foo\nbar\nbaz\n') + assert openapi_response.status_code == response.status_code + assert openapi_response.mimetype == response["Content-Type"] + + def test_redirect_response(self): + response = HttpResponseRedirect('/redirected/') + + openapi_response = DjangoOpenAPIResponse(response) + + assert openapi_response.data == response.content + assert openapi_response.status_code == response.status_code + assert openapi_response.mimetype == response["Content-Type"] + + +class TestDjangoOpenAPIValidation(object): + + @pytest.fixture + def request_factory(self): + return RequestFactory() + + @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): + 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 = HttpResponse(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): + 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 diff --git a/tests/integration/data/v3.0/django_factory.yaml b/tests/integration/data/v3.0/django_factory.yaml new file mode 100644 index 0000000..3304013 --- /dev/null +++ b/tests/integration/data/v3.0/django_factory.yaml @@ -0,0 +1,19 @@ +openapi: "3.0.0" +info: + title: Basic OpenAPI specification used with test_flask.TestFlaskOpenAPIIValidation + version: "0.1" +servers: + - url: 'http://testserver' +paths: + '/admin/auth/group/{object_id}/': + parameters: + - name: object_id + in: path + required: true + description: the ID of the resource to retrieve + schema: + type: integer + get: + responses: + default: + description: Return the resource.