Falcon integration

This commit is contained in:
Artur Maciag 2020-02-17 16:33:01 +00:00 committed by p1c2u
parent 09f87a9967
commit 9baea54920
10 changed files with 430 additions and 42 deletions

View file

@ -206,6 +206,48 @@ You can use DjangoOpenAPIResponse as a Django response factory:
validator = ResponseValidator(spec)
result = validator.validate(openapi_request, openapi_response)
Falcon
******
This section describes integration with `Falcon <https://falconframework.org>`__ web framework.
Middleware
==========
Falcon API can be integrated by `FalconOpenAPIMiddleware` middleware.
.. code-block:: python
from openapi_core.contrib.falcon.middlewares import FalconOpenAPIMiddleware
openapi_middleware = FalconOpenAPIMiddleware.from_spec(spec)
api = falcon.API(middleware=[openapi_middleware])
Low level
=========
For Falcon you can use FalconOpenAPIRequest a Falcon request factory:
.. code-block:: python
from openapi_core.validation.request.validators import RequestValidator
from openapi_core.contrib.falcon import FalconOpenAPIRequest
openapi_request = FalconOpenAPIRequest(falcon_request)
validator = RequestValidator(spec)
result = validator.validate(openapi_request)
You can use FalconOpenAPIResponse as a Falcon response factory:
.. code-block:: python
from openapi_core.validation.response.validators import ResponseValidator
from openapi_core.contrib.falcon import FalconOpenAPIResponse
openapi_response = FalconOpenAPIResponse(falcon_response)
validator = ResponseValidator(spec)
result = validator.validate(openapi_request, openapi_response)
Flask
*****

View file

@ -0,0 +1,52 @@
"""OpenAPI core contrib falcon handlers module"""
from json import dumps
from falcon.constants import MEDIA_JSON
from falcon.status_codes import (
HTTP_400, HTTP_404, HTTP_405, HTTP_415,
)
from openapi_core.schema.media_types.exceptions import InvalidContentType
from openapi_core.templating.paths.exceptions import (
ServerNotFound, OperationNotFound, PathNotFound,
)
class FalconOpenAPIErrorsHandler(object):
OPENAPI_ERROR_STATUS = {
ServerNotFound: 400,
OperationNotFound: 405,
PathNotFound: 404,
InvalidContentType: 415,
}
FALCON_STATUS_CODES = {
400: HTTP_400,
404: HTTP_404,
405: HTTP_405,
415: HTTP_415,
}
@classmethod
def handle(cls, req, resp, errors):
data_errors = [
cls.format_openapi_error(err)
for err in errors
]
data = {
'errors': data_errors,
}
data_error_max = max(data_errors, key=lambda x: x['status'])
resp.content_type = MEDIA_JSON
resp.status = cls.FALCON_STATUS_CODES.get(
data_error_max['status'], HTTP_400)
resp.body = dumps(data)
resp.complete = True
@classmethod
def format_openapi_error(cls, error):
return {
'title': str(error),
'status': cls.OPENAPI_ERROR_STATUS.get(error.__class__, 400),
'class': str(type(error)),
}

View file

