From 7e920f829091f78506b99c422414569971e8071e Mon Sep 17 00:00:00 2001 From: Artur Maciag Date: Mon, 2 Mar 2020 16:05:36 +0000 Subject: [PATCH] Requests integration --- README.rst | 30 ++++++++ openapi_core/contrib/requests/__init__.py | 15 ++++ openapi_core/contrib/requests/requests.py | 34 +++++++++ openapi_core/contrib/requests/responses.py | 14 ++++ openapi_core/validation/request/datatypes.py | 9 ++- openapi_core/validation/request/validators.py | 33 ++++++--- .../validation/response/validators.py | 19 +++-- requirements_dev.txt | 1 + setup.cfg | 1 + .../integration/contrib/requests/conftest.py | 34 +++++++++ .../requests/data/v3.0/requests_factory.yaml | 48 +++++++++++++ .../requests/test_requests_requests.py | 72 +++++++++++++++++++ .../requests/test_requests_validation.py | 35 +++++++++ tests/integration/validation/test_minimal.py | 5 +- .../integration/validation/test_validators.py | 14 ++-- 15 files changed, 337 insertions(+), 27 deletions(-) create mode 100644 openapi_core/contrib/requests/__init__.py create mode 100644 openapi_core/contrib/requests/requests.py create mode 100644 openapi_core/contrib/requests/responses.py create mode 100644 tests/integration/contrib/requests/conftest.py create mode 100644 tests/integration/contrib/requests/data/v3.0/requests_factory.yaml create mode 100644 tests/integration/contrib/requests/test_requests_requests.py create mode 100644 tests/integration/contrib/requests/test_requests_validation.py diff --git a/README.rst b/README.rst index 7fca8e2..7befbcb 100644 --- a/README.rst +++ b/README.rst @@ -291,6 +291,36 @@ Pyramid See `pyramid_openapi3 `_ project. +Requests +******** + +This section describes integration with `Requests `__ library. + +Low level +========= + +For Requests you can use RequestsOpenAPIRequest a Requests request factory: + +.. code-block:: python + + from openapi_core.validation.request.validators import RequestValidator + from openapi_core.contrib.requests import RequestsOpenAPIRequest + + openapi_request = RequestsOpenAPIRequest(requests_request) + validator = RequestValidator(spec) + result = validator.validate(openapi_request) + +You can use RequestsOpenAPIResponse as a Requests response factory: + +.. code-block:: python + + from openapi_core.validation.response.validators import ResponseValidator + from openapi_core.contrib.requests import RequestsOpenAPIResponse + + openapi_response = RequestsOpenAPIResponse(requests_response) + validator = ResponseValidator(spec) + result = validator.validate(openapi_request, openapi_response) + Related projects ################ * `openapi-spec-validator `__ diff --git a/openapi_core/contrib/requests/__init__.py b/openapi_core/contrib/requests/__init__.py new file mode 100644 index 0000000..a95180a --- /dev/null +++ b/openapi_core/contrib/requests/__init__.py @@ -0,0 +1,15 @@ +from openapi_core.contrib.requests.requests import ( + RequestsOpenAPIRequestFactory, +) +from openapi_core.contrib.requests.responses import ( + RequestsOpenAPIResponseFactory, +) + +# backward compatibility +RequestsOpenAPIRequest = RequestsOpenAPIRequestFactory.create +RequestsOpenAPIResponse = RequestsOpenAPIResponseFactory.create + +__all__ = [ + 'RequestsOpenAPIRequestFactory', 'RequestsOpenAPIResponseFactory', + 'RequestsOpenAPIRequest', 'RequestsOpenAPIResponse', +] diff --git a/openapi_core/contrib/requests/requests.py b/openapi_core/contrib/requests/requests.py new file mode 100644 index 0000000..12921d9 --- /dev/null +++ b/openapi_core/contrib/requests/requests.py @@ -0,0 +1,34 @@ +"""OpenAPI core contrib requests requests module""" +from werkzeug.datastructures import ImmutableMultiDict + +from openapi_core.validation.request.datatypes import ( + RequestParameters, OpenAPIRequest, +) + + +class RequestsOpenAPIRequestFactory(object): + + @classmethod + def create(cls, request): + method = request.method.lower() + + cookie = request.cookies or {} + + # gets deduced by path finder against spec + path = {} + + mimetype = request.headers.get('Accept') or \ + request.headers.get('Content-Type') + parameters = RequestParameters( + query=ImmutableMultiDict(request.params), + header=request.headers, + cookie=cookie, + path=path, + ) + return OpenAPIRequest( + full_url_pattern=request.url, + method=method, + parameters=parameters, + body=request.data, + mimetype=mimetype, + ) diff --git a/openapi_core/contrib/requests/responses.py b/openapi_core/contrib/requests/responses.py new file mode 100644 index 0000000..0546051 --- /dev/null +++ b/openapi_core/contrib/requests/responses.py @@ -0,0 +1,14 @@ +"""OpenAPI core contrib requests responses module""" +from openapi_core.validation.response.datatypes import OpenAPIResponse + + +class RequestsOpenAPIResponseFactory(object): + + @classmethod + def create(cls, response): + mimetype = response.headers.get('Content-Type') + return OpenAPIResponse( + data=response.raw, + status_code=response.status_code, + mimetype=mimetype, + ) diff --git a/openapi_core/validation/request/datatypes.py b/openapi_core/validation/request/datatypes.py index abece0a..b8433b0 100644 --- a/openapi_core/validation/request/datatypes.py +++ b/openapi_core/validation/request/datatypes.py @@ -10,19 +10,19 @@ class RequestParameters(object): """OpenAPI request parameters dataclass. Attributes: - path - Path parameters as dict. query Query string parameters as MultiDict. Must support getlist method. header Request headers as dict. cookie Request cookies as dict. + path + Path parameters as dict. Gets resolved against spec if empty. """ - path = attr.ib(factory=dict) query = attr.ib(factory=ImmutableMultiDict) header = attr.ib(factory=dict) cookie = attr.ib(factory=dict) + path = attr.ib(factory=dict) def __getitem__(self, location): return getattr(self, location) @@ -63,3 +63,6 @@ class RequestValidationResult(BaseValidationResult): body = attr.ib(default=None) parameters = attr.ib(factory=RequestParameters) security = attr.ib(default=None) + server = attr.ib(default=None) + path = attr.ib(default=None) + operation = attr.ib(default=None) diff --git a/openapi_core/validation/request/validators.py b/openapi_core/validation/request/validators.py index cffeef8..6d26058 100644 --- a/openapi_core/validation/request/validators.py +++ b/openapi_core/validation/request/validators.py @@ -26,16 +26,18 @@ class RequestValidator(BaseValidator): def validate(self, request): try: - path, operation, _, _, _ = self._find_path(request) + path, operation, _, path_result, _ = self._find_path(request) # don't process if operation errors except PathError as exc: - return RequestValidationResult([exc, ], None, None, None) + return RequestValidationResult(errors=[exc, ]) try: security = self._get_security(request, operation) except InvalidSecurity as exc: - return RequestValidationResult([exc, ], None, None, None) + return RequestValidationResult(errors=[exc, ]) + request.parameters.path = request.parameters.path or \ + path_result.variables params, params_errors = self._get_parameters( request, chain( iteritems(operation.parameters), @@ -46,30 +48,43 @@ class RequestValidator(BaseValidator): body, body_errors = self._get_body(request, operation) errors = params_errors + body_errors - return RequestValidationResult(errors, body, params, security) + return RequestValidationResult( + errors=errors, + body=body, + parameters=params, + security=security, + ) def _validate_parameters(self, request): try: - path, operation, _, _, _ = self._find_path(request) + path, operation, _, path_result, _ = self._find_path(request) except PathError as exc: - return RequestValidationResult([exc, ], None, None) + return RequestValidationResult(errors=[exc, ]) + request.parameters.path = request.parameters.path or \ + path_result.variables params, params_errors = self._get_parameters( request, chain( iteritems(operation.parameters), iteritems(path.parameters) ) ) - return RequestValidationResult(params_errors, None, params, None) + return RequestValidationResult( + errors=params_errors, + parameters=params, + ) def _validate_body(self, request): try: _, operation, _, _, _ = self._find_path(request) except PathError as exc: - return RequestValidationResult([exc, ], None, None) + return RequestValidationResult(errors=[exc, ]) body, body_errors = self._get_body(request, operation) - return RequestValidationResult(body_errors, body, None, None) + return RequestValidationResult( + errors=body_errors, + body=body, + ) def _get_security(self, request, operation): security = operation.security or self.spec.security diff --git a/openapi_core/validation/response/validators.py b/openapi_core/validation/response/validators.py index 07dc1d3..10acdc9 100644 --- a/openapi_core/validation/response/validators.py +++ b/openapi_core/validation/response/validators.py @@ -21,14 +21,14 @@ class ResponseValidator(BaseValidator): _, operation, _, _, _ = self._find_path(request) # don't process if operation errors except PathError as exc: - return ResponseValidationResult([exc, ], None, None) + return ResponseValidationResult(errors=[exc, ]) try: operation_response = self._get_operation_response( operation, response) # don't process if operation errors except InvalidResponse as exc: - return ResponseValidationResult([exc, ], None, None) + return ResponseValidationResult(errors=[exc, ]) data, data_errors = self._get_data(response, operation_response) @@ -36,7 +36,11 @@ class ResponseValidator(BaseValidator): response, operation_response) errors = data_errors + headers_errors - return ResponseValidationResult(errors, data, headers) + return ResponseValidationResult( + errors=errors, + data=data, + headers=headers, + ) def _get_operation_response(self, operation, response): return operation.get_response(str(response.status_code)) @@ -46,17 +50,20 @@ class ResponseValidator(BaseValidator): _, operation, _, _, _ = self._find_path(request) # don't process if operation errors except PathError as exc: - return ResponseValidationResult([exc, ], None, None) + return ResponseValidationResult(errors=[exc, ]) try: operation_response = self._get_operation_response( operation, response) # don't process if operation errors except InvalidResponse as exc: - return ResponseValidationResult([exc, ], None, None) + return ResponseValidationResult(errors=[exc, ]) data, data_errors = self._get_data(response, operation_response) - return ResponseValidationResult(data_errors, data, None) + return ResponseValidationResult( + errors=data_errors, + data=data, + ) def _get_data(self, response, operation_response): if not operation_response.content: diff --git a/requirements_dev.txt b/requirements_dev.txt index 2ea7e7e..d96c287 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -4,4 +4,5 @@ pytest-flake8 pytest-cov==2.5.1 flask django==2.2.10; python_version>="3.0" +requests==2.22.0 webob diff --git a/setup.cfg b/setup.cfg index 1d78dd6..3c96c3b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -49,6 +49,7 @@ exclude = [options.extras_require] django = django>=2.2; python_version>="3.0" flask = flask +requests = requests [tool:pytest] addopts = -sv --flake8 --junitxml reports/junit.xml --cov openapi_core --cov-report term-missing --cov-report xml:reports/coverage.xml diff --git a/tests/integration/contrib/requests/conftest.py b/tests/integration/contrib/requests/conftest.py new file mode 100644 index 0000000..00aac4f --- /dev/null +++ b/tests/integration/contrib/requests/conftest.py @@ -0,0 +1,34 @@ +import pytest +from requests.models import Request, Response +from requests.structures import CaseInsensitiveDict +from six.moves.urllib.parse import urljoin, parse_qs + + +@pytest.fixture +def request_factory(): + schema = 'http' + server_name = 'localhost' + + def create_request(method, path, subdomain=None, query_string=''): + base_url = '://'.join([schema, server_name]) + url = urljoin(base_url, path) + params = parse_qs(query_string) + headers = { + 'Content-Type': 'application/json', + } + return Request(method, url, params=params, headers=headers) + return create_request + + +@pytest.fixture +def response_factory(): + def create_response( + data, status_code=200, content_type='application/json'): + resp = Response() + resp.headers = CaseInsensitiveDict({ + 'Content-Type': content_type, + }) + resp.status_code = status_code + resp.raw = data + return resp + return create_response diff --git a/tests/integration/contrib/requests/data/v3.0/requests_factory.yaml b/tests/integration/contrib/requests/data/v3.0/requests_factory.yaml new file mode 100644 index 0000000..abef7eb --- /dev/null +++ b/tests/integration/contrib/requests/data/v3.0/requests_factory.yaml @@ -0,0 +1,48 @@ +openapi: "3.0.0" +info: + title: Basic OpenAPI specification used with requests integration tests + 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 + get: + responses: + 200: + description: Return the resource. + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + type: string + 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/requests/test_requests_requests.py b/tests/integration/contrib/requests/test_requests_requests.py new file mode 100644 index 0000000..45e0258 --- /dev/null +++ b/tests/integration/contrib/requests/test_requests_requests.py @@ -0,0 +1,72 @@ +from werkzeug.datastructures import ImmutableMultiDict + +from openapi_core.contrib.requests import RequestsOpenAPIRequest +from openapi_core.validation.request.datatypes import RequestParameters + + +class TestRequestsOpenAPIRequest(object): + + def test_simple(self, request_factory, request): + request = request_factory('GET', '/', subdomain='www') + + openapi_request = RequestsOpenAPIRequest(request) + + path = {} + query = ImmutableMultiDict([]) + headers = request.headers + cookies = {} + assert openapi_request.parameters == RequestParameters( + path=path, + query=query, + header=headers, + cookie=cookies, + ) + assert openapi_request.method == request.method.lower() + assert openapi_request.full_url_pattern == 'http://localhost/' + assert openapi_request.body == request.data + assert openapi_request.mimetype == 'application/json' + + def test_multiple_values(self, request_factory, request): + request = request_factory( + 'GET', '/', subdomain='www', query_string='a=b&a=c') + + openapi_request = RequestsOpenAPIRequest(request) + + path = {} + query = ImmutableMultiDict([ + ('a', 'b'), ('a', 'c'), + ]) + headers = request.headers + cookies = {} + assert openapi_request.parameters == RequestParameters( + path=path, + query=query, + header=headers, + cookie=cookies, + ) + assert openapi_request.method == request.method.lower() + assert openapi_request.full_url_pattern == 'http://localhost/' + assert openapi_request.body == request.data + assert openapi_request.mimetype == 'application/json' + + def test_url_rule(self, request_factory, request): + request = request_factory('GET', '/browse/12/', subdomain='kb') + + openapi_request = RequestsOpenAPIRequest(request) + + # empty when not bound to spec + path = {} + query = ImmutableMultiDict([]) + headers = request.headers + cookies = {} + assert openapi_request.parameters == RequestParameters( + path=path, + query=query, + header=headers, + cookie=cookies, + ) + assert openapi_request.method == request.method.lower() + assert openapi_request.full_url_pattern == \ + 'http://localhost/browse/12/' + assert openapi_request.body == request.data + assert openapi_request.mimetype == 'application/json' diff --git a/tests/integration/contrib/requests/test_requests_validation.py b/tests/integration/contrib/requests/test_requests_validation.py new file mode 100644 index 0000000..7dc0355 --- /dev/null +++ b/tests/integration/contrib/requests/test_requests_validation.py @@ -0,0 +1,35 @@ +import pytest + +from openapi_core.contrib.requests import ( + RequestsOpenAPIRequest, RequestsOpenAPIResponse, +) +from openapi_core.shortcuts import create_spec +from openapi_core.validation.request.validators import RequestValidator +from openapi_core.validation.response.validators import ResponseValidator + + +class TestFlaskOpenAPIValidation(object): + + @pytest.fixture + def spec(self, factory): + specfile = 'contrib/requests/data/v3.0/requests_factory.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 = RequestsOpenAPIRequest(request) + response = response_factory('{"data": "data"}', status_code=200) + openapi_response = RequestsOpenAPIResponse(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 = RequestsOpenAPIRequest(request) + result = validator.validate(openapi_request) + assert not result.errors diff --git a/tests/integration/validation/test_minimal.py b/tests/integration/validation/test_minimal.py index 7e7e1f5..6936ce1 100644 --- a/tests/integration/validation/test_minimal.py +++ b/tests/integration/validation/test_minimal.py @@ -5,6 +5,7 @@ from openapi_core.templating.paths.exceptions import ( PathNotFound, OperationNotFound, ) from openapi_core.testing import MockRequest +from openapi_core.validation.request.datatypes import RequestParameters from openapi_core.validation.request.validators import RequestValidator @@ -48,7 +49,7 @@ class TestMinimal(object): assert len(result.errors) == 1 assert isinstance(result.errors[0], OperationNotFound) assert result.body is None - assert result.parameters is None + assert result.parameters == RequestParameters() @pytest.mark.parametrize("server", servers) @pytest.mark.parametrize("spec_path", spec_paths) @@ -63,4 +64,4 @@ class TestMinimal(object): assert len(result.errors) == 1 assert isinstance(result.errors[0], PathNotFound) assert result.body is None - assert result.parameters is None + assert result.parameters == RequestParameters() diff --git a/tests/integration/validation/test_validators.py b/tests/integration/validation/test_validators.py index 101f232..07dddd5 100644 --- a/tests/integration/validation/test_validators.py +++ b/tests/integration/validation/test_validators.py @@ -58,7 +58,7 @@ class TestRequestValidator(object): assert len(result.errors) == 1 assert type(result.errors[0]) == PathNotFound assert result.body is None - assert result.parameters is None + assert result.parameters == RequestParameters() def test_invalid_path(self, validator): request = MockRequest(self.host_url, 'get', '/v1') @@ -68,7 +68,7 @@ class TestRequestValidator(object): assert len(result.errors) == 1 assert type(result.errors[0]) == PathNotFound assert result.body is None - assert result.parameters is None + assert result.parameters == RequestParameters() def test_invalid_operation(self, validator): request = MockRequest(self.host_url, 'patch', '/v1/pets') @@ -78,7 +78,7 @@ class TestRequestValidator(object): assert len(result.errors) == 1 assert type(result.errors[0]) == OperationNotFound assert result.body is None - assert result.parameters is None + assert result.parameters == RequestParameters() def test_missing_parameter(self, validator): request = MockRequest(self.host_url, 'get', '/v1/pets') @@ -259,7 +259,7 @@ class TestRequestValidator(object): assert result.errors == [InvalidSecurity(), ] assert result.body is None - assert result.parameters is None + assert result.parameters == RequestParameters() assert result.security is None def test_get_pet(self, validator): @@ -432,7 +432,7 @@ class TestResponseValidator(object): assert len(result.errors) == 1 assert type(result.errors[0]) == PathNotFound assert result.data is None - assert result.headers is None + assert result.headers == {} def test_invalid_operation(self, validator): request = MockRequest(self.host_url, 'patch', '/v1/pets') @@ -443,7 +443,7 @@ class TestResponseValidator(object): assert len(result.errors) == 1 assert type(result.errors[0]) == OperationNotFound assert result.data is None - assert result.headers is None + assert result.headers == {} def test_invalid_response(self, validator): request = MockRequest(self.host_url, 'get', '/v1/pets') @@ -454,7 +454,7 @@ class TestResponseValidator(object): assert len(result.errors) == 1 assert type(result.errors[0]) == InvalidResponse assert result.data is None - assert result.headers is None + assert result.headers == {} def test_invalid_content_type(self, validator): request = MockRequest(self.host_url, 'get', '/v1/pets')