diff --git a/README.rst b/README.rst index 9a414f4..89c9f7d 100644 --- a/README.rst +++ b/README.rst @@ -1,3 +1,4 @@ +************ openapi-core ************ @@ -15,13 +16,13 @@ openapi-core :target: https://pypi.python.org/pypi/openapi-core About -===== +##### Openapi-core is a Python library that adds client-side and server-side support for the `OpenAPI Specification v3.0.0 `__. Installation -============ +############ Recommended way (via pip): @@ -37,7 +38,7 @@ Alternatively you can download the code and install from the repository: Usage -===== +##### Firstly create your specification: @@ -47,6 +48,9 @@ Firstly create your specification: spec = create_spec(spec_dict) +Request +******* + Now you can use it to validate requests .. code-block:: python @@ -83,27 +87,10 @@ or use shortcuts for simple validation validated_params = validate_parameters(spec, request) validated_body = validate_body(spec, request) -Request object should be instance of OpenAPIRequest class. You can use FlaskOpenAPIRequest a Flask/Werkzeug request factory: +Request object should be instance of OpenAPIRequest class (See `Integrations`_). -.. code-block:: python - - from openapi_core.shortcuts import RequestValidator - from openapi_core.contrib.flask import FlaskOpenAPIRequest - - openapi_request = FlaskOpenAPIRequest(flask_request) - validator = RequestValidator(spec) - result = validator.validate(openapi_request) - -or simply specify request factory for shortcuts - -.. code-block:: python - - from openapi_core import validate_parameters, validate_body - - validated_params = validate_parameters( - spec, request, request_factory=FlaskOpenAPIRequest) - validated_body = validate_body( - spec, request, request_factory=FlaskOpenAPIRequest) +Response +******** You can also validate responses @@ -138,7 +125,85 @@ or use shortcuts for simple validation validated_data = validate_data(spec, request, response) -Response object should be instance of OpenAPIResponse class. You can use FlaskOpenAPIResponse a Flask/Werkzeug response factory: +Response object should be instance of OpenAPIResponse class (See `Integrations`_). + + +Integrations +############ + +Django +****** + +For Django 2.2 you can use DjangoOpenAPIRequest a Django request factory: + +.. code-block:: python + + from openapi_core.shortcuts import RequestValidator + from openapi_core.contrib.django import DjangoOpenAPIRequest + + openapi_request = DjangoOpenAPIRequest(django_request) + validator = RequestValidator(spec) + result = validator.validate(openapi_request) + +or simply specify request factory for shortcuts + +.. code-block:: python + + from openapi_core import validate_parameters, validate_body + + validated_params = validate_parameters( + spec, request, request_factory=DjangoOpenAPIRequest) + validated_body = validate_body( + spec, request, request_factory=DjangoOpenAPIRequest) + +You can use DjangoOpenAPIResponse as a Django response factory: + +.. code-block:: python + + from openapi_core.shortcuts import ResponseValidator + from openapi_core.contrib.django import DjangoOpenAPIResponse + + openapi_response = DjangoOpenAPIResponse(django_response) + validator = ResponseValidator(spec) + result = validator.validate(openapi_request, openapi_response) + +or simply specify response factory for shortcuts + +.. code-block:: python + + from openapi_core import validate_parameters, validate_body + + validated_data = validate_data( + spec, request, response, + request_factory=DjangoOpenAPIRequest, + response_factory=DjangoOpenAPIResponse) + +Flask +***** + +You can use FlaskOpenAPIRequest a Flask/Werkzeug request factory: + +.. code-block:: python + + from openapi_core.shortcuts import RequestValidator + from openapi_core.contrib.flask import FlaskOpenAPIRequest + + openapi_request = FlaskOpenAPIRequest(flask_request) + validator = RequestValidator(spec) + result = validator.validate(openapi_request) + +or simply specify request factory for shortcuts + +.. code-block:: python + + from openapi_core import validate_parameters, validate_body + + validated_params = validate_parameters( + spec, request, request_factory=FlaskOpenAPIRequest) + validated_body = validate_body( + spec, request, request_factory=FlaskOpenAPIRequest) + +You can use FlaskOpenAPIResponse as a Flask/Werkzeug response factory: .. code-block:: python @@ -160,7 +225,12 @@ or simply specify response factory for shortcuts request_factory=FlaskOpenAPIRequest, response_factory=FlaskOpenAPIResponse) +Pyramid +******* + +See `pyramid_openapi3 `_ project. + Related projects -================ +################ * `openapi-spec-validator `__ * `pyramid_openapi3 `__ 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/requirements_dev.txt b/requirements_dev.txt index 7d4afaa..37815b2 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; python_version>="3.0" diff --git a/setup.cfg b/setup.cfg index 44b4320..bd43a27 100644 --- a/setup.cfg +++ b/setup.cfg @@ -43,6 +43,7 @@ exclude = tests [options.extras_require] +django = django>=2.2; python_version>="3.0" 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..fd57984 --- /dev/null +++ b/tests/integration/contrib/test_django.py @@ -0,0 +1,196 @@ +import sys + +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.mark.skipif(sys.version_info < (3, 0), reason="requires python3") +class BaseTestDjango(object): + + @pytest.fixture(autouse=True, scope='module') + def django_settings(self): + import django + from django.conf import settings + from django.contrib import admin + from django.urls import path + + if settings.configured: + return + + 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), + ) + + @pytest.fixture + def request_factory(self): + from django.test.client import RequestFactory + return RequestFactory() + + @pytest.fixture + def response_factory(self): + from django.http import HttpResponse + + def create(content=b(''), status_code=None): + return HttpResponse(content, status=status_code) + + return create + + +class TestDjangoOpenAPIRequest(BaseTestDjango): + + 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): + from django.urls import resolve + 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): + from django.urls import resolve + 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(BaseTestDjango): + + def test_stream_response(self, response_factory): + response = response_factory() + 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_factory): + response = response_factory('/redirected/', status_code=302) + + 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(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 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.