From 7e920f829091f78506b99c422414569971e8071e Mon Sep 17 00:00:00 2001
From: Artur Maciag <maciag.artur@gmail.com>
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  <https://github.com/niteoweb/pyramid_openapi3>`_ project.
 
+Requests
+********
+
+This section describes integration with `Requests <https://requests.readthedocs.io>`__ 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 <https://github.com/p1c2u/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')