diff --git a/openapi_core/contrib/falcon/handlers.py b/openapi_core/contrib/falcon/handlers.py index 5b48404d..2711f304 100644 --- a/openapi_core/contrib/falcon/handlers.py +++ b/openapi_core/contrib/falcon/handlers.py @@ -1,33 +1,28 @@ """OpenAPI core contrib falcon handlers module""" from json import dumps +from falcon import status_codes from falcon.constants import MEDIA_JSON -from falcon.status_codes import ( - HTTP_400, HTTP_404, HTTP_405, HTTP_415, -) +from openapi_core.exceptions import MissingRequiredParameter from openapi_core.templating.media_types.exceptions import MediaTypeNotFound from openapi_core.templating.paths.exceptions import ( ServerNotFound, OperationNotFound, PathNotFound, ) +from openapi_core.validation.exceptions import InvalidSecurity class FalconOpenAPIErrorsHandler: OPENAPI_ERROR_STATUS = { + MissingRequiredParameter: 400, ServerNotFound: 400, + InvalidSecurity: 403, OperationNotFound: 405, PathNotFound: 404, MediaTypeNotFound: 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 = [ @@ -40,8 +35,10 @@ def handle(cls, req, resp, errors): data_str = dumps(data) data_error_max = max(data_errors, key=cls.get_error_status) resp.content_type = MEDIA_JSON - resp.status = cls.FALCON_STATUS_CODES.get( - data_error_max['status'], HTTP_400) + resp.status = getattr( + status_codes, f"HTTP_{data_error_max['status']}", + status_codes.HTTP_400, + ) resp.text = data_str resp.complete = True diff --git a/tests/integration/contrib/falcon/data/v3.0/falconproject/__main__.py b/tests/integration/contrib/falcon/data/v3.0/falconproject/__main__.py index fc9d5e75..c44eea23 100644 --- a/tests/integration/contrib/falcon/data/v3.0/falconproject/__main__.py +++ b/tests/integration/contrib/falcon/data/v3.0/falconproject/__main__.py @@ -1,7 +1,7 @@ from falcon import App from falconproject.openapi import openapi_middleware -from falconproject.resources import PetListResource, PetDetailResource +from falconproject.pets.resources import PetListResource, PetDetailResource app = App(middleware=[openapi_middleware]) diff --git a/tests/integration/contrib/falcon/data/v3.0/falconproject/openapi.py b/tests/integration/contrib/falcon/data/v3.0/falconproject/openapi.py index eefb3a65..0a0691f3 100644 --- a/tests/integration/contrib/falcon/data/v3.0/falconproject/openapi.py +++ b/tests/integration/contrib/falcon/data/v3.0/falconproject/openapi.py @@ -5,7 +5,6 @@ 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_dict = yaml.load(openapi_spec_path.read_text(), yaml.Loader) spec = create_spec(spec_dict) openapi_middleware = FalconOpenAPIMiddleware.from_spec(spec) diff --git a/tests/integration/contrib/falcon/data/v3.0/falconproject/pets/__init__.py b/tests/integration/contrib/falcon/data/v3.0/falconproject/pets/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/integration/contrib/falcon/data/v3.0/falconproject/resources.py b/tests/integration/contrib/falcon/data/v3.0/falconproject/pets/resources.py similarity index 65% rename from tests/integration/contrib/falcon/data/v3.0/falconproject/resources.py rename to tests/integration/contrib/falcon/data/v3.0/falconproject/pets/resources.py index cca48515..b0e63786 100644 --- a/tests/integration/contrib/falcon/data/v3.0/falconproject/resources.py +++ b/tests/integration/contrib/falcon/data/v3.0/falconproject/pets/resources.py @@ -1,7 +1,7 @@ from json import dumps from falcon.constants import MEDIA_JSON -from falcon.status_codes import HTTP_200 +from falcon.status_codes import HTTP_200, HTTP_201 class PetListResource: @@ -27,6 +27,23 @@ def on_get(self, request, response): response.text = dumps({"data": data}) response.set_header('X-Rate-Limit', '12') + def on_post(self, request, response): + assert request.openapi + assert not request.openapi.errors + assert request.openapi.parameters.cookie == { + 'user': 1, + } + assert request.openapi.parameters.header == { + 'api-key': '12345', + } + assert request.openapi.body.__class__.__name__ == 'PetCreate' + assert request.openapi.body.name == 'Cat' + assert request.openapi.body.ears.__class__.__name__ == 'Ears' + assert request.openapi.body.ears.healthy is True + + response.status = HTTP_201 + response.set_header('X-Rate-Limit', '12') + class PetDetailResource: def on_get(self, request, response, petId=None): diff --git a/tests/integration/contrib/falcon/data/v3.0/openapi.yaml b/tests/integration/contrib/falcon/data/v3.0/openapi.yaml deleted file mode 100644 index 7646f8fc..00000000 --- a/tests/integration/contrib/falcon/data/v3.0/openapi.yaml +++ /dev/null @@ -1,60 +0,0 @@ -openapi: "3.0.0" -info: - title: Basic OpenAPI specification used with test_falcon.TestFalconOpenAPIIValidation - version: "0.1" -servers: - - url: 'http://localhost' -paths: - '/browse/{id}': - parameters: - - name: id - in: path - required: true - description: the ID of the resource to retrieve - schema: - type: integer - - name: detail_level - in: query - required: false - description: optional level of detail to provide - schema: - type: integer - get: - responses: - '200': - description: Return the resource. - content: - application/json: - schema: - type: object - required: - - data - properties: - data: - type: string - headers: - X-Rate-Limit: - description: Rate limit - schema: - type: integer - required: true - default: - description: Return errors. - content: - application/json: - schema: - type: object - required: - - errors - properties: - errors: - type: array - items: - type: object - properties: - title: - type: string - code: - type: string - message: - type: string diff --git a/tests/integration/contrib/falcon/test_falcon.py b/tests/integration/contrib/falcon/test_falcon.py deleted file mode 100644 index ca030976..00000000 --- a/tests/integration/contrib/falcon/test_falcon.py +++ /dev/null @@ -1,67 +0,0 @@ -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': f'Basic {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 deleted file mode 100644 index f49d792b..00000000 --- a/tests/integration/contrib/falcon/test_falcon_middlewares.py +++ /dev/null @@ -1,208 +0,0 @@ -from json import dumps - -from falcon import App -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 Parameters - - -class TestFalconOpenAPIMiddleware: - - view_response_callable = None - - @pytest.fixture - def spec(self, factory): - specfile = 'contrib/falcon/data/v3.0/openapi.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 App(middleware=[middleware]) - - @pytest.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: - 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: - 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 == Parameters(path={ - 'id': 12, - }) - response.content_type = MEDIA_HTML - response.status = HTTP_200 - response.body = 'success' - response.set_header('X-Rate-Limit', '12') - 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': ( - "" - ), - 'status': 415, - 'title': ( - "Content for the following mimetype not found: " - "text/html. Valid mimetypes: ['application/json']" - ) - } - ] - } - - 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': ( - "" - ), - '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': ( - "" - ), - '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': ( - "" - ), - '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': ( - "" - ), - 'status': 400, - 'title': ( - "Failed to cast value to integer type: " - "invalidparameter" - ) - } - ] - } - 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 == Parameters(path={ - 'id': 12, - }) - response.status = HTTP_200 - response.content_type = MEDIA_JSON - response.body = dumps({ - 'data': 'data', - }) - response.set_header('X-Rate-Limit', '12') - 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', - } diff --git a/tests/integration/contrib/falcon/test_falcon_project.py b/tests/integration/contrib/falcon/test_falcon_project.py new file mode 100644 index 00000000..20aa9659 --- /dev/null +++ b/tests/integration/contrib/falcon/test_falcon_project.py @@ -0,0 +1,322 @@ +from base64 import b64encode +from json import dumps + +from falcon.constants import MEDIA_URLENCODED + + +class BaseTestFalconProject: + + api_key = '12345' + + @property + def api_key_encoded(self): + api_key_bytes = self.api_key.encode('utf8') + api_key_bytes_enc = b64encode(api_key_bytes) + return str(api_key_bytes_enc, 'utf8') + + +class TestPetListResource(BaseTestFalconProject): + + def test_get_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_get_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, + }, + }, + ], + } + + def test_post_server_invalid(self, client): + response = client.simulate_post( + '/v1/pets', + host='petstore.swagger.io', + ) + + expected_data = { + 'errors': [ + { + 'class': ( + "" + ), + 'status': 400, + 'title': ( + 'Server not found for ' + 'http://petstore.swagger.io/v1/pets' + ), + } + ] + } + assert response.status_code == 400 + assert response.json == expected_data + + def test_post_required_header_param_missing(self, client): + cookies = {'user': 1} + pet_name = 'Cat' + pet_tag = 'cats' + pet_street = 'Piekna' + pet_city = 'Warsaw' + pet_healthy = False + data_json = { + 'name': pet_name, + 'tag': pet_tag, + 'position': 2, + 'address': { + 'street': pet_street, + 'city': pet_city, + }, + 'healthy': pet_healthy, + 'wings': { + 'healthy': pet_healthy, + } + } + content_type = 'application/json' + headers = { + 'Authorization': 'Basic testuser', + 'Content-Type': content_type, + } + body = dumps(data_json) + + response = client.simulate_post( + '/v1/pets', + host='staging.gigantic-server.com', headers=headers, + body=body, cookies=cookies, protocol='https', + ) + + expected_data = { + 'errors': [ + { + 'class': ( + "" + ), + 'status': 400, + 'title': 'Missing required parameter: api-key', + } + ] + } + assert response.status_code == 400 + assert response.json == expected_data + + def test_post_media_type_invalid(self, client): + cookies = {'user': 1} + data = 'data' + # noly 3 media types are supported by falcon by default: + # json, multipart and urlencoded + content_type = MEDIA_URLENCODED + headers = { + 'Authorization': 'Basic testuser', + 'Api-Key': self.api_key_encoded, + 'Content-Type': content_type, + } + + response = client.simulate_post( + '/v1/pets', + host='staging.gigantic-server.com', headers=headers, + body=data, cookies=cookies, protocol='https', + ) + + expected_data = { + 'errors': [ + { + 'class': ( + "" + ), + 'status': 415, + 'title': ( + "Content for the following mimetype not found: " + f"{content_type}. " + "Valid mimetypes: ['application/json', 'text/plain']" + ), + } + ] + } + assert response.status_code == 415 + assert response.json == expected_data + + def test_post_required_cookie_param_missing(self, client): + content_type = 'application/json' + data_json = { + 'id': 12, + 'name': 'Cat', + 'ears': { + 'healthy': True, + }, + } + headers = { + 'Authorization': 'Basic testuser', + 'Api-Key': self.api_key_encoded, + 'Content-Type': content_type, + } + body = dumps(data_json) + + response = client.simulate_post( + '/v1/pets', + host='staging.gigantic-server.com', headers=headers, + body=body, protocol='https', + ) + + expected_data = { + 'errors': [ + { + 'class': ( + "" + ), + 'status': 400, + 'title': "Missing required parameter: user", + } + ] + } + assert response.status_code == 400 + assert response.json == expected_data + + def test_post_valid(self, client): + cookies = {'user': 1} + content_type = 'application/json' + data_json = { + 'id': 12, + 'name': 'Cat', + 'ears': { + 'healthy': True, + }, + } + headers = { + 'Authorization': 'Basic testuser', + 'Api-Key': self.api_key_encoded, + 'Content-Type': content_type, + } + body = dumps(data_json) + + response = client.simulate_post( + '/v1/pets', + host='staging.gigantic-server.com', headers=headers, + body=body, cookies=cookies, protocol='https', + ) + + assert response.status_code == 201 + assert not response.content + + +class TestPetDetailResource: + + def test_get_server_invalid(self, client): + headers = {'Content-Type': 'application/json'} + + response = client.simulate_get('/v1/pets/12', headers=headers) + + expected_data = { + 'errors': [ + { + 'class': ( + "" + ), + 'status': 400, + 'title': ( + 'Server not found for ' + 'http://falconframework.org/v1/pets/12' + ), + } + ] + } + assert response.status_code == 400 + assert response.json == expected_data + + def test_get_path_invalid(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_get_unauthorized(self, client): + headers = {'Content-Type': 'application/json'} + + response = client.simulate_get( + '/v1/pets/12', host='petstore.swagger.io', headers=headers) + + expected_data = { + 'errors': [ + { + 'class': ( + "" + ), + 'status': 403, + 'title': 'Security not valid for any requirement', + } + ] + } + assert response.status_code == 403 + assert response.json == expected_data + + def test_get_valid(self, client): + auth = 'authuser' + content_type = 'application/json' + headers = { + 'Authorization': f'Basic {auth}', + 'Content-Type': content_type, + } + + response = client.simulate_get( + '/v1/pets/12', host='petstore.swagger.io', headers=headers) + + assert response.status_code == 200 + + def test_delete_method_invalid(self, client): + auth = 'authuser' + content_type = 'application/json' + headers = { + 'Authorization': f'Basic {auth}', + 'Content-Type': content_type, + } + + response = client.simulate_delete( + '/v1/pets/12', host='petstore.swagger.io', headers=headers) + + expected_data = { + 'errors': [ + { + 'class': ( + "" + ), + 'status': 405, + 'title': ( + 'Operation delete not found for ' + 'http://petstore.swagger.io/v1/pets/12' + ), + } + ] + } + assert response.status_code == 405 + assert response.json == expected_data diff --git a/tests/integration/contrib/falcon/test_falcon_validation.py b/tests/integration/contrib/falcon/test_falcon_validation.py deleted file mode 100644 index f65b690a..00000000 --- a/tests/integration/contrib/falcon/test_falcon_validation.py +++ /dev/null @@ -1,46 +0,0 @@ -import pytest - -from openapi_core.contrib.falcon.requests import FalconOpenAPIRequestFactory -from openapi_core.contrib.falcon.responses import FalconOpenAPIResponseFactory -from openapi_core.shortcuts import create_spec -from openapi_core.validation.request.validators import RequestValidator -from openapi_core.validation.response.validators import ResponseValidator - - -class TestFalconOpenAPIValidation: - - @pytest.fixture - def spec(self, factory): - specfile = 'contrib/falcon/data/v3.0/openapi.yaml' - return create_spec(factory.spec_from_file(specfile)) - - def test_response_validator_path_pattern(self, - spec, - request_factory, - response_factory): - validator = ResponseValidator(spec) - request = request_factory('GET', '/browse/12', subdomain='kb') - openapi_request = FalconOpenAPIRequestFactory().create(request) - response = response_factory( - '{"data": "data"}', - status_code=200, headers={'X-Rate-Limit': '12'}, - ) - 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) - result = validator.validate(openapi_request) - assert not result.errors - - def test_request_validator_with_query(self, spec, request_factory): - validator = RequestValidator(spec) - request = request_factory('GET', '/browse/12', - query_string='detail_level=2', - subdomain='kb') - openapi_request = FalconOpenAPIRequestFactory().create(request) - result = validator.validate(openapi_request) - assert not result.errors