diff --git a/openapi_core/wrappers.py b/openapi_core/wrappers.py index 019e242d..a19db2b5 100644 --- a/openapi_core/wrappers.py +++ b/openapi_core/wrappers.py @@ -1,9 +1,14 @@ """OpenAPI core wrappers module""" +import re +from string import Formatter import warnings +from urllib.parse import urlparse from six.moves.urllib.parse import urljoin from werkzeug.datastructures import ImmutableMultiDict +from openapi_core.exceptions import InvalidServer, InvalidOperation + class BaseOpenAPIRequest(object): @@ -107,6 +112,83 @@ def mimetype(self): return self.request.mimetype +class RequestsOpenAPIRequest(BaseOpenAPIRequest): + + def __init__(self, response, path_pattern, regex_pattern, server_pattern): + """ + + :param response: The Requests Responce Object + :type response: requests.models.Response + :param path_pattern: The path pattern determined by the factory + :type path_pattern: str + :param regex_pattern: Used to extract path params. + :type regex_pattern: str + """ + self.response = response + self.url = urlparse(self.response.url) + self._path_pattern = path_pattern + self._regex_pattern = regex_pattern + self._server_pattern = server_pattern + self._parameters = { + 'path': self._extract_path_params(), + 'query': self._extract_query_params(), + 'headers': self.response.request.headers, + 'cookies': self.response.cookies, + } + + @property + def full_url_pattern(self): + return self.host_url + self.path_pattern + + @property + def host_url(self): + return re.match(self._server_pattern, self.response.url).group(0) + + @property + def path(self): + return re.sub(self._server_pattern, '', urljoin(self.host_url, + self.url.path)) + + @property + def method(self): + return self.response.request.method.lower() + + @property + def path_pattern(self): + return self._path_pattern + + @property + def parameters(self): + return self._parameters + + def _extract_body(self): + if hasattr(self.response.request, 'text'): + return self.response.request.text + return '' + + def _extract_query_params(self): + if hasattr(self.response.request, 'qs'): + return self.response.request.qs + return {} + + def _extract_path_params(self): + # Get the values of the path parameters + groups = re.match(self._regex_pattern, self.path).groups() + # Get the names of path parameters + names = [fname[1] for fname in Formatter() + .parse(self.path_pattern) if fname] + return {name: group for name, group in zip(names, groups)} + + @property + def body(self): + return self._extract_body() + + @property + def mimetype(self): + return self.response.request.headers.get('Accept') or\ + self.response.headers.get('Content-Type') + + class BaseOpenAPIResponse(object): body = NotImplemented @@ -135,8 +217,104 @@ def data(self): @property def status_code(self): - return self.response._status_code + return self.response.status_code @property def mimetype(self): return self.response.mimetype + + +class RequestsOpenAPIResponse(BaseOpenAPIResponse): + + def __init__(self, response): + self.response = response + + @property + def data(self): + return self._extract_data() + + @property + def status_code(self): + return self.response.status_code + + @property + def mimetype(self): + return self.response.headers.get('Content-Type') + + def _extract_data(self): + if hasattr(self.response, 'text'): + return self.response.text + return '' + + +class RequestsFactory(object): + path_regex = re.compile('{(.*?)}') + + def __init__(self, spec): + """ + Creates the request factory. A spec is required. + + :param spec: The openapi spec to use for decoding the request + :type spec: openapi_core.specs.Spec + """ + self.paths_regex = self._create_paths_regex(spec) + self.server_regex = self._create_server_regex(spec) + + def _create_server_regex(self, spec): + server_regex = [] + for server in spec.servers: + var_map = {} + for var in server.variables: + if server.variables[var].enum: + var_map[var] = "(" +\ + "|".join(server.variables[var].enum) + ")" + else: + var_map[var] = "(.*)" + server_regex.append(server.url.format_map(var_map)) + return server_regex + + def _create_paths_regex(self, spec): + paths_regex = {} + for path in spec.paths: + pattern = self.path_regex.sub('(.*)', path) + paths_regex[pattern] = path + return paths_regex + + def _match_operation(self, path_pattern): + for expr in self.paths_regex: + if re.fullmatch(expr, path_pattern): + return expr + return None + + def _match_server(self, server_pattern): + for expr in self.server_regex: + if re.match(expr, server_pattern): + return expr + return None + + def create_request(self, response): + """ + Creates an OpenApi compatible request out of the raw requests request + + :param request: requests.models.Request + :return: RequestsOpenApiRequest + """ + server_pattern = self._match_server(response.url) + if not server_pattern: + raise InvalidServer("Url server not in spec.") + path = re.sub(server_pattern, '', response.url) + pattern = self._match_operation(path) + if not pattern: + raise InvalidOperation("Operation not in spec.") + response = RequestsOpenAPIRequest( + response, self.paths_regex[pattern], pattern, server_pattern) + return response + + def create_response(self, response): + """ + + :param request: + :type request: requests.models.PreparedRequest + :return: RequestsOpenAPIResponse + """ + return RequestsOpenAPIResponse(response) diff --git a/requirements.txt b/requirements.txt index 86471695..7dc7a102 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ openapi-spec-validator six +typing yarl diff --git a/requirements_dev.txt b/requirements_dev.txt index 2326086e..656468b3 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -3,4 +3,6 @@ pytest pytest-pep8 pytest-flakes pytest-cov +requests +requests-mock flask \ No newline at end of file diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 3f537be6..880ebe43 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -1,5 +1,4 @@ from os import path - import pytest from six.moves.urllib import request from yaml import safe_load diff --git a/tests/integration/data/v3.0/petstore.yaml b/tests/integration/data/v3.0/petstore.yaml index ca549057..1cc62e14 100644 --- a/tests/integration/data/v3.0/petstore.yaml +++ b/tests/integration/data/v3.0/petstore.yaml @@ -81,6 +81,12 @@ paths: schema: $ref: '#/components/schemas/PetCreate' responses: + '200': + description: Pet Created Response + content: + application/json: + schema: + $ref: "#/components/schemas/PetData" '201': description: Null response default: @@ -232,3 +238,5 @@ components: application/json: schema: $ref: "#/components/schemas/ExtendedError" + + diff --git a/tests/integration/data/v3.0/server_path_variations.yaml b/tests/integration/data/v3.0/server_path_variations.yaml new file mode 100644 index 00000000..6b9cd14c --- /dev/null +++ b/tests/integration/data/v3.0/server_path_variations.yaml @@ -0,0 +1,21 @@ +openapi: "3.0.0" +info: + title: Minimal valid OpenAPI specification with explicit 'servers' array + version: "0.1" +servers: + - url: https://{customerId}.saas-app.com:{port}/v2 + variables: + customerId: + default: demo + description: Customer ID assigned by the service provider + port: + enum: + - '443' + - '8443' + default: '443' +paths: + /status: + get: + responses: + default: + description: Return the API status. diff --git a/tests/integration/test_requests_factory.py b/tests/integration/test_requests_factory.py new file mode 100644 index 00000000..44d7fc2c --- /dev/null +++ b/tests/integration/test_requests_factory.py @@ -0,0 +1,300 @@ +import json + +import pytest +import requests +import requests_mock + +from openapi_core.wrappers import RequestsFactory +from openapi_core.shortcuts import create_spec +from openapi_core.validators import ResponseValidator, RequestValidator +from openapi_core.exceptions import InvalidServer, InvalidOperation + + +@pytest.fixture(scope='session', autouse=True) +def create_mock_session(): + session = requests.Session() + adapter = requests_mock.Adapter() + session.mount('mock', adapter) + + +@pytest.fixture(scope='session') +def server_spec(factory): + server_spec_dict = factory.spec_from_file( + "data/v3.0/server_path_variations.yaml" + ) + return create_spec(server_spec_dict) + + +@pytest.fixture(scope='session') +def spec_dict(factory): + return factory.spec_from_file("data/v3.0/petstore.yaml") + + +@pytest.fixture(scope='session') +def spec(spec_dict): + return create_spec(spec_dict) + + +@pytest.fixture(scope='session') +def request_validator(spec): + return RequestValidator(spec) + + +@pytest.fixture(scope='session') +def response_validator(spec): + return ResponseValidator(spec) + + +@pytest.fixture(scope='session') +def server_request_validator(server_spec): + return RequestValidator(server_spec) + + +@pytest.fixture +def requests_factory(spec): + return RequestsFactory(spec) + + +@pytest.fixture +def server_requests_factory(server_spec): + return RequestsFactory(server_spec) + + +@pytest.fixture('session') +def pets_get_response_body(): + return { + "data": { + "id": 12345, + "name": "Sparky", + "tag": "dogs", + "address": {"street": "1234 Someplace", + "city": "Atlanta"}, + "position": 1, + "healthy": True + } + } + + +@pytest.fixture('session') +def pets_post_body(): + return { + "tag": "dogs", + "name": "Sparky", + "address": {"street": "1234 Someplace", + "city": "Atlanta"}, + "position": 1, + "healthy": True + } + + +@pytest.fixture(scope='session') +def pets_path_mock_get(pets_get_response_body): + with requests_mock.mock() as m: + url = 'http://petstore.swagger.io/v1/pets/12345?page=3' + m.get(url, + text=json.dumps(pets_get_response_body), + headers={'Authorization': 'Bearer 123456', + 'Content-Type': 'application/json'}, + cookies={} + ) + response = requests.get(url=url, + headers={'Authorization': 'Bearer 123456', + 'Accept': 'application/json'}) + return response + + +@pytest.fixture(scope='session') +def pets_mock_get(pets_get_response_body): + with requests_mock.mock() as m: + m.get('http://petstore.swagger.io/v1/pets/12345', + text=json.dumps(pets_get_response_body), + headers={'Authorization': 'Bearer 123456', + 'Content-Type': 'application/json'}, + cookies={} + ) + response = requests.get(url='http://petstore.swagger.io/v1/pets/12345', + headers={'Authorization': 'Bearer 123456', + 'Accept': 'application/json'}) + return response + + +@pytest.fixture(scope='session') +def pets_mock_post(pets_get_response_body, pets_post_body): + url = 'http://petstore.swagger.io/v1/pets' + with requests_mock.mock() as m: + m.post(url, + text=json.dumps(pets_get_response_body), + headers={'Authorization': 'Bearer 123456', + 'Content-Type': 'application/json'}, + cookies={} + ) + response = requests.post(url=url, + headers={'Authorization': 'Bearer 123456', + 'Accept': 'application/json'}, + data=json.dumps(pets_post_body)) + return response + + +def test_mock_get_request_converts_correctly( + requests_factory, + pets_path_mock_get, + pets_get_response_body): + """ + Verifies that a GET request is correctly converted. + + :param requests_factory: + :type requests_factory: openapi_core.wrappers.RequestsFactory + :param pets_path_mock_get: + :type pets_path_mock_get: requests.models.Request + """ + request = requests_factory.create_request(pets_path_mock_get) + + assert request.host_url == "http://petstore.swagger.io/v1" + assert request.path == "/pets/12345" + assert request.method == 'get' + assert request.path_pattern == "/pets/{petId}" + assert request.parameters['path']['petId'] == '12345' + assert request.parameters['query']['page'] == ['3'] + assert request.parameters['headers']['Authorization'] == 'Bearer 123456' + assert not request.parameters['cookies'] + assert not request.body + assert request.mimetype == 'application/json' + + response = requests_factory.create_response(pets_path_mock_get) + assert json.loads(response.data) == pets_get_response_body + assert response.status_code == 200 + assert response.mimetype == 'application/json' + + +def test_mock_post_request_converts_correctly( + requests_factory, + pets_mock_post, + pets_post_body, + pets_get_response_body): + """ + Verifies that a POST request is correctly converted. + + :param requests_factory: + :type requests_factory: openapi_core.wrappers.RequestsFactory + :param pets_mock_post: + :type pets_mock_post: requests.models.Request + """ + + request = requests_factory.create_request(pets_mock_post) + assert request.host_url == "http://petstore.swagger.io/v1" + assert request.path == "/pets" + assert request.method == 'post' + assert request.path_pattern == "/pets" + assert request.parameters['headers']['Authorization'] == 'Bearer 123456' + assert not request.parameters['cookies'] + assert json.loads(request.body) == pets_post_body + assert request.mimetype == 'application/json' + + response = requests_factory.create_response(pets_mock_post) + assert json.loads(response.data) == pets_get_response_body + assert response.status_code == 200 + assert response.mimetype == 'application/json' + + +def test_server_regex_for_server_wildcards( + server_requests_factory, + server_request_validator): + with requests_mock.mock() as m: + m.get('https://123456.saas-app.com:443/v2/status') + response = requests.get( + url='https://123456.saas-app.com:443/v2/status') + request = server_requests_factory.create_request(response) + expected_server_pattern = 'https://(.*).saas-app.com:(443|8443)/v2' + assert request._server_pattern == expected_server_pattern + server_request_validator.validate(request) + + +def test_get_validation( + requests_factory, + request_validator, + response_validator, + pets_mock_get): + """ + Verifies that a GET request is correctly validated. + + :param requests_factory: + :type requests_factory: openapi_core.wrappers.RequestsFactory + :param request_validator: + :type request_validator: openapi_core.validators.RequestValidator + :param pets_mock_get: + :type pets_mock_get: requests.models.Request + """ + + request = requests_factory.create_request(pets_mock_get) + results = request_validator.validate(request) + assert not results.errors + + response = requests_factory.create_response(pets_mock_get) + results = response_validator.validate(request, response) + assert not results.errors + + +def test_post_validation(requests_factory, + request_validator, + response_validator, + pets_mock_post): + """ + Verifies that a POST request is correctly validated. + + :param requests_factory: + :type requests_factory: openapi_core.wrappers.RequestsFactory + :param request_validator: + :type request_validator: openapi_core.validators.RequestValidator + :param pets_mock_post: + :type pets_mock_post: requests.models.Request + """ + + request = requests_factory.create_request(pets_mock_post) + results = request_validator.validate(request) + assert not results.errors + + response = requests_factory.create_response(pets_mock_post) + results = response_validator.validate(request, response) + assert not results.errors + + +def test_invalid_server(requests_factory, pets_get_response_body): + """ + Verifies that an invalid server will throw an error + + :param requests_factory: + """ + with pytest.raises(InvalidServer): + with requests_mock.mock() as m: + m.get('http://petstore.swagger.io.derp/v1/pets/12345', + text=json.dumps(pets_get_response_body), + headers={'Authorization': 'Bearer 123456', + 'Content-Type': 'application/json'}, + cookies={} + ) + response = requests.get( + url='http://petstore.swagger.io.derp/v1/pets/12345', + headers={'Authorization': 'Bearer 123456', + 'Accept': 'application/json'}) + requests_factory.create_request(response) + + +def test_invalid_operation(requests_factory, pets_get_response_body): + """ + Verifies that an invalid server will throw an error + + :param requests_factory: + """ + with pytest.raises(InvalidOperation): + with requests_mock.mock() as m: + m.get('http://petstore.swagger.io/v1/petters/12345', + text=json.dumps(pets_get_response_body), + headers={'Authorization': 'Bearer 123456', + 'Content-Type': 'application/json'}, + cookies={} + ) + response = requests.get( + url='http://petstore.swagger.io/v1/petters/12345', + headers={'Authorization': 'Bearer 123456', + 'Accept': 'application/json'}) + requests_factory.create_request(response) diff --git a/tests/integration/test_wrappers.py b/tests/integration/test_wrappers.py index b33505e9..660a017e 100644 --- a/tests/integration/test_wrappers.py +++ b/tests/integration/test_wrappers.py @@ -1,5 +1,4 @@ import pytest - from flask.wrappers import Request, Response from werkzeug.datastructures import EnvironHeaders, ImmutableMultiDict from werkzeug.routing import Map, Rule, Subdomain