@ -0,0 +1,73 @@
"""OpenAPI core contrib falcon middlewares module"""
from openapi_core.contrib.falcon.handlers import FalconOpenAPIErrorsHandler
from openapi_core.contrib.falcon.requests import FalconOpenAPIRequestFactory
from openapi_core.contrib.falcon.responses import FalconOpenAPIResponseFactory
from openapi_core.validation.processors import OpenAPIProcessor
from openapi_core.validation.request.validators import RequestValidator
from openapi_core.validation.response.validators import ResponseValidator
class FalconOpenAPIMiddleware(OpenAPIProcessor):
def __init__(
self,
request_validator,
response_validator,
request_factory,
response_factory,
openapi_errors_handler,
):
super(FalconOpenAPIMiddleware, self).__init__(
request_validator, response_validator)
self.request_factory = request_factory
self.response_factory = response_factory
self.openapi_errors_handler = openapi_errors_handler
def process_request(self, req, resp):
openapi_req = self._get_openapi_request(req)
req_result = super(FalconOpenAPIMiddleware, self).process_request(
openapi_req)
if req_result.errors:
return self._handle_request_errors(req, resp, req_result)
req.openapi = req_result
def process_response(self, req, resp, resource, req_succeeded):
openapi_req = self._get_openapi_request(req)
openapi_resp = self._get_openapi_response(resp)
resp_result = super(FalconOpenAPIMiddleware, self).process_response(
openapi_req, openapi_resp)
if resp_result.errors:
return self._handle_response_errors(req, resp, resp_result)
def _handle_request_errors(self, req, resp, request_result):
return self.openapi_errors_handler.handle(
req, resp, request_result.errors)
def _handle_response_errors(self, req, resp, response_result):
return self.openapi_errors_handler.handle(
req, resp, response_result.errors)
def _get_openapi_request(self, request):
return self.request_factory.create(request)
def _get_openapi_response(self, response):
return self.response_factory.create(response)
@classmethod
def from_spec(
cls,
spec,
request_factory=FalconOpenAPIRequestFactory,
response_factory=FalconOpenAPIResponseFactory,
openapi_errors_handler=FalconOpenAPIErrorsHandler,
):
request_validator = RequestValidator(spec)
response_validator = ResponseValidator(spec)
return cls(
request_validator=request_validator,
response_validator=response_validator,
request_factory=request_factory,
response_factory=response_factory,
openapi_errors_handler=openapi_errors_handler,
)

View file

@ -1,36 +1,45 @@
"""OpenAPI core contrib falcon responses module"""
import json
from json import dumps
from openapi_core.validation.request.datatypes import OpenAPIRequest, RequestParameters
from werkzeug.datastructures import ImmutableMultiDict
from openapi_core.validation.request.datatypes import (
OpenAPIRequest, RequestParameters,
)
class FalconOpenAPIRequestFactory:
@classmethod
def create(cls, req, route_params):
def create(cls, request):
"""
Create OpenAPIRequest from falcon Request and route params.
"""
method = req.method.lower()
method = request.method.lower()
# Convert keys to lowercase as that's what the OpenAPIRequest expects.
headers = {key.lower(): value for key, value in req.headers.items()}
# gets deduced by path finder against spec
path = {}
# Support falcon-jsonify.
body = (
dumps(request.json) if getattr(request, "json", None)
else request.bounded_stream.read()
)
mimetype = request.options.default_media_type
if request.content_type:
mimetype = request.content_type.partition(";")[0]
query = ImmutableMultiDict(request.params.items())
parameters = RequestParameters(
path=route_params,
query=ImmutableMultiDict(req.params.items()),
header=headers,
cookie=req.cookies,
query=query,
header=request.headers,
cookie=request.cookies,
path=path,
)
return OpenAPIRequest(
host_url=None,
path=req.path,
path_pattern=req.uri_template or req.path,
full_url_pattern=request.url,
method=method,
parameters=parameters,
# Support falcon-jsonify.
body=json.dumps(req.json)
if getattr(req, "json", None)
else req.bounded_stream.read(),
mimetype=req.content_type.partition(";")[0] if req.content_type else "",
body=body,
mimetype=mimetype,
)

View file

@ -4,9 +4,17 @@ from openapi_core.validation.response.datatypes import OpenAPIResponse
class FalconOpenAPIResponseFactory(object):
@classmethod
def create(cls, resp):
def create(cls, response):
status_code = int(response.status[:3])
mimetype = ''
if response.content_type:
mimetype = response.content_type.partition(";")[0]
else:
mimetype = response.options.default_media_type
return OpenAPIResponse(
data=resp.body,
status_code=resp.status[:3],
mimetype=resp.content_type.partition(";")[0] if resp.content_type else '',
data=response.body,
status_code=status_code,
mimetype=mimetype,
)

