mirror of
https://github.com/correl/tornado-openapi3.git
synced 2024-11-25 11:09:57 +00:00
Update openapi-core to 0.19.4+
This commit is contained in:
parent
10de1bd477
commit
5d442073ad
12 changed files with 432 additions and 526 deletions
|
@ -14,7 +14,7 @@ packages = [
|
||||||
[tool.poetry.dependencies]
|
[tool.poetry.dependencies]
|
||||||
python = "^3.9"
|
python = "^3.9"
|
||||||
tornado = "^5 || ^6"
|
tornado = "^5 || ^6"
|
||||||
openapi-core = "^0.14.2"
|
openapi-core = "^0.19.4"
|
||||||
ietfparse = "^1.8.0"
|
ietfparse = "^1.8.0"
|
||||||
typing-extensions = "^4.0.1"
|
typing-extensions = "^4.0.1"
|
||||||
|
|
||||||
|
|
21
tests/common.py
Normal file
21
tests/common.py
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import hypothesis.strategies as s
|
||||||
|
from werkzeug.datastructures import Headers
|
||||||
|
|
||||||
|
field_names = s.text(
|
||||||
|
s.characters(
|
||||||
|
min_codepoint=33,
|
||||||
|
max_codepoint=126,
|
||||||
|
blacklist_categories=["Lu"],
|
||||||
|
blacklist_characters=":\r\n",
|
||||||
|
),
|
||||||
|
min_size=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
field_values = s.text(
|
||||||
|
s.characters(min_codepoint=0x20, max_codepoint=0x7E, blacklist_characters="; \r\n"),
|
||||||
|
min_size=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
headers: s.SearchStrategy[Headers] = s.builds(
|
||||||
|
Headers, s.lists(s.tuples(field_names, field_values))
|
||||||
|
)
|
|
@ -1,14 +1,16 @@
|
||||||
import datetime
|
import datetime
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
import typing
|
||||||
import unittest.mock
|
import unittest.mock
|
||||||
|
|
||||||
from openapi_core.exceptions import OpenAPIError # type: ignore
|
from openapi_core.exceptions import OpenAPIError
|
||||||
import tornado.httpclient
|
import tornado.httpclient
|
||||||
import tornado.web
|
import tornado.web
|
||||||
import tornado.testing
|
import tornado.testing
|
||||||
|
|
||||||
from tornado_openapi3.handler import OpenAPIRequestHandler
|
from tornado_openapi3.handler import OpenAPIRequestHandler
|
||||||
|
from tornado_openapi3.types import Deserializer, Formatter
|
||||||
|
|
||||||
|
|
||||||
class USDateFormatter:
|
class USDateFormatter:
|
||||||
|
@ -74,23 +76,25 @@ class ResourceHandler(OpenAPIRequestHandler):
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
custom_formatters = {
|
@property
|
||||||
|
def custom_formatters(self) -> typing.Dict[str, Formatter]:
|
||||||
|
return {
|
||||||
"usdate": USDateFormatter(),
|
"usdate": USDateFormatter(),
|
||||||
}
|
}
|
||||||
|
|
||||||
custom_media_type_deserializers = {
|
@property
|
||||||
|
def custom_media_type_deserializers(self) -> typing.Dict[str, Deserializer]:
|
||||||
|
return {
|
||||||
"application/vnd.example.resource+json": json.loads,
|
"application/vnd.example.resource+json": json.loads,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def post(self) -> None:
|
async def post(self) -> None:
|
||||||
self.set_header("Content-Type", "application/vnd.example.resource+json")
|
self.set_header("Content-Type", "application/vnd.example.resource+json")
|
||||||
self.finish(
|
body = b""
|
||||||
json.dumps(
|
if isinstance(self.validated.body, dict) and "name" in self.validated.body:
|
||||||
{
|
body = json.dumps({"name": self.validated.body["name"]}).encode()
|
||||||
"name": self.validated.body["name"],
|
|
||||||
}
|
self.finish(body)
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class DefaultSchemaTest(tornado.testing.AsyncHTTPTestCase):
|
class DefaultSchemaTest(tornado.testing.AsyncHTTPTestCase):
|
||||||
|
@ -102,8 +106,7 @@ class DefaultSchemaTest(tornado.testing.AsyncHTTPTestCase):
|
||||||
with test.assertRaises(NotImplementedError):
|
with test.assertRaises(NotImplementedError):
|
||||||
self.spec
|
self.spec
|
||||||
|
|
||||||
async def get(self) -> None:
|
async def get(self) -> None: ...
|
||||||
...
|
|
||||||
|
|
||||||
return tornado.web.Application(
|
return tornado.web.Application(
|
||||||
[
|
[
|
||||||
|
@ -124,8 +127,7 @@ class DefaultFormatters(tornado.testing.AsyncHTTPTestCase):
|
||||||
async def prepare(self) -> None:
|
async def prepare(self) -> None:
|
||||||
test.assertEqual(dict(), self.custom_formatters)
|
test.assertEqual(dict(), self.custom_formatters)
|
||||||
|
|
||||||
async def get(self) -> None:
|
async def get(self) -> None: ...
|
||||||
...
|
|
||||||
|
|
||||||
return tornado.web.Application(
|
return tornado.web.Application(
|
||||||
[
|
[
|
||||||
|
@ -146,8 +148,7 @@ class DefaultDeserializers(tornado.testing.AsyncHTTPTestCase):
|
||||||
async def prepare(self) -> None:
|
async def prepare(self) -> None:
|
||||||
test.assertEqual(dict(), self.custom_media_type_deserializers)
|
test.assertEqual(dict(), self.custom_media_type_deserializers)
|
||||||
|
|
||||||
async def get(self) -> None:
|
async def get(self) -> None: ...
|
||||||
...
|
|
||||||
|
|
||||||
return tornado.web.Application(
|
return tornado.web.Application(
|
||||||
[
|
[
|
||||||
|
@ -246,7 +247,7 @@ class RequestHandlerTests(tornado.testing.AsyncHTTPTestCase):
|
||||||
|
|
||||||
def test_unexpected_openapi_error(self) -> None:
|
def test_unexpected_openapi_error(self) -> None:
|
||||||
with unittest.mock.patch(
|
with unittest.mock.patch(
|
||||||
"openapi_core.validation.datatypes.BaseValidationResult.raise_for_errors",
|
"openapi_core.OpenAPI.unmarshal_request",
|
||||||
side_effect=OpenAPIError,
|
side_effect=OpenAPIError,
|
||||||
):
|
):
|
||||||
response = self.fetch(
|
response = self.fetch(
|
||||||
|
|
|
@ -1,257 +1,196 @@
|
||||||
from dataclasses import dataclass
|
import dataclasses
|
||||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
import http.cookies
|
||||||
|
import string
|
||||||
|
import typing
|
||||||
import unittest
|
import unittest
|
||||||
from urllib.parse import urlencode, urlparse
|
import urllib.parse
|
||||||
|
|
||||||
import attr
|
from hypothesis import given, provisional
|
||||||
from hypothesis import given
|
import hypothesis.strategies as s
|
||||||
import hypothesis.strategies as s # type: ignore
|
import openapi_core.datatypes
|
||||||
from openapi_core import create_spec # type: ignore
|
import openapi_core.protocols
|
||||||
from openapi_core.exceptions import ( # type: ignore
|
from openapi_core.validation.request.datatypes import RequestParameters
|
||||||
MissingRequiredParameter,
|
import tornado.httpclient
|
||||||
OpenAPIError,
|
import tornado.httputil
|
||||||
)
|
|
||||||
from openapi_core.validation.request.datatypes import ( # type: ignore
|
|
||||||
RequestParameters,
|
|
||||||
OpenAPIRequest,
|
|
||||||
)
|
|
||||||
from tornado.httpclient import HTTPRequest
|
|
||||||
from tornado.httputil import HTTPHeaders, HTTPServerRequest
|
|
||||||
from tornado.testing import AsyncHTTPTestCase
|
|
||||||
from tornado.web import Application, RequestHandler
|
|
||||||
from werkzeug.datastructures import ImmutableMultiDict
|
from werkzeug.datastructures import ImmutableMultiDict
|
||||||
|
|
||||||
from tornado_openapi3 import RequestValidator, TornadoRequestFactory
|
import tornado_openapi3.requests
|
||||||
|
|
||||||
|
from tests import common
|
||||||
|
|
||||||
@dataclass
|
methods = s.sampled_from(
|
||||||
class Parameters:
|
["get", "head", "post", "put", "delete", "connect", "options", "trace", "patch"]
|
||||||
headers: Dict[str, str]
|
)
|
||||||
query_parameters: Dict[str, str]
|
|
||||||
|
|
||||||
def as_openapi(self) -> List[dict]:
|
queries: s.SearchStrategy[ImmutableMultiDict[str, str]] = s.builds(
|
||||||
headers = [
|
ImmutableMultiDict,
|
||||||
{
|
s.lists(
|
||||||
"name": name.lower(),
|
s.tuples(common.field_names, common.field_values),
|
||||||
"in": "header",
|
|
||||||
"required": True,
|
|
||||||
"schema": {"type": "string", "enum": [value]},
|
|
||||||
}
|
|
||||||
for name, value in self.headers.items()
|
|
||||||
]
|
|
||||||
qargs = [
|
|
||||||
{
|
|
||||||
"name": name.lower(),
|
|
||||||
"in": "query",
|
|
||||||
"required": True,
|
|
||||||
"schema": {"type": "string", "enum": [value]},
|
|
||||||
}
|
|
||||||
for name, value in self.query_parameters.items()
|
|
||||||
]
|
|
||||||
return headers + qargs
|
|
||||||
|
|
||||||
|
|
||||||
field_name = s.text(
|
|
||||||
s.characters(
|
|
||||||
min_codepoint=33,
|
|
||||||
max_codepoint=126,
|
|
||||||
blacklist_categories=("Lu",),
|
|
||||||
blacklist_characters=":",
|
|
||||||
),
|
),
|
||||||
min_size=1,
|
|
||||||
)
|
)
|
||||||
field_value = s.text(
|
|
||||||
s.characters(min_codepoint=0x20, max_codepoint=0x7E, blacklist_characters=" \r\n"),
|
cookies: s.SearchStrategy[ImmutableMultiDict[str, str]] = s.builds(
|
||||||
|
ImmutableMultiDict,
|
||||||
|
s.dictionaries(
|
||||||
|
s.text(
|
||||||
|
alphabet=string.ascii_letters + string.digits + "!#$%&'*+-.^_`|~:",
|
||||||
min_size=1,
|
min_size=1,
|
||||||
|
),
|
||||||
|
common.field_values,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
request_parameters = s.builds(
|
||||||
|
RequestParameters,
|
||||||
|
query=queries,
|
||||||
|
header=common.headers,
|
||||||
|
cookie=cookies,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def headers(min_size: int = 0) -> s.SearchStrategy[Dict[str, str]]:
|
@dataclasses.dataclass
|
||||||
return s.dictionaries(field_name, field_value, min_size=min_size)
|
class TestOpenAPIRequest:
|
||||||
|
parameters: openapi_core.datatypes.RequestParameters
|
||||||
|
method: str
|
||||||
def query_parameters(min_size: int = 0) -> s.SearchStrategy[Dict[str, str]]:
|
body: typing.Optional[bytes]
|
||||||
return s.dictionaries(field_name, field_value, min_size=min_size)
|
content_type: str
|
||||||
|
host_url: str
|
||||||
|
path: str
|
||||||
|
|
||||||
|
|
||||||
@s.composite
|
@s.composite
|
||||||
def parameters(
|
def openapi_requests(
|
||||||
draw: Callable[[Any], Any], min_headers: int = 0, min_query_parameters: int = 0
|
draw: typing.Callable[[typing.Any], typing.Any]
|
||||||
) -> Parameters:
|
) -> openapi_core.protocols.Request:
|
||||||
return Parameters(
|
url = draw(provisional.urls())
|
||||||
headers=draw(headers(min_size=min_headers)),
|
parts = urllib.parse.urlparse(url)
|
||||||
query_parameters=draw(query_parameters(min_size=min_query_parameters)),
|
content_type = draw(common.field_values)
|
||||||
|
parameters = draw(request_parameters)
|
||||||
|
parameters.header["Content-Type"] = content_type
|
||||||
|
if parameters.cookie:
|
||||||
|
cookie = http.cookies.SimpleCookie()
|
||||||
|
for key, value in parameters.cookie.items():
|
||||||
|
cookie[key] = value
|
||||||
|
|
||||||
|
for header in cookie.output(header="").splitlines():
|
||||||
|
parameters.header.add_header("Cookie", header.strip())
|
||||||
|
return TestOpenAPIRequest(
|
||||||
|
parameters=parameters,
|
||||||
|
method=draw(methods),
|
||||||
|
body=draw(s.one_of(s.none(), s.binary())),
|
||||||
|
content_type=content_type,
|
||||||
|
host_url="{}://{}".format(parts.scheme, parts.netloc),
|
||||||
|
path=parts.path,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestRequestFactory(unittest.TestCase):
|
class RequestTests(unittest.TestCase):
|
||||||
@given(
|
def assertOpenAPIRequestsEqual(
|
||||||
s.one_of(
|
self,
|
||||||
s.tuples(s.just(""), s.just(dict())),
|
value: openapi_core.protocols.Request,
|
||||||
s.tuples(s.just("http://example.com/foo"), query_parameters()),
|
expected: openapi_core.protocols.Request,
|
||||||
)
|
|
||||||
)
|
|
||||||
def test_http_request(self, opts: Tuple[str, Dict[str, str]]) -> None:
|
|
||||||
url, parameters = opts
|
|
||||||
request_url = f"{url}?{urlencode(parameters)}" if url else ""
|
|
||||||
tornado_request = HTTPRequest(method="GET", url=request_url)
|
|
||||||
expected = OpenAPIRequest(
|
|
||||||
full_url_pattern=url,
|
|
||||||
method="get",
|
|
||||||
parameters=RequestParameters(query=ImmutableMultiDict(parameters)),
|
|
||||||
body=None,
|
|
||||||
mimetype="application/x-www-form-urlencoded",
|
|
||||||
)
|
|
||||||
openapi_request = TornadoRequestFactory.create(tornado_request)
|
|
||||||
self.assertEqual(attr.asdict(expected), attr.asdict(openapi_request))
|
|
||||||
|
|
||||||
@given(
|
|
||||||
s.one_of(
|
|
||||||
s.tuples(s.just(""), s.just(dict())),
|
|
||||||
s.tuples(s.just("http://example.com/foo"), query_parameters()),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
def test_http_server_request(self, opts: Tuple[str, Dict[str, str]]) -> None:
|
|
||||||
url, parameters = opts
|
|
||||||
request_url = f"{url}?{urlencode(parameters)}" if url else ""
|
|
||||||
parsed = urlparse(request_url)
|
|
||||||
tornado_request = HTTPServerRequest(
|
|
||||||
method="GET",
|
|
||||||
uri=f"{parsed.path}?{parsed.query}",
|
|
||||||
)
|
|
||||||
tornado_request.protocol = parsed.scheme
|
|
||||||
tornado_request.host = parsed.netloc.split(":")[0]
|
|
||||||
expected = OpenAPIRequest(
|
|
||||||
full_url_pattern=url,
|
|
||||||
method="get",
|
|
||||||
parameters=RequestParameters(
|
|
||||||
query=ImmutableMultiDict(parameters), path={}, cookie={}
|
|
||||||
),
|
|
||||||
body=None,
|
|
||||||
mimetype="application/x-www-form-urlencoded",
|
|
||||||
)
|
|
||||||
openapi_request = TornadoRequestFactory.create(tornado_request)
|
|
||||||
self.assertEqual(attr.asdict(expected), attr.asdict(openapi_request))
|
|
||||||
|
|
||||||
|
|
||||||
class TestRequest(AsyncHTTPTestCase):
|
|
||||||
def setUp(self) -> None:
|
|
||||||
super(TestRequest, self).setUp()
|
|
||||||
self.request: Optional[HTTPServerRequest] = None
|
|
||||||
|
|
||||||
def get_app(self) -> Application:
|
|
||||||
testcase = self
|
|
||||||
|
|
||||||
class TestHandler(RequestHandler):
|
|
||||||
def get(self) -> None:
|
|
||||||
nonlocal testcase
|
|
||||||
testcase.request = self.request
|
|
||||||
|
|
||||||
return Application([(r"/.*", TestHandler)])
|
|
||||||
|
|
||||||
@given(parameters())
|
|
||||||
def test_simple_request(self, parameters: Parameters) -> None:
|
|
||||||
spec = create_spec(
|
|
||||||
{
|
|
||||||
"openapi": "3.0.0",
|
|
||||||
"info": {"title": "Test specification", "version": "0.1"},
|
|
||||||
"paths": {
|
|
||||||
"/": {
|
|
||||||
"get": {
|
|
||||||
"parameters": parameters.as_openapi(),
|
|
||||||
"responses": {"default": {"description": "Root response"}},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
validator = RequestValidator(spec)
|
|
||||||
self.fetch(
|
|
||||||
"/?" + urlencode(parameters.query_parameters),
|
|
||||||
headers=HTTPHeaders(parameters.headers),
|
|
||||||
)
|
|
||||||
assert self.request is not None
|
|
||||||
result = validator.validate(self.request)
|
|
||||||
result.raise_for_errors()
|
|
||||||
|
|
||||||
@given(parameters(min_headers=1) | parameters(min_query_parameters=1))
|
|
||||||
def test_simple_request_fails_without_parameters(
|
|
||||||
self, parameters: Parameters
|
|
||||||
) -> None:
|
) -> None:
|
||||||
spec = create_spec(
|
self.assertEqual(
|
||||||
{
|
value.parameters.query,
|
||||||
"openapi": "3.0.0",
|
expected.parameters.query,
|
||||||
"info": {"title": "Test specification", "version": "0.1"},
|
"Query parameters are equal",
|
||||||
"paths": {
|
|
||||||
"/": {
|
|
||||||
"get": {
|
|
||||||
"parameters": parameters.as_openapi(),
|
|
||||||
"responses": {"default": {"description": "Root response"}},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
validator = RequestValidator(spec)
|
self.assertEqual(
|
||||||
self.fetch("/")
|
value.parameters.header, expected.parameters.header, "Headers are equal"
|
||||||
assert self.request is not None
|
)
|
||||||
result = validator.validate(self.request)
|
self.assertEqual(
|
||||||
with self.assertRaises(MissingRequiredParameter):
|
value.parameters.cookie, expected.parameters.cookie, "Cookies are equal"
|
||||||
result.raise_for_errors()
|
)
|
||||||
|
self.assertEqual(value.method, expected.method, "HTTP methods are equal")
|
||||||
|
self.assertEqual(value.body, expected.body, "Bodies are equal")
|
||||||
|
self.assertEqual(
|
||||||
|
value.content_type, expected.content_type, "Content types are equal"
|
||||||
|
)
|
||||||
|
self.assertEqual(value.host_url, expected.host_url, "Host URLs are equal")
|
||||||
|
self.assertEqual(value.path, expected.path, "Paths are equal")
|
||||||
|
|
||||||
def test_url_parameters(self) -> None:
|
def url_from_openapi_request(self, request: TestOpenAPIRequest) -> str:
|
||||||
spec = create_spec(
|
scheme, netloc = request.host_url.split("://")
|
||||||
{
|
params = ""
|
||||||
"openapi": "3.0.0",
|
# Preserves multiple values if the parameters are a multidict. This
|
||||||
"info": {"title": "Test specification", "version": "0.1"},
|
# whole dance is because ImmutableMultiDict's .items() does not return
|
||||||
"paths": {
|
# more than one pair per key. Curiously, the Headers structure from the
|
||||||
"/{id}": {
|
# same library does.
|
||||||
"get": {
|
qsl: typing.List[typing.Tuple[str, str]] = []
|
||||||
"parameters": [
|
query_parameters = ImmutableMultiDict(request.parameters.query)
|
||||||
{
|
for key in query_parameters.keys():
|
||||||
"name": "id",
|
for value in query_parameters.getlist(key):
|
||||||
"in": "path",
|
qsl.append((key, value))
|
||||||
"required": True,
|
query = urllib.parse.urlencode(qsl)
|
||||||
"schema": {"type": "integer"},
|
fragment = ""
|
||||||
}
|
return urllib.parse.urlunparse(
|
||||||
],
|
(
|
||||||
"responses": {"default": {"description": "Root response"}},
|
scheme,
|
||||||
}
|
netloc,
|
||||||
}
|
request.path,
|
||||||
},
|
params,
|
||||||
}
|
query,
|
||||||
|
fragment,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
validator = RequestValidator(spec)
|
|
||||||
self.fetch("/1234")
|
|
||||||
assert self.request is not None
|
|
||||||
result = validator.validate(self.request)
|
|
||||||
result.raise_for_errors()
|
|
||||||
|
|
||||||
def test_bad_url_parameters(self) -> None:
|
def tornado_headers_from_openapi_request(
|
||||||
spec = create_spec(
|
self, request: TestOpenAPIRequest
|
||||||
{
|
) -> tornado.httputil.HTTPHeaders:
|
||||||
"openapi": "3.0.0",
|
headers = tornado.httputil.HTTPHeaders()
|
||||||
"info": {"title": "Test specification", "version": "0.1"},
|
for key, value in request.parameters.header.items():
|
||||||
"paths": {
|
headers.add(key, value)
|
||||||
"/{id}": {
|
headers["Content-Type"] = request.content_type
|
||||||
"get": {
|
if request.parameters.cookie:
|
||||||
"parameters": [
|
cookie = http.cookies.SimpleCookie()
|
||||||
{
|
for key, value in request.parameters.cookie.items():
|
||||||
"name": "id",
|
cookie[key] = value
|
||||||
"in": "path",
|
for header in cookie.output(header="").splitlines():
|
||||||
"required": True,
|
headers.add("Cookie", header.strip())
|
||||||
"schema": {"type": "integer"},
|
return headers
|
||||||
}
|
|
||||||
],
|
def openapi_to_tornado_request(
|
||||||
"responses": {"default": {"description": "Root response"}},
|
self, request: TestOpenAPIRequest
|
||||||
}
|
) -> tornado.httpclient.HTTPRequest:
|
||||||
}
|
url = self.url_from_openapi_request(request)
|
||||||
},
|
headers = self.tornado_headers_from_openapi_request(request)
|
||||||
}
|
return tornado.httpclient.HTTPRequest(
|
||||||
|
url,
|
||||||
|
method=request.method.upper(),
|
||||||
|
headers=headers,
|
||||||
|
body=request.body,
|
||||||
)
|
)
|
||||||
validator = RequestValidator(spec)
|
|
||||||
self.fetch("/abcd")
|
def openapi_to_tornado_server_request(
|
||||||
assert self.request is not None
|
self, request: TestOpenAPIRequest
|
||||||
result = validator.validate(self.request)
|
) -> tornado.httputil.HTTPServerRequest:
|
||||||
with self.assertRaises(OpenAPIError):
|
url = self.url_from_openapi_request(request)
|
||||||
result.raise_for_errors()
|
headers = self.tornado_headers_from_openapi_request(request)
|
||||||
|
uri = url.removeprefix(request.host_url)
|
||||||
|
server_request = tornado.httputil.HTTPServerRequest(
|
||||||
|
method=request.method.upper(), uri=uri, headers=headers, body=request.body
|
||||||
|
)
|
||||||
|
scheme, netloc = request.host_url.split("://")
|
||||||
|
server_request.protocol = scheme
|
||||||
|
server_request.host = netloc
|
||||||
|
return server_request
|
||||||
|
|
||||||
|
@given(openapi_requests())
|
||||||
|
def test_http_request_round_trip_conversion(
|
||||||
|
self, request: TestOpenAPIRequest
|
||||||
|
) -> None:
|
||||||
|
converted = tornado_openapi3.requests.TornadoOpenAPIRequest(
|
||||||
|
self.openapi_to_tornado_request(request)
|
||||||
|
)
|
||||||
|
self.assertOpenAPIRequestsEqual(converted, request)
|
||||||
|
|
||||||
|
@given(openapi_requests())
|
||||||
|
def test_http_server_request_round_trip_conversion(
|
||||||
|
self, request: TestOpenAPIRequest
|
||||||
|
) -> None:
|
||||||
|
# HTTP Server request bodies are not optional
|
||||||
|
request.body = request.body or b""
|
||||||
|
converted = tornado_openapi3.requests.TornadoOpenAPIRequest(
|
||||||
|
self.openapi_to_tornado_server_request(request)
|
||||||
|
)
|
||||||
|
self.assertOpenAPIRequestsEqual(converted, request)
|
||||||
|
|
|
@ -1,110 +1,81 @@
|
||||||
from dataclasses import dataclass
|
import dataclasses
|
||||||
from typing import Any, Callable, Dict, Optional
|
import io
|
||||||
|
import typing
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
import attr
|
|
||||||
|
|
||||||
from hypothesis import given
|
from hypothesis import given
|
||||||
import hypothesis.strategies as s
|
import hypothesis.strategies as s
|
||||||
|
|
||||||
from openapi_core import create_spec # type: ignore
|
import openapi_core.protocols
|
||||||
from openapi_core.validation.response.datatypes import OpenAPIResponse # type: ignore
|
import tornado.httpclient
|
||||||
from tornado.httpclient import HTTPRequest, HTTPResponse
|
import tornado.httputil
|
||||||
from tornado.testing import AsyncHTTPTestCase
|
from werkzeug.datastructures import Headers
|
||||||
from tornado.web import Application, RequestHandler
|
|
||||||
|
|
||||||
from tornado_openapi3 import (
|
import tornado_openapi3.responses
|
||||||
ResponseValidator,
|
|
||||||
TornadoResponseFactory,
|
from tests import common
|
||||||
)
|
import tornado_openapi3
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclasses.dataclass
|
||||||
class Responses:
|
class TestOpenAPIResponse:
|
||||||
code: int
|
status_code: int
|
||||||
headers: Dict[str, str]
|
headers: Headers
|
||||||
|
content_type: str
|
||||||
def as_openapi(self) -> Dict[str, Any]:
|
data: typing.Optional[bytes]
|
||||||
return {
|
|
||||||
str(self.code): {
|
|
||||||
"description": "Response",
|
|
||||||
"headers": {
|
|
||||||
name: {"schema": {"type": "string", "enum": [value]}}
|
|
||||||
for name, value in self.headers.items()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@s.composite
|
@s.composite
|
||||||
def responses(draw: Callable[[Any], Any], min_headers: int = 0) -> Responses:
|
def openapi_responses(
|
||||||
field_name = s.text(
|
draw: typing.Callable[[typing.Any], typing.Any]
|
||||||
s.characters(
|
) -> openapi_core.protocols.Response:
|
||||||
min_codepoint=33,
|
status_code = draw(s.integers(min_value=100, max_value=599))
|
||||||
max_codepoint=126,
|
headers = draw(common.headers)
|
||||||
blacklist_categories=("Lu",),
|
content_type = draw(common.field_values)
|
||||||
blacklist_characters=":",
|
headers["Content-Type"] = content_type
|
||||||
),
|
data = draw(s.binary())
|
||||||
min_size=1,
|
return TestOpenAPIResponse(
|
||||||
)
|
status_code=status_code,
|
||||||
field_value = s.text(
|
headers=headers,
|
||||||
s.characters(
|
content_type=content_type,
|
||||||
min_codepoint=0x20, max_codepoint=0x7E, blacklist_characters=" \r\n"
|
data=data,
|
||||||
),
|
|
||||||
min_size=1,
|
|
||||||
)
|
|
||||||
code = s.sampled_from([200, 304, 400, 500])
|
|
||||||
headers = s.dictionaries(field_name, field_value, min_size=min_headers)
|
|
||||||
return Responses(
|
|
||||||
code=draw(code),
|
|
||||||
headers=draw(headers),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestResponseFactory(unittest.TestCase):
|
class ResponseTests(unittest.TestCase):
|
||||||
def test_response(self) -> None:
|
def assertOpenAPIResponsesEqual(
|
||||||
tornado_request = HTTPRequest(url="http://example.com")
|
self,
|
||||||
tornado_response = HTTPResponse(request=tornado_request, code=200)
|
value: openapi_core.protocols.Response,
|
||||||
expected = OpenAPIResponse(
|
expected: openapi_core.protocols.Response,
|
||||||
data=b"",
|
) -> None:
|
||||||
status_code=200,
|
self.assertEqual(
|
||||||
mimetype="text/html",
|
value.status_code, expected.status_code, "Status codes are equal"
|
||||||
)
|
)
|
||||||
openapi_response = TornadoResponseFactory.create(tornado_response)
|
self.assertEqual(value.headers, expected.headers, "Headers are equal")
|
||||||
self.assertEqual(attr.asdict(expected), attr.asdict(openapi_response))
|
self.assertEqual(
|
||||||
|
value.content_type, expected.content_type, "Content types are equal"
|
||||||
|
|
||||||
class ResponsesHandler(RequestHandler):
|
|
||||||
responses: Optional[Responses] = None
|
|
||||||
|
|
||||||
def get(self) -> None:
|
|
||||||
if ResponsesHandler.responses:
|
|
||||||
self.set_status(ResponsesHandler.responses.code)
|
|
||||||
for name, value in ResponsesHandler.responses.headers.items():
|
|
||||||
self.add_header(name, value)
|
|
||||||
|
|
||||||
|
|
||||||
class TestResponse(AsyncHTTPTestCase):
|
|
||||||
def get_app(self) -> Application:
|
|
||||||
return Application([(r"/.*", ResponsesHandler)])
|
|
||||||
|
|
||||||
@given(responses())
|
|
||||||
def test_simple_request(self, responses: Responses) -> None:
|
|
||||||
spec = create_spec(
|
|
||||||
{
|
|
||||||
"openapi": "3.0.0",
|
|
||||||
"info": {"title": "Test specification", "version": "0.1"},
|
|
||||||
"paths": {
|
|
||||||
"/": {
|
|
||||||
"get": {
|
|
||||||
"responses": responses.as_openapi(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
ResponsesHandler.responses = responses
|
self.assertEqual(value.data, expected.data, "Bodies are equal")
|
||||||
validator = ResponseValidator(spec)
|
|
||||||
response = self.fetch("/")
|
def openapi_to_tornado_response(
|
||||||
result = validator.validate(response)
|
self, response: TestOpenAPIResponse
|
||||||
result.raise_for_errors()
|
) -> tornado.httpclient.HTTPResponse:
|
||||||
|
headers = tornado.httputil.HTTPHeaders()
|
||||||
|
for key, value in response.headers.items():
|
||||||
|
headers.add(key, value)
|
||||||
|
return tornado.httpclient.HTTPResponse(
|
||||||
|
request=tornado.httpclient.HTTPRequest(""),
|
||||||
|
code=response.status_code,
|
||||||
|
headers=headers,
|
||||||
|
buffer=io.BytesIO(response.data or b""),
|
||||||
|
)
|
||||||
|
|
||||||
|
@given(openapi_responses())
|
||||||
|
def test_http_response_round_trip_conversion(
|
||||||
|
self, response: TestOpenAPIResponse
|
||||||
|
) -> None:
|
||||||
|
converted = tornado_openapi3.responses.TornadoOpenAPIResponse(
|
||||||
|
self.openapi_to_tornado_response(response)
|
||||||
|
)
|
||||||
|
self.assertOpenAPIResponsesEqual(converted, response)
|
||||||
|
|
|
@ -1,11 +1,13 @@
|
||||||
import json
|
import json
|
||||||
|
import typing
|
||||||
|
|
||||||
from openapi_core.templating.responses.exceptions import ( # type: ignore
|
from openapi_core.templating.responses.exceptions import (
|
||||||
ResponseNotFound,
|
ResponseNotFound,
|
||||||
)
|
)
|
||||||
import tornado.web
|
import tornado.web
|
||||||
from tornado_openapi3.handler import OpenAPIRequestHandler
|
from tornado_openapi3.handler import OpenAPIRequestHandler
|
||||||
from tornado_openapi3.testing import AsyncOpenAPITestCase
|
from tornado_openapi3.testing import AsyncOpenAPITestCase
|
||||||
|
from tornado_openapi3.types import Deserializer
|
||||||
|
|
||||||
|
|
||||||
def spec(responses: dict = dict()) -> dict:
|
def spec(responses: dict = dict()) -> dict:
|
||||||
|
@ -37,11 +39,9 @@ def spec(responses: dict = dict()) -> dict:
|
||||||
|
|
||||||
|
|
||||||
class TestTestCase(AsyncOpenAPITestCase):
|
class TestTestCase(AsyncOpenAPITestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None: ...
|
||||||
...
|
|
||||||
|
|
||||||
def tearDown(self) -> None:
|
def tearDown(self) -> None: ...
|
||||||
...
|
|
||||||
|
|
||||||
def test_schema_must_be_implemented(self) -> None:
|
def test_schema_must_be_implemented(self) -> None:
|
||||||
with self.assertRaises(NotImplementedError):
|
with self.assertRaises(NotImplementedError):
|
||||||
|
@ -52,8 +52,13 @@ class TestTestCase(AsyncOpenAPITestCase):
|
||||||
|
|
||||||
|
|
||||||
class BaseTestCase(AsyncOpenAPITestCase):
|
class BaseTestCase(AsyncOpenAPITestCase):
|
||||||
spec_dict = spec()
|
@property
|
||||||
custom_media_type_deserializers = {
|
def spec_dict(self) -> dict:
|
||||||
|
return spec()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def custom_media_type_deserializers(self) -> typing.Dict[str, Deserializer]:
|
||||||
|
return {
|
||||||
"application/vnd.example.resource+json": json.loads,
|
"application/vnd.example.resource+json": json.loads,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -61,16 +66,22 @@ class BaseTestCase(AsyncOpenAPITestCase):
|
||||||
testcase = self
|
testcase = self
|
||||||
|
|
||||||
class ResourceHandler(OpenAPIRequestHandler):
|
class ResourceHandler(OpenAPIRequestHandler):
|
||||||
spec = self.spec
|
@property
|
||||||
custom_media_type_deserializers = self.custom_media_type_deserializers
|
def spec_dict(self) -> dict:
|
||||||
|
return spec()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def custom_media_type_deserializers(self) -> typing.Dict[str, Deserializer]:
|
||||||
|
return {
|
||||||
|
"application/vnd.example.resource+json": json.loads,
|
||||||
|
}
|
||||||
|
|
||||||
async def get(self) -> None:
|
async def get(self) -> None:
|
||||||
await testcase.get(self)
|
await testcase.get(self)
|
||||||
|
|
||||||
return tornado.web.Application([(r"/resource", ResourceHandler)])
|
return tornado.web.Application([(r"/resource", ResourceHandler)])
|
||||||
|
|
||||||
async def get(self, handler: tornado.web.RequestHandler) -> None:
|
async def get(self, handler: tornado.web.RequestHandler) -> None: ...
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
class SuccessTests(BaseTestCase):
|
class SuccessTests(BaseTestCase):
|
||||||
|
@ -97,7 +108,9 @@ class SuccessTests(BaseTestCase):
|
||||||
|
|
||||||
|
|
||||||
class IncorrectResponseTests(BaseTestCase):
|
class IncorrectResponseTests(BaseTestCase):
|
||||||
spec_dict = spec(responses={"200": {"description": "Success"}})
|
@property
|
||||||
|
def spec_dict(self) -> dict:
|
||||||
|
return spec(responses={"200": {"description": "Success"}})
|
||||||
|
|
||||||
async def get(self, handler: tornado.web.RequestHandler) -> None:
|
async def get(self, handler: tornado.web.RequestHandler) -> None:
|
||||||
handler.set_status(400)
|
handler.set_status(400)
|
||||||
|
@ -112,7 +125,9 @@ class IncorrectResponseTests(BaseTestCase):
|
||||||
|
|
||||||
|
|
||||||
class RaiseErrorTests(BaseTestCase):
|
class RaiseErrorTests(BaseTestCase):
|
||||||
spec_dict = spec(
|
@property
|
||||||
|
def spec_dict(self) -> dict:
|
||||||
|
return spec(
|
||||||
responses={
|
responses={
|
||||||
"500": {
|
"500": {
|
||||||
"description": "An error has occurred.",
|
"description": "An error has occurred.",
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
from tornado_openapi3.handler import OpenAPIRequestHandler
|
# from tornado_openapi3.handler import OpenAPIRequestHandler
|
||||||
from tornado_openapi3.requests import RequestValidator, TornadoRequestFactory
|
# from tornado_openapi3.requests import RequestValidator, TornadoRequestFactory
|
||||||
from tornado_openapi3.responses import ResponseValidator, TornadoResponseFactory
|
# from tornado_openapi3.responses import ResponseValidator, TornadoResponseFactory
|
||||||
|
|
||||||
__all__ = [
|
# __all__ = [
|
||||||
"OpenAPIRequestHandler",
|
# "OpenAPIRequestHandler",
|
||||||
"RequestValidator",
|
# "RequestValidator",
|
||||||
"ResponseValidator",
|
# "ResponseValidator",
|
||||||
"TornadoRequestFactory",
|
# "TornadoRequestFactory",
|
||||||
"TornadoResponseFactory",
|
# "TornadoResponseFactory",
|
||||||
]
|
# ]
|
||||||
|
|
|
@ -1,28 +1,25 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from typing import Mapping
|
import typing
|
||||||
|
|
||||||
from openapi_core import create_spec # type: ignore
|
import openapi_core
|
||||||
from openapi_core.casting.schemas.exceptions import CastError # type: ignore
|
import openapi_core.validation.request.exceptions
|
||||||
from openapi_core.exceptions import ( # type: ignore
|
from openapi_core.exceptions import OpenAPIError
|
||||||
MissingRequestBody,
|
from openapi_core.validation.request.exceptions import (
|
||||||
MissingRequiredParameter,
|
RequestBodyValidationError,
|
||||||
OpenAPIError,
|
SecurityValidationError,
|
||||||
)
|
)
|
||||||
from openapi_core.deserializing.exceptions import DeserializeError # type: ignore
|
from openapi_core.templating.media_types.exceptions import (
|
||||||
from openapi_core.spec.paths import SpecPath # type: ignore
|
|
||||||
from openapi_core.templating.media_types.exceptions import ( # type: ignore
|
|
||||||
MediaTypeNotFound,
|
MediaTypeNotFound,
|
||||||
)
|
)
|
||||||
from openapi_core.templating.paths.exceptions import ( # type: ignore
|
from openapi_core.templating.paths.exceptions import (
|
||||||
OperationNotFound,
|
OperationNotFound,
|
||||||
PathNotFound,
|
PathNotFound,
|
||||||
)
|
)
|
||||||
from openapi_core.unmarshalling.schemas.exceptions import ValidateError # type: ignore
|
|
||||||
from openapi_core.validation.exceptions import InvalidSecurity # type: ignore
|
|
||||||
import tornado.web
|
import tornado.web
|
||||||
|
|
||||||
from tornado_openapi3.requests import RequestValidator
|
import tornado_openapi3.requests
|
||||||
|
import tornado_openapi3.types
|
||||||
from tornado_openapi3.types import Deserializer, Formatter
|
from tornado_openapi3.types import Deserializer, Formatter
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
@ -50,7 +47,7 @@ class OpenAPIRequestHandler(tornado.web.RequestHandler):
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def spec(self) -> SpecPath:
|
def spec(self) -> openapi_core.OpenAPI:
|
||||||
"""The OpenAPI 3 specification.
|
"""The OpenAPI 3 specification.
|
||||||
|
|
||||||
Override this in your request handlers to customize how your OpenAPI 3
|
Override this in your request handlers to customize how your OpenAPI 3
|
||||||
|
@ -59,10 +56,21 @@ class OpenAPIRequestHandler(tornado.web.RequestHandler):
|
||||||
:rtype: :class:`openapi_core.schema.specs.model.Spec`
|
:rtype: :class:`openapi_core.schema.specs.model.Spec`
|
||||||
|
|
||||||
"""
|
"""
|
||||||
return create_spec(self.spec_dict, validate_spec=False)
|
config = openapi_core.Config(
|
||||||
|
extra_format_unmarshallers={
|
||||||
|
format: formatter.unmarshal
|
||||||
|
for format, formatter in self.custom_formatters.items()
|
||||||
|
},
|
||||||
|
extra_format_validators={
|
||||||
|
format: formatter.validate
|
||||||
|
for format, formatter in self.custom_formatters.items()
|
||||||
|
},
|
||||||
|
extra_media_type_deserializers=self.custom_media_type_deserializers,
|
||||||
|
)
|
||||||
|
return openapi_core.OpenAPI.from_dict(self.spec_dict, config=config)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def custom_formatters(self) -> Mapping[str, Formatter]:
|
def custom_formatters(self) -> typing.Dict[str, Formatter]:
|
||||||
"""A dictionary mapping value formats to formatter objects.
|
"""A dictionary mapping value formats to formatter objects.
|
||||||
|
|
||||||
If your schemas make use of format modifiers, you may specify them in
|
If your schemas make use of format modifiers, you may specify them in
|
||||||
|
@ -76,7 +84,7 @@ class OpenAPIRequestHandler(tornado.web.RequestHandler):
|
||||||
return dict()
|
return dict()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def custom_media_type_deserializers(self) -> Mapping[str, Deserializer]:
|
def custom_media_type_deserializers(self) -> typing.Dict[str, Deserializer]:
|
||||||
"""A dictionary mapping media types to deserializing functions.
|
"""A dictionary mapping media types to deserializing functions.
|
||||||
|
|
||||||
If your endpoints make use of content types beyond ``application/json``,
|
If your endpoints make use of content types beyond ``application/json``,
|
||||||
|
@ -128,31 +136,22 @@ class OpenAPIRequestHandler(tornado.web.RequestHandler):
|
||||||
if maybe_coro and asyncio.iscoroutine(maybe_coro): # pragma: no cover
|
if maybe_coro and asyncio.iscoroutine(maybe_coro): # pragma: no cover
|
||||||
await maybe_coro
|
await maybe_coro
|
||||||
|
|
||||||
validator = RequestValidator(
|
request = tornado_openapi3.requests.TornadoOpenAPIRequest(self.request)
|
||||||
self.spec,
|
result = self.spec.unmarshal_request(request)
|
||||||
custom_formatters=self.custom_formatters,
|
|
||||||
custom_media_type_deserializers=self.custom_media_type_deserializers,
|
|
||||||
)
|
|
||||||
result = validator.validate(self.request)
|
|
||||||
try:
|
try:
|
||||||
result.raise_for_errors()
|
result.raise_for_errors()
|
||||||
except PathNotFound as e:
|
except PathNotFound as e:
|
||||||
self.on_openapi_error(404, e)
|
self.on_openapi_error(404, e)
|
||||||
except OperationNotFound as e:
|
except OperationNotFound as e:
|
||||||
self.on_openapi_error(405, e)
|
self.on_openapi_error(405, e)
|
||||||
except (
|
except RequestBodyValidationError as e:
|
||||||
CastError,
|
if isinstance(e.__cause__, MediaTypeNotFound):
|
||||||
DeserializeError,
|
|
||||||
MissingRequiredParameter,
|
|
||||||
MissingRequestBody,
|
|
||||||
ValidateError,
|
|
||||||
) as e:
|
|
||||||
self.on_openapi_error(400, e)
|
|
||||||
except InvalidSecurity as e:
|
|
||||||
self.on_openapi_error(401, e)
|
|
||||||
except MediaTypeNotFound as e:
|
|
||||||
self.on_openapi_error(415, e)
|
self.on_openapi_error(415, e)
|
||||||
except OpenAPIError as e:
|
else:
|
||||||
|
self.on_openapi_error(400, e)
|
||||||
|
except SecurityValidationError as e:
|
||||||
|
self.on_openapi_error(401, e)
|
||||||
|
except OpenAPIError as e: # pragma: no cover
|
||||||
logger.exception("Unexpected validation failure")
|
logger.exception("Unexpected validation failure")
|
||||||
self.on_openapi_error(500, e)
|
self.on_openapi_error(500, e)
|
||||||
self.validated = result
|
self.validated = result
|
||||||
|
|
|
@ -1,75 +1,58 @@
|
||||||
import itertools
|
import typing
|
||||||
|
import urllib.parse
|
||||||
from urllib.parse import parse_qsl
|
from urllib.parse import parse_qsl
|
||||||
from typing import Union
|
|
||||||
|
|
||||||
from openapi_core.validation.request.datatypes import ( # type: ignore
|
from openapi_core.validation.request.datatypes import RequestParameters
|
||||||
OpenAPIRequest,
|
|
||||||
RequestParameters,
|
|
||||||
RequestValidationResult,
|
|
||||||
)
|
|
||||||
from openapi_core.validation.request import validators # type: ignore
|
|
||||||
from tornado.httpclient import HTTPRequest
|
from tornado.httpclient import HTTPRequest
|
||||||
from tornado.httputil import HTTPServerRequest, parse_cookie
|
from tornado.httputil import HTTPServerRequest, parse_cookie
|
||||||
from werkzeug.datastructures import ImmutableMultiDict, Headers
|
from werkzeug.datastructures import ImmutableMultiDict, Headers
|
||||||
|
|
||||||
from .util import parse_mimetype
|
|
||||||
|
|
||||||
|
class TornadoOpenAPIRequest:
|
||||||
class TornadoRequestFactory:
|
def __init__(self, request: typing.Union[HTTPRequest, HTTPServerRequest]) -> None:
|
||||||
"""Factory for converting Tornado requests to OpenAPI request objects."""
|
"""Create an OpenAPI request from Tornado request objects.
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def create(cls, request: Union[HTTPRequest, HTTPServerRequest]) -> OpenAPIRequest:
|
|
||||||
"""Creates an OpenAPI request from Tornado request objects.
|
|
||||||
|
|
||||||
Supports both :class:`tornado.httpclient.HTTPRequest` and
|
Supports both :class:`tornado.httpclient.HTTPRequest` and
|
||||||
:class:`tornado.httputil.HTTPServerRequest` objects.
|
:class:`tornado.httputil.HTTPServerRequest` objects.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
self.request = request
|
||||||
if isinstance(request, HTTPRequest):
|
if isinstance(request, HTTPRequest):
|
||||||
if request.url:
|
parts = urllib.parse.urlparse(request.url)
|
||||||
path, _, querystring = request.url.partition("?")
|
|
||||||
query_arguments: ImmutableMultiDict[str, str] = ImmutableMultiDict(
|
|
||||||
parse_qsl(querystring)
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
path = ""
|
parts = urllib.parse.urlparse(request.full_url())
|
||||||
query_arguments = ImmutableMultiDict()
|
protocol = parts.scheme
|
||||||
else:
|
host = parts.netloc
|
||||||
path, _, _ = request.full_url().partition("?")
|
path = parts.path
|
||||||
if path == "://":
|
query_arguments = parse_qsl(parts.query)
|
||||||
path = ""
|
self.protocol = protocol
|
||||||
query_arguments = ImmutableMultiDict(
|
self.host = host
|
||||||
itertools.chain(
|
self.path = path
|
||||||
*[
|
cookies = {}
|
||||||
[(k, v.decode("utf-8")) for v in vs]
|
for values in request.headers.get_list("Cookie"):
|
||||||
for k, vs in request.query_arguments.items()
|
cookies.update(parse_cookie(values))
|
||||||
]
|
self.parameters = RequestParameters(
|
||||||
)
|
query=ImmutableMultiDict(query_arguments),
|
||||||
)
|
|
||||||
return OpenAPIRequest(
|
|
||||||
full_url_pattern=path,
|
|
||||||
method=request.method.lower() if request.method else "get",
|
|
||||||
parameters=RequestParameters(
|
|
||||||
query=query_arguments,
|
|
||||||
header=Headers(request.headers.get_all()),
|
header=Headers(request.headers.get_all()),
|
||||||
cookie=parse_cookie(request.headers.get("Cookie", "")),
|
cookie=ImmutableMultiDict(cookies),
|
||||||
),
|
)
|
||||||
body=request.body if request.body else None,
|
self.content_type = request.headers.get(
|
||||||
mimetype=parse_mimetype(
|
"Content-Type", "application/x-www-form-urlencoded"
|
||||||
request.headers.get("Content-Type", "application/x-www-form-urlencoded")
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def host_url(self) -> str:
|
||||||
|
return "{}://{}".format(self.protocol, self.host)
|
||||||
|
|
||||||
class RequestValidator(validators.RequestValidator):
|
@property
|
||||||
"""Validator for Tornado HTTP Requests."""
|
def method(self) -> str:
|
||||||
|
method = self.request.method or "GET"
|
||||||
|
return method.lower()
|
||||||
|
|
||||||
def validate(
|
@property
|
||||||
self, request: Union[HTTPRequest, HTTPServerRequest]
|
def body(self) -> typing.Optional[bytes]:
|
||||||
) -> RequestValidationResult:
|
return self.request.body
|
||||||
"""Validate a Tornado HTTP request object."""
|
|
||||||
return super().validate(TornadoRequestFactory.create(request))
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["RequestValidator", "TornadoRequestFactory"]
|
__all__ = ["TornadoOpenAPIRequest"]
|
||||||
|
|
|
@ -1,37 +1,13 @@
|
||||||
from openapi_core.validation.response.datatypes import ( # type: ignore
|
|
||||||
OpenAPIResponse,
|
|
||||||
ResponseValidationResult,
|
|
||||||
)
|
|
||||||
from openapi_core.validation.response import validators # type: ignore
|
|
||||||
from tornado.httpclient import HTTPResponse
|
from tornado.httpclient import HTTPResponse
|
||||||
|
from werkzeug.datastructures import Headers
|
||||||
from .requests import TornadoRequestFactory
|
|
||||||
from .util import parse_mimetype
|
|
||||||
|
|
||||||
|
|
||||||
class TornadoResponseFactory:
|
class TornadoOpenAPIResponse:
|
||||||
"""Factory for converting Tornado responses to OpenAPI response objects."""
|
def __init__(self, response: HTTPResponse) -> None:
|
||||||
|
self.status_code = response.code
|
||||||
@classmethod
|
self.headers = Headers(response.headers.get_all())
|
||||||
def create(cls, response: HTTPResponse) -> OpenAPIResponse:
|
self.content_type = response.headers.get("Content-Type", "text/html")
|
||||||
"""Creates an OpenAPI response from Tornado response objects."""
|
self.data = response.body
|
||||||
mimetype = parse_mimetype(response.headers.get("Content-Type", "text/html"))
|
|
||||||
return OpenAPIResponse(
|
|
||||||
data=response.body if response.body else b"",
|
|
||||||
status_code=response.code,
|
|
||||||
mimetype=mimetype,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ResponseValidator(validators.ResponseValidator):
|
__all__ = ["TornadoOpenAPIResponse"]
|
||||||
"""Validator for Tornado HTTP Responses."""
|
|
||||||
|
|
||||||
def validate(self, response: HTTPResponse) -> ResponseValidationResult:
|
|
||||||
"""Validate a Tornado HTTP response object."""
|
|
||||||
return super().validate(
|
|
||||||
TornadoRequestFactory.create(response.request),
|
|
||||||
TornadoResponseFactory.create(response),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["ResponseValidator", "TornadoResponseFactory"]
|
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
from typing import Any
|
import typing
|
||||||
|
|
||||||
import tornado.httpclient
|
import tornado.httpclient
|
||||||
import tornado.testing
|
import tornado.testing
|
||||||
|
import openapi_core
|
||||||
|
|
||||||
from openapi_core import create_spec # type: ignore
|
from tornado_openapi3.requests import TornadoOpenAPIRequest
|
||||||
from openapi_core.spec.paths import SpecPath # type: ignore
|
from tornado_openapi3.responses import TornadoOpenAPIResponse
|
||||||
from tornado_openapi3.responses import ResponseValidator
|
from tornado_openapi3.types import Deserializer, Formatter
|
||||||
|
|
||||||
|
|
||||||
class AsyncOpenAPITestCase(tornado.testing.AsyncHTTPTestCase):
|
class AsyncOpenAPITestCase(tornado.testing.AsyncHTTPTestCase):
|
||||||
|
@ -29,7 +30,7 @@ class AsyncOpenAPITestCase(tornado.testing.AsyncHTTPTestCase):
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def spec(self) -> SpecPath:
|
def spec(self) -> openapi_core.OpenAPI:
|
||||||
"""The OpenAPI 3 specification.
|
"""The OpenAPI 3 specification.
|
||||||
|
|
||||||
Override this in your test cases to customize how your OpenAPI 3 spec is
|
Override this in your test cases to customize how your OpenAPI 3 spec is
|
||||||
|
@ -38,10 +39,21 @@ class AsyncOpenAPITestCase(tornado.testing.AsyncHTTPTestCase):
|
||||||
:rtype: :class:`openapi_core.schema.specs.model.Spec`
|
:rtype: :class:`openapi_core.schema.specs.model.Spec`
|
||||||
|
|
||||||
"""
|
"""
|
||||||
return create_spec(self.spec_dict)
|
config = openapi_core.Config(
|
||||||
|
extra_format_unmarshallers={
|
||||||
|
format: formatter.unmarshal
|
||||||
|
for format, formatter in self.custom_formatters.items()
|
||||||
|
},
|
||||||
|
extra_format_validators={
|
||||||
|
format: formatter.validate
|
||||||
|
for format, formatter in self.custom_formatters.items()
|
||||||
|
},
|
||||||
|
extra_media_type_deserializers=self.custom_media_type_deserializers,
|
||||||
|
)
|
||||||
|
return openapi_core.OpenAPI.from_dict(self.spec_dict, config=config)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def custom_formatters(self) -> dict:
|
def custom_formatters(self) -> typing.Dict[str, Formatter]:
|
||||||
"""A dictionary mapping value formats to formatter objects.
|
"""A dictionary mapping value formats to formatter objects.
|
||||||
|
|
||||||
A formatter object must provide:
|
A formatter object must provide:
|
||||||
|
@ -52,7 +64,7 @@ class AsyncOpenAPITestCase(tornado.testing.AsyncHTTPTestCase):
|
||||||
return dict()
|
return dict()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def custom_media_type_deserializers(self) -> dict:
|
def custom_media_type_deserializers(self) -> typing.Dict[str, Deserializer]:
|
||||||
"""A dictionary mapping media types to deserializing functions.
|
"""A dictionary mapping media types to deserializing functions.
|
||||||
|
|
||||||
If your endpoints make use of content types beyond ``application/json``,
|
If your endpoints make use of content types beyond ``application/json``,
|
||||||
|
@ -62,22 +74,8 @@ class AsyncOpenAPITestCase(tornado.testing.AsyncHTTPTestCase):
|
||||||
"""
|
"""
|
||||||
return dict()
|
return dict()
|
||||||
|
|
||||||
def setUp(self) -> None:
|
|
||||||
"""Hook method for setting up the test fixture before exercising it.
|
|
||||||
|
|
||||||
Instantiates the :class:`~tornado_openapi3.responses.ResponseValidator`
|
|
||||||
for this test case.
|
|
||||||
|
|
||||||
"""
|
|
||||||
super().setUp()
|
|
||||||
self.validator = ResponseValidator(
|
|
||||||
self.spec,
|
|
||||||
custom_formatters=self.custom_formatters,
|
|
||||||
custom_media_type_deserializers=self.custom_media_type_deserializers,
|
|
||||||
)
|
|
||||||
|
|
||||||
def fetch(
|
def fetch(
|
||||||
self, path: str, raise_error: bool = False, **kwargs: Any
|
self, path: str, raise_error: bool = False, **kwargs: typing.Any
|
||||||
) -> tornado.httpclient.HTTPResponse:
|
) -> tornado.httpclient.HTTPResponse:
|
||||||
"""Convenience methiod to synchronously fetch a URL.
|
"""Convenience methiod to synchronously fetch a URL.
|
||||||
|
|
||||||
|
@ -95,7 +93,10 @@ class AsyncOpenAPITestCase(tornado.testing.AsyncHTTPTestCase):
|
||||||
return super().fetch(path, raise_error=raise_error, **kwargs)
|
return super().fetch(path, raise_error=raise_error, **kwargs)
|
||||||
|
|
||||||
response = super().fetch(path, raise_error=False, **kwargs)
|
response = super().fetch(path, raise_error=False, **kwargs)
|
||||||
result = self.validator.validate(response)
|
result = self.spec.unmarshal_response(
|
||||||
|
request=TornadoOpenAPIRequest(response.request),
|
||||||
|
response=TornadoOpenAPIResponse(response),
|
||||||
|
)
|
||||||
result.raise_for_errors()
|
result.raise_for_errors()
|
||||||
if raise_error:
|
if raise_error:
|
||||||
response.rethrow()
|
response.rethrow()
|
||||||
|
|
|
@ -2,7 +2,7 @@ import typing
|
||||||
import typing_extensions
|
import typing_extensions
|
||||||
|
|
||||||
#: A type representing an OpenAPI deserializer.
|
#: A type representing an OpenAPI deserializer.
|
||||||
Deserializer = typing.Callable[[typing.Union[bytes, str]], typing.Any]
|
Deserializer = typing.Callable[[bytes], typing.Any]
|
||||||
|
|
||||||
|
|
||||||
class Formatter(typing_extensions.Protocol):
|
class Formatter(typing_extensions.Protocol):
|
||||||
|
|
Loading…
Reference in a new issue