Django OpenAPI request/response factories

This commit is contained in:
p1c2u 2019-10-19 23:47:58 +01:00
parent 0d62c5f374
commit 2000b1215f
10 changed files with 305 additions and 105 deletions

View file

@ -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',
]

View file

@ -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<page_number>\d+)/)?$
# - unnamed regex groups, e.g.: ^articles/([0-9]{4})/$
# - multiple named parameters between a single pair of slashes
# e.g.: <page_slug>-<page_id>/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,
)

View file

@ -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,
)

View file

@ -21,6 +21,23 @@ class RequestParameters(object):
@attr.s @attr.s
class OpenAPIRequest(object): 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() host_url = attr.ib()
path = attr.ib() path = attr.ib()

View file

@ -6,6 +6,16 @@ from openapi_core.validation.datatypes import BaseValidationResult
@attr.s @attr.s
class OpenAPIResponse(object): 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() data = attr.ib()
status_code = attr.ib() status_code = attr.ib()

View file

@ -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<page_number>\d+)/)?$
# - unnamed regex groups, e.g.: ^articles/([0-9]{4})/$
# - multiple named parameters between a single pair of slashes e.g.: <page_slug>-<page_id>/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"]

View file

@ -2,4 +2,5 @@ mock==2.0.0
pytest==3.5.0 pytest==3.5.0
pytest-flake8 pytest-flake8
pytest-cov==2.5.1 pytest-cov==2.5.1
flask flask
django==2.2.6

View file

@ -43,6 +43,7 @@ exclude =
tests tests
[options.extras_require] [options.extras_require]
django = django
flask = werkzeug flask = werkzeug
[tool:pytest] [tool:pytest]

View file

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

View file

@ -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.