View file

View file

@ -1,8 +1,8 @@
from falcon import Request, Response
from falcon import Request, Response, RequestOptions, ResponseOptions
from falcon.routing import DefaultRouter
from falcon.status_codes import HTTP_200
from falcon.testing import create_environ
import pytest
from six import BytesIO
@pytest.fixture
@ -18,7 +18,7 @@ def environ_factory():
@pytest.fixture
def router():
router = DefaultRouter()
router.add_route('/browse/<int:id>/', None)
router.add_route("/browse/{id:int}/", lambda x: x)
return router
@ -26,12 +26,14 @@ def router():
def request_factory(environ_factory, router):
server_name = 'localhost'
def create_request(method, path, subdomain=None, query_string=None):
def create_request(
method, path, subdomain=None, query_string=None,
content_type='application/json'):
environ = environ_factory(method, path, server_name)
options = None
options = RequestOptions()
# return create_req(options=options, **environ)
req = Request(environ, options)
req.uri_template = router.find(path, req)
resource, method_map, params, req.uri_template = router.find(path, req)
return req
return create_request
@ -40,10 +42,10 @@ def request_factory(environ_factory, router):
def response_factory(environ_factory):
def create_response(
data, status_code=200, content_type='application/json'):
options = {
'content_type': content_type,
'data': data,
'status': status_code,
}
return Response(options)
options = ResponseOptions()
resp = Response(options)
resp.body = data
resp.content_type = content_type
resp.status = HTTP_200
return resp
return create_response

View file

@ -1,11 +1,11 @@
openapi: "3.0.0"
info:
title: Basic OpenAPI specification used with test_flask.TestFlaskOpenAPIIValidation
title: Basic OpenAPI specification used with test_falcon.TestFalconOpenAPIIValidation
version: "0.1"
servers:
- url: 'http://localhost'
paths:
'/browse/{id}/':
'/browse/{id}':
parameters:
- name: id
in: path

View file

@ -0,0 +1,204 @@
from json import dumps
from falcon import API
from falcon.testing import TestClient
import pytest
from openapi_core.contrib.falcon.middlewares import FalconOpenAPIMiddleware
from openapi_core.shortcuts import create_spec
from openapi_core.validation.request.datatypes import RequestParameters
class TestFalconOpenAPIMiddleware(object):
view_response_callable = None
@pytest.fixture
def spec(self, factory):
specfile = 'contrib/falcon/data/v3.0/falcon_factory.yaml'
return create_spec(factory.spec_from_file(specfile))
@pytest.fixture
def middleware(self, spec):
return FalconOpenAPIMiddleware.from_spec(spec)
@pytest.fixture
def app(self, middleware):
return API(middleware=[middleware])
@pytest.yield_fixture
def client(self, app):
return TestClient(app)
@pytest.fixture
def view_response(self):
def view_response(*args, **kwargs):
return self.view_response_callable(*args, **kwargs)
return view_response
@pytest.fixture(autouse=True)
def details_view(self, app, view_response):
class BrowseDetailResource(object):
def on_get(self, *args, **kwargs):
return view_response(*args, **kwargs)
resource = BrowseDetailResource()
app.add_route("/browse/{id}", resource)
return resource
@pytest.fixture(autouse=True)
def list_view(self, app, view_response):
class BrowseListResource(object):
def on_get(self, *args, **kwargs):
return view_response(*args, **kwargs)
resource = BrowseListResource()
app.add_route("/browse", resource)
return resource
def test_invalid_content_type(self, client):
def view_response_callable(request, response, id):
from falcon.constants import MEDIA_HTML
from falcon.status_codes import HTTP_200
assert request.openapi
assert not request.openapi.errors
assert request.openapi.parameters == RequestParameters(path={
'id': 12,
})
response.content_type = MEDIA_HTML
response.status = HTTP_200
response.body = 'success'
self.view_response_callable = view_response_callable
headers = {'Content-Type': 'application/json'}
result = client.simulate_get(
'/browse/12', host='localhost', headers=headers)
assert result.json == {
'errors': [
{
'class': (
"<class 'openapi_core.schema.media_types.exceptions."
"InvalidContentType'>"
),
'status': 415,
'title': (
'Content for following mimetype not found: text/html'
)
}
]
}
def test_server_error(self, client):
headers = {'Content-Type': 'application/json'}
result = client.simulate_get(
'/browse/12', host='localhost', headers=headers, protocol='https')
expected_data = {
'errors': [
{
'class': (
"<class 'openapi_core.templating.paths.exceptions."
"ServerNotFound'>"
),
'status': 400,
'title': (
'Server not found for '
'https://localhost/browse/12'
),
}
]
}
assert result.status_code == 400
assert result.json == expected_data
def test_operation_error(self, client):
headers = {'Content-Type': 'application/json'}
result = client.simulate_post(
'/browse/12', host='localhost', headers=headers)
expected_data = {
'errors': [
{
'class': (
"<class 'openapi_core.templating.paths.exceptions."
"OperationNotFound'>"
),
'status': 405,
'title': (
'Operation post not found for '
'http://localhost/browse/12'
),
}
]
}
assert result.status_code == 405
assert result.json == expected_data
def test_path_error(self, client):
headers = {'Content-Type': 'application/json'}
result = client.simulate_get(
'/browse', host='localhost', headers=headers)
expected_data = {
'errors': [
{
'class': (
"<class 'openapi_core.templating.paths.exceptions."
"PathNotFound'>"
),
'status': 404,
'title': (
'Path not found for '
'http://localhost/browse'
),
}
]
}
assert result.status_code == 404
assert result.json == expected_data
def test_endpoint_error(self, client):
headers = {'Content-Type': 'application/json'}
result = client.simulate_get(
'/browse/invalidparameter', host='localhost', headers=headers)
expected_data = {
'errors': [
{
'class': (
"<class 'openapi_core.casting.schemas.exceptions."
"CastError'>"
),
'status': 400,
'title': (
"Failed to cast value invalidparameter to type integer"
)
}
]
}
assert result.json == expected_data
def test_valid(self, client):
def view_response_callable(request, response, id):
from falcon.constants import MEDIA_JSON
from falcon.status_codes import HTTP_200
assert request.openapi
assert not request.openapi.errors
assert request.openapi.parameters == RequestParameters(path={
'id': 12,
})
response.status = HTTP_200
response.content_type = MEDIA_JSON
response.body = dumps({
'data': 'data',
})
self.view_response_callable = view_response_callable
headers = {'Content-Type': 'application/json'}
result = client.simulate_get(
'/browse/12', host='localhost', headers=headers)
assert result.status_code == 200
assert result.json == {
'data': 'data',
}

View file

@ -19,9 +19,8 @@ class TestFalconOpenAPIValidation(object):
request_factory,
response_factory):
validator = ResponseValidator(spec)
request = request_factory('GET', '/browse/12/', subdomain='kb')
openapi_request = FalconOpenAPIRequestFactory.create(
request, '/browse/12/')
request = request_factory('GET', '/browse/12', subdomain='kb')
openapi_request = FalconOpenAPIRequestFactory.create(request)
response = response_factory('{"data": "data"}', status_code=200)
openapi_response = FalconOpenAPIResponseFactory.create(response)
result = validator.validate(openapi_request, openapi_response)
@ -29,8 +28,7 @@ class TestFalconOpenAPIValidation(object):
def test_request_validator_path_pattern(self, spec, request_factory):
validator = RequestValidator(spec)
request = request_factory('GET', '/browse/12/', subdomain='kb')
openapi_request = FalconOpenAPIRequestFactory.create(
request, '/browse/12/')
request = request_factory('GET', '/browse/12', subdomain='kb')
openapi_request = FalconOpenAPIRequestFactory.create(request)
result = validator.validate(openapi_request)
assert not result.errors