diff --git a/docs/integrations.rst b/docs/integrations.rst index d4793f8b..fbbcaf1c 100644 --- a/docs/integrations.rst +++ b/docs/integrations.rst @@ -39,6 +39,7 @@ Falcon ------ This section describes integration with `Falcon `__ web framework. +The integration supports Falcon from version 3.0 and above. Middleware ~~~~~~~~~~ @@ -50,7 +51,7 @@ Falcon API can be integrated by `FalconOpenAPIMiddleware` middleware. from openapi_core.contrib.falcon.middlewares import FalconOpenAPIMiddleware openapi_middleware = FalconOpenAPIMiddleware.from_spec(spec) - api = falcon.API(middleware=[openapi_middleware]) + app = falcon.App(middleware=[openapi_middleware]) Low level ~~~~~~~~~ @@ -62,7 +63,7 @@ For Falcon you can use FalconOpenAPIRequest a Falcon request factory: from openapi_core.validation.request.validators import RequestValidator from openapi_core.contrib.falcon import FalconOpenAPIRequestFactory - openapi_request = FalconOpenAPIRequestFactory.create(falcon_request) + openapi_request = FalconOpenAPIRequestFactory().create(falcon_request) validator = RequestValidator(spec) result = validator.validate(openapi_request) @@ -73,7 +74,7 @@ You can use FalconOpenAPIResponse as a Falcon response factory: from openapi_core.validation.response.validators import ResponseValidator from openapi_core.contrib.falcon import FalconOpenAPIResponseFactory - openapi_response = FalconOpenAPIResponseFactory.create(falcon_response) + openapi_response = FalconOpenAPIResponseFactory().create(falcon_response) validator = ResponseValidator(spec) result = validator.validate(openapi_request, openapi_response) diff --git a/openapi_core/contrib/falcon/compat.py b/openapi_core/contrib/falcon/compat.py deleted file mode 100644 index 4e60e86c..00000000 --- a/openapi_core/contrib/falcon/compat.py +++ /dev/null @@ -1,24 +0,0 @@ -"""OpenAPI core contrib falcon compat module""" -try: - from falcon import App # noqa: F401 - HAS_FALCON3 = True -except ImportError: - HAS_FALCON3 = False - - -def get_request_media(req, default=None): - # in falcon 3 media is deprecated - return req.get_media(default_when_empty=default) if HAS_FALCON3 else \ - (req.media if req.media else default) - - -def get_response_text(resp): - # in falcon 3 body is deprecated - return getattr(resp, 'text') if HAS_FALCON3 else \ - getattr(resp, 'body') - - -def set_response_text(resp, text): - # in falcon 3 body is deprecated - setattr(resp, 'text', text) if HAS_FALCON3 else \ - setattr(resp, 'body', text) diff --git a/openapi_core/contrib/falcon/handlers.py b/openapi_core/contrib/falcon/handlers.py index 671cb7a7..5b48404d 100644 --- a/openapi_core/contrib/falcon/handlers.py +++ b/openapi_core/contrib/falcon/handlers.py @@ -6,7 +6,6 @@ HTTP_400, HTTP_404, HTTP_405, HTTP_415, ) -from openapi_core.contrib.falcon.compat import set_response_text from openapi_core.templating.media_types.exceptions import MediaTypeNotFound from openapi_core.templating.paths.exceptions import ( ServerNotFound, OperationNotFound, PathNotFound, @@ -43,7 +42,7 @@ def handle(cls, req, resp, errors): resp.content_type = MEDIA_JSON resp.status = cls.FALCON_STATUS_CODES.get( data_error_max['status'], HTTP_400) - set_response_text(resp, data_str) + resp.text = data_str resp.complete = True @classmethod diff --git a/openapi_core/contrib/falcon/middlewares.py b/openapi_core/contrib/falcon/middlewares.py index e809cc7c..2e151975 100644 --- a/openapi_core/contrib/falcon/middlewares.py +++ b/openapi_core/contrib/falcon/middlewares.py @@ -8,24 +8,46 @@ from openapi_core.validation.response.validators import ResponseValidator -class FalconOpenAPIMiddleware(OpenAPIProcessor): +class FalconOpenAPIMiddleware: + + request_factory = FalconOpenAPIRequestFactory() + response_factory = FalconOpenAPIResponseFactory() + errors_handler = FalconOpenAPIErrorsHandler() def __init__( - self, - request_validator, - response_validator, - request_factory, - response_factory, - openapi_errors_handler, + self, + validation_processor, + request_factory=None, + response_factory=None, + errors_handler=None, + ): + self.validation_processor = validation_processor + self.request_factory = request_factory or self.request_factory + self.response_factory = response_factory or self.response_factory + self.errors_handler = errors_handler or self.errors_handler + + @classmethod + def from_spec( + cls, + spec, + request_factory=None, + response_factory=None, + errors_handler=None, ): - super().__init__(request_validator, response_validator) - self.request_factory = request_factory - self.response_factory = response_factory - self.openapi_errors_handler = openapi_errors_handler + request_validator = RequestValidator(spec) + response_validator = ResponseValidator(spec) + validation_processor = OpenAPIProcessor( + request_validator, response_validator) + return cls( + validation_processor, + request_factory=request_factory, + response_factory=response_factory, + errors_handler=errors_handler, + ) def process_request(self, req, resp): openapi_req = self._get_openapi_request(req) - req_result = super().process_request(openapi_req) + req_result = self.validation_processor.process_request(openapi_req) if req_result.errors: return self._handle_request_errors(req, resp, req_result) req.openapi = req_result @@ -33,16 +55,17 @@ def process_request(self, req, resp): 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().process_response(openapi_req, openapi_resp) + resp_result = self.validation_processor.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( + return self.errors_handler.handle( req, resp, request_result.errors) def _handle_response_errors(self, req, resp, response_result): - return self.openapi_errors_handler.handle( + return self.errors_handler.handle( req, resp, response_result.errors) def _get_openapi_request(self, request): @@ -50,21 +73,3 @@ def _get_openapi_request(self, 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, - ) diff --git a/openapi_core/contrib/falcon/requests.py b/openapi_core/contrib/falcon/requests.py index 9f6b5292..49fe2fe9 100644 --- a/openapi_core/contrib/falcon/requests.py +++ b/openapi_core/contrib/falcon/requests.py @@ -3,7 +3,6 @@ from werkzeug.datastructures import ImmutableMultiDict, Headers -from openapi_core.contrib.falcon.compat import get_request_media from openapi_core.validation.request.datatypes import ( OpenAPIRequest, RequestParameters, ) @@ -11,15 +10,18 @@ class FalconOpenAPIRequestFactory: - @classmethod - def create(cls, request, default_when_empty={}): + def __init__(self, default_when_empty=None): + if default_when_empty is None: + default_when_empty = {} + self.default_when_empty = default_when_empty + + def create(self, request): """ Create OpenAPIRequest from falcon Request and route params. """ - default = default_when_empty method = request.method.lower() - media = get_request_media(request, default=default) + media = request.get_media(default_when_empty=self.default_when_empty) # Support falcon-jsonify. body = ( dumps(getattr(request, "json", media)) diff --git a/openapi_core/contrib/falcon/responses.py b/openapi_core/contrib/falcon/responses.py index f99da684..9e90af43 100644 --- a/openapi_core/contrib/falcon/responses.py +++ b/openapi_core/contrib/falcon/responses.py @@ -1,7 +1,6 @@ """OpenAPI core contrib falcon responses module""" from werkzeug.datastructures import Headers -from openapi_core.contrib.falcon.compat import get_response_text from openapi_core.validation.response.datatypes import OpenAPIResponse @@ -16,7 +15,7 @@ def create(cls, response): else: mimetype = response.options.default_media_type - data = get_response_text(response) + data = response.text headers = Headers(response.headers) return OpenAPIResponse( diff --git a/requirements_dev.txt b/requirements_dev.txt index fb38e977..7bebefda 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,7 +1,7 @@ pytest==5.4.3 pytest-flake8 pytest-cov==2.5.1 -falcon==3.0.0 +falcon==3.0.1 flask django==2.2.20 djangorestframework==3.11.2 diff --git a/setup.cfg b/setup.cfg index 7de244ae..1d775e51 100644 --- a/setup.cfg +++ b/setup.cfg @@ -37,7 +37,7 @@ tests_require = pytest>=5.0.0 pytest-flake8 pytest-cov - falcon + falcon>=3.0 flask responses webob @@ -48,6 +48,7 @@ exclude = [options.extras_require] django = django>=2.2 +falcon = falcon>=3.0 flask = flask requests = requests diff --git a/tests/integration/contrib/falcon/conftest.py b/tests/integration/contrib/falcon/conftest.py index 66d209de..2d8c255c 100644 --- a/tests/integration/contrib/falcon/conftest.py +++ b/tests/integration/contrib/falcon/conftest.py @@ -1,7 +1,10 @@ +import os +import sys + 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 +from falcon.testing import create_environ, TestClient import pytest @@ -50,3 +53,23 @@ def create_response( resp.set_headers(headers or {}) return resp return create_response + + +@pytest.fixture(autouse=True, scope='module') +def falcon_setup(): + directory = os.path.abspath(os.path.dirname(__file__)) + falcon_project_dir = os.path.join(directory, 'data/v3.0') + sys.path.insert(0, falcon_project_dir) + yield + sys.path.remove(falcon_project_dir) + + +@pytest.fixture +def app(): + from falconproject.__main__ import app + return app + + +@pytest.fixture +def client(app): + return TestClient(app) diff --git a/tests/integration/contrib/falcon/data/v3.0/falconproject/__init__.py b/tests/integration/contrib/falcon/data/v3.0/falconproject/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/integration/contrib/falcon/data/v3.0/falconproject/__main__.py b/tests/integration/contrib/falcon/data/v3.0/falconproject/__main__.py new file mode 100644 index 00000000..fc9d5e75 --- /dev/null +++ b/tests/integration/contrib/falcon/data/v3.0/falconproject/__main__.py @@ -0,0 +1,12 @@ +from falcon import App + +from falconproject.openapi import openapi_middleware +from falconproject.resources import PetListResource, PetDetailResource + +app = App(middleware=[openapi_middleware]) + +pet_list_resource = PetListResource() +pet_detail_resource = PetDetailResource() + +app.add_route("/v1/pets", pet_list_resource) +app.add_route("/v1/pets/{petId}", pet_detail_resource) diff --git a/tests/integration/contrib/falcon/data/v3.0/falconproject/openapi.py b/tests/integration/contrib/falcon/data/v3.0/falconproject/openapi.py new file mode 100644 index 00000000..eefb3a65 --- /dev/null +++ b/tests/integration/contrib/falcon/data/v3.0/falconproject/openapi.py @@ -0,0 +1,11 @@ +from pathlib import Path + +from openapi_core import create_spec +from openapi_core.contrib.falcon.middlewares import FalconOpenAPIMiddleware +import yaml + +openapi_spec_path = Path("tests/integration/data/v3.0/petstore.yaml") +spec_yaml = openapi_spec_path.read_text() +spec_dict = yaml.load(spec_yaml) +spec = create_spec(spec_dict) +openapi_middleware = FalconOpenAPIMiddleware.from_spec(spec) diff --git a/tests/integration/contrib/falcon/data/v3.0/falconproject/resources.py b/tests/integration/contrib/falcon/data/v3.0/falconproject/resources.py new file mode 100644 index 00000000..cca48515 --- /dev/null +++ b/tests/integration/contrib/falcon/data/v3.0/falconproject/resources.py @@ -0,0 +1,49 @@ +from json import dumps + +from falcon.constants import MEDIA_JSON +from falcon.status_codes import HTTP_200 + + +class PetListResource: + def on_get(self, request, response): + assert request.openapi + assert not request.openapi.errors + assert request.openapi.parameters.query == { + 'page': 1, + 'limit': 12, + 'search': '', + } + data = [ + { + 'id': 12, + 'name': 'Cat', + 'ears': { + 'healthy': True, + }, + }, + ] + response.status = HTTP_200 + response.content_type = MEDIA_JSON + response.text = dumps({"data": data}) + response.set_header('X-Rate-Limit', '12') + + +class PetDetailResource: + def on_get(self, request, response, petId=None): + assert petId == '12' + assert request.openapi + assert not request.openapi.errors + assert request.openapi.parameters.path == { + 'petId': 12, + } + data = { + 'id': 12, + 'name': 'Cat', + 'ears': { + 'healthy': True, + }, + } + response.status = HTTP_200 + response.content_type = MEDIA_JSON + response.text = dumps({"data": data}) + response.set_header('X-Rate-Limit', '12') diff --git a/tests/integration/contrib/falcon/data/v3.0/falcon_factory.yaml b/tests/integration/contrib/falcon/data/v3.0/openapi.yaml similarity index 99% rename from tests/integration/contrib/falcon/data/v3.0/falcon_factory.yaml rename to tests/integration/contrib/falcon/data/v3.0/openapi.yaml index 295f3670..7646f8fc 100644 --- a/tests/integration/contrib/falcon/data/v3.0/falcon_factory.yaml +++ b/tests/integration/contrib/falcon/data/v3.0/openapi.yaml @@ -21,7 +21,7 @@ paths: type: integer get: responses: - 200: + '200': description: Return the resource. content: application/json: diff --git a/tests/integration/contrib/falcon/test_falcon.py b/tests/integration/contrib/falcon/test_falcon.py new file mode 100644 index 00000000..1706ec07 --- /dev/null +++ b/tests/integration/contrib/falcon/test_falcon.py @@ -0,0 +1,67 @@ +class TestPetListResource: + + def test_no_required_param(self, client): + headers = { + 'Content-Type': 'application/json', + } + + response = client.simulate_get( + '/v1/pets', host='petstore.swagger.io', headers=headers) + + assert response.status_code == 400 + + def test_valid(self, client): + headers = { + 'Content-Type': 'application/json', + } + query_string = "limit=12" + + response = client.simulate_get( + '/v1/pets', + host='petstore.swagger.io', headers=headers, + query_string=query_string, + ) + + assert response.status_code == 200 + assert response.json == { + 'data': [ + { + 'id': 12, + 'name': 'Cat', + 'ears': { + 'healthy': True, + }, + }, + ], + } + + +class TestPetDetailResource: + + def test_invalid_path(self, client): + headers = {'Content-Type': 'application/json'} + + response = client.simulate_get( + '/v1/pet/invalid', host='petstore.swagger.io', headers=headers) + + assert response.status_code == 404 + + def test_invalid_security(self, client): + headers = {'Content-Type': 'application/json'} + + response = client.simulate_get( + '/v1/pets/12', host='petstore.swagger.io', headers=headers) + + assert response.status_code == 400 + + def test_valid(self, client): + auth = 'authuser' + headers = { + 'Authorization': 'Basic {auth}'.format(auth=auth), + 'Content-Type': 'application/json', + } + + response = client.simulate_get( + '/v1/pets/12', host='petstore.swagger.io', headers=headers) + + assert response.status_code == 200 diff --git a/tests/integration/contrib/falcon/test_falcon_middlewares.py b/tests/integration/contrib/falcon/test_falcon_middlewares.py index 354b7eea..f49d792b 100644 --- a/tests/integration/contrib/falcon/test_falcon_middlewares.py +++ b/tests/integration/contrib/falcon/test_falcon_middlewares.py @@ -1,6 +1,6 @@ from json import dumps -from falcon import API as App +from falcon import App from falcon.testing import TestClient import pytest @@ -15,7 +15,7 @@ class TestFalconOpenAPIMiddleware: @pytest.fixture def spec(self, factory): - specfile = 'contrib/falcon/data/v3.0/falcon_factory.yaml' + specfile = 'contrib/falcon/data/v3.0/openapi.yaml' return create_spec(factory.spec_from_file(specfile)) @pytest.fixture diff --git a/tests/integration/contrib/falcon/test_falcon_validation.py b/tests/integration/contrib/falcon/test_falcon_validation.py index 5021497c..f65b690a 100644 --- a/tests/integration/contrib/falcon/test_falcon_validation.py +++ b/tests/integration/contrib/falcon/test_falcon_validation.py @@ -11,7 +11,7 @@ class TestFalconOpenAPIValidation: @pytest.fixture def spec(self, factory): - specfile = 'contrib/falcon/data/v3.0/falcon_factory.yaml' + specfile = 'contrib/falcon/data/v3.0/openapi.yaml' return create_spec(factory.spec_from_file(specfile)) def test_response_validator_path_pattern(self, @@ -20,19 +20,19 @@ def test_response_validator_path_pattern(self, response_factory): validator = ResponseValidator(spec) request = request_factory('GET', '/browse/12', subdomain='kb') - openapi_request = FalconOpenAPIRequestFactory.create(request) + openapi_request = FalconOpenAPIRequestFactory().create(request) response = response_factory( '{"data": "data"}', status_code=200, headers={'X-Rate-Limit': '12'}, ) - openapi_response = FalconOpenAPIResponseFactory.create(response) + openapi_response = FalconOpenAPIResponseFactory().create(response) result = validator.validate(openapi_request, openapi_response) assert not result.errors 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) + openapi_request = FalconOpenAPIRequestFactory().create(request) result = validator.validate(openapi_request) assert not result.errors @@ -41,6 +41,6 @@ def test_request_validator_with_query(self, spec, request_factory): request = request_factory('GET', '/browse/12', query_string='detail_level=2', subdomain='kb') - openapi_request = FalconOpenAPIRequestFactory.create(request) + openapi_request = FalconOpenAPIRequestFactory().create(request) result = validator.validate(openapi_request) assert not result.errors