diff --git a/.gitignore b/.gitignore index 43845f6fed..e1ac1bfc96 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # pact-python specific ignores e2e/pacts userserviceclient-userservice.json +detectcontentlambda-contentprovider.json pact/bin # Byte-compiled / optimized / DLL files diff --git a/examples/message/README.md b/examples/message/README.md index 6bc0a06d30..55081fa39b 100644 --- a/examples/message/README.md +++ b/examples/message/README.md @@ -41,7 +41,7 @@ class MessageHandler(object): ``` Below is a snippet from a test where the message handler has no error. -Since the expected event contains a key `documentType` with value `microsoft-word`, message handler does not throw an error and a pact file `f"pacts/{expected_json}"` is expected to be generated. +Since the expected event contains a key `documentType` with value `microsoft-word`, message handler does not throw an error and a pact file `f"{PACT_FILE}""` is expected to be generated. ```python def test_generate_new_pact_file(pact): @@ -69,7 +69,7 @@ def test_generate_new_pact_file(pact): assert isfile(f"{PACT_FILE}") == 1 ``` -For a similar test where the event does not contain a key `documentType` with value `microsoft-word`, a `CustomError` is generated and there there is no generated json file `f"pacts/{expected_json}"`. +For a similar test where the event does not contain a key `documentType` with value `microsoft-word`, a `CustomError` is generated and there there is no generated json file `f"{PACT_FILE}"`. ```python def test_throw_exception_handler(pact): @@ -97,8 +97,6 @@ def test_throw_exception_handler(pact): assert isfile(f"{PACT_FILE}") == 0 ``` -Otherwise, no pact file is generated. - ## Provider Note: The current example only tests the consumer side. diff --git a/examples/message/tests/consumer/test_message_consumer.py b/examples/message/tests/consumer/test_message_consumer.py index 0f06adae9e..b5671da66c 100644 --- a/examples/message/tests/consumer/test_message_consumer.py +++ b/examples/message/tests/consumer/test_message_consumer.py @@ -16,16 +16,17 @@ PACT_BROKER_URL = "http://localhost" PACT_BROKER_USERNAME = "pactbroker" PACT_BROKER_PASSWORD = "pactbroker" -PACT_DIR = 'pacts' +PACT_DIR = "pacts" -CONSUMER_NAME = 'DetectContentLambda' -PROVIDER_NAME = 'ContentProvider' -PACT_FILE = (f"{PACT_DIR}/{CONSUMER_NAME.lower().replace(' ', '_')}_message-" - + f"{PROVIDER_NAME.lower().replace(' ', '_')}_message.json") +CONSUMER_NAME = "DetectContentLambda" +PROVIDER_NAME = "ContentProvider" +PACT_FILE = (f"{PACT_DIR}/{CONSUMER_NAME.lower().replace(' ', '_')}-" + + f"{PROVIDER_NAME.lower().replace(' ', '_')}.json") -@pytest.fixture(scope='session') + +@pytest.fixture(scope="session") def pact(request): - version = request.config.getoption('--publish-pact') + version = request.config.getoption("--publish-pact") publish = True if version else False pact = MessageConsumer(CONSUMER_NAME, version=version).has_pact_with( @@ -54,57 +55,59 @@ def progressive_delay(file, time_to_wait=10, second_interval=0.5, verbose=False) time.sleep(second_interval) time_counter += 1 if verbose: - print(f'Trying for {time_counter*second_interval} seconds') + print(f"Trying for {time_counter*second_interval} seconds") if time_counter > time_to_wait: if verbose: - print(f'Already waited {time_counter*second_interval} seconds') + print(f"Already waited {time_counter*second_interval} seconds") break def test_throw_exception_handler(pact): cleanup_json(PACT_FILE) + wrong_event = { - 'documentName': 'spreadsheet.xls', - 'creator': 'WI', - 'documentType': 'microsoft-excel' + "event": "ObjectCreated:Put", + "documentName": "spreadsheet.xls", + "creator": "WI", + "documentType": "microsoft-excel" } (pact - .given('Another document in Document Service') - .expects_to_receive('Description') + .given("Document unsupported type") + .expects_to_receive("Description") .with_content(wrong_event) .with_metadata({ - 'Content-Type': 'application/json' + "Content-Type": "application/json" })) with pytest.raises(CustomError): with pact: - # handler needs 'documentType' == 'microsoft-word' + # handler needs "documentType" == "microsoft-word" MessageHandler(wrong_event) progressive_delay(f"{PACT_FILE}") assert isfile(f"{PACT_FILE}") == 0 -def test_generate_new_pact_file(pact): +def test_put_file(pact): cleanup_json(PACT_FILE) expected_event = { - 'documentName': 'document.doc', - 'creator': 'TP', - 'documentType': 'microsoft-word' + "event": "ObjectCreated:Put", + "documentName": "document.doc", + "creator": "TP", + "documentType": "microsoft-word" } (pact - .given('A document create in Document Service') - .expects_to_receive('Description') + .given("A document created successfully") + .expects_to_receive("Description") .with_content(expected_event) .with_metadata({ - 'Content-Type': 'application/json' + "Content-Type": "application/json" })) with pact: - # handler needs 'documentType' == 'microsoft-word' MessageHandler(expected_event) progressive_delay(f"{PACT_FILE}") @@ -121,17 +124,18 @@ def test_publish_to_broker(pact): `pytest tests/consumer/test_message_consumer.py::test_publish_pact_to_broker --publish-pact 2` """ expected_event = { - 'documentName': 'document.doc', - 'creator': 'TP', - 'documentType': 'microsoft-word' + "event": "ObjectCreated:Delete", + "documentName": "document.doc", + "creator": "TP", + "documentType": "microsoft-word" } (pact - .given('A document create in Document Service with broker') - .expects_to_receive('Description with broker') + .given("A document deleted successfully") + .expects_to_receive("Description with broker") .with_content(expected_event) .with_metadata({ - 'Content-Type': 'application/json' + "Content-Type": "application/json" })) with pact: diff --git a/examples/message/tests/provider/__init__.py b/examples/message/tests/provider/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/message/tests/provider/test_message_provider.py b/examples/message/tests/provider/test_message_provider.py new file mode 100644 index 0000000000..f7eab414dc --- /dev/null +++ b/examples/message/tests/provider/test_message_provider.py @@ -0,0 +1,51 @@ +import pytest +from pact import MessageProvider + + +def document_created_handler(): + return { + "event": "ObjectCreated:Put", + "documentName": "document.doc", + "creator": "TP", + "documentType": "microsoft-word" + } + + +def document_deleted_handler(): + return { + "event": "ObjectCreated:Delete", + "documentName": "document.doc", + "creator": "TP", + "documentType": "microsoft-word" + } + + +def test_verify_success(): + provider = MessageProvider( + message_providers={ + 'A document created successfully': document_created_handler, + 'A document deleted successfully': document_deleted_handler + }, + provider='ContentProvider', + consumer='DetectContentLambda', + pact_dir='pacts' + + ) + with provider: + provider.verify() + + +def test_verify_failure_when_a_provider_missing(): + provider = MessageProvider( + message_providers={ + 'A document created successfully': document_created_handler, + }, + provider='ContentProvider', + consumer='DetectContentLambda', + pact_dir='pacts' + + ) + + with pytest.raises(AssertionError): + with provider: + provider.verify() diff --git a/pact/__init__.py b/pact/__init__.py index 4d9c66d8ad..e81016348c 100644 --- a/pact/__init__.py +++ b/pact/__init__.py @@ -2,13 +2,15 @@ from .broker import Broker from .consumer import Consumer from .matchers import EachLike, Like, SomethingLike, Term, Format -from .message_pact import MessagePact from .message_consumer import MessageConsumer +from .message_pact import MessagePact +from .message_provider import MessageProvider from .pact import Pact from .provider import Provider from .verifier import Verifier from .__version__ import __version__ # noqa: F401 -__all__ = ('Broker', 'Consumer', 'EachLike', 'Like', 'MessageConsumer', 'MessagePact', +__all__ = ('Broker', 'Consumer', 'EachLike', 'Like', + 'MessageConsumer', 'MessagePact', 'MessageProvider', 'Pact', 'Provider', 'SomethingLike', 'Term', 'Format', 'Verifier') diff --git a/pact/broker.py b/pact/broker.py index 7f70ca3f3e..c67b632754 100644 --- a/pact/broker.py +++ b/pact/broker.py @@ -85,7 +85,7 @@ def publish(self, consumer_name, version, pact_dir=None, for tag in consumer_tags: command.extend(['-t', tag]) - print(f"PactBroker command: {command}") + log.debug(f"PactBroker publish command: {command}") publish_process = Popen(command) publish_process.wait() diff --git a/pact/http_proxy.py b/pact/http_proxy.py new file mode 100644 index 0000000000..23c9ebda96 --- /dev/null +++ b/pact/http_proxy.py @@ -0,0 +1,58 @@ +"""Http Proxy to be used as provider url in verifier.""" +from fastapi import FastAPI, status, Request, HTTPException +import uvicorn as uvicorn +import logging +log = logging.getLogger(__name__) +logging.basicConfig(level=logging.DEBUG) + +app = FastAPI() +PROXY_PORT = 1234 +UVICORN_LOGGING_LEVEL = "error" +items = { + "states": None +} + + +def _match_states(payload): + """Match states in payload against stored message handlers.""" + log.debug(f'Find handler from payload: {payload}') + handlers = items["states"] + states = handlers['messageHandlers'] + log.debug(f'Setup states: {handlers}') + provider_states = payload['providerStates'] + + for state in provider_states: + matching_state = state['name'] + if matching_state in states: + return states[matching_state] + raise HTTPException(status_code=500, detail='No matched handler.') + + +@app.post("/") +async def root(request: Request): + """Match states with provided message handlers.""" + payload = await request.json() + message = _match_states(payload) + return {'contents': message} + + +@app.get('/ping', status_code=status.HTTP_200_OK) +def ping(): + """Check whether the server is available before setting up states.""" + return {"ping": "pong"} + + +@app.post("/setup", status_code=status.HTTP_201_CREATED) +async def setup(request: Request): + """Endpoint to setup states. + + Use localstack to store payload. + """ + payload = await request.json() + items["states"] = payload + return items["states"] + + +def run_proxy(): + """Rub HTTP Proxy.""" + uvicorn.run("pact.http_proxy:app", port=PROXY_PORT, log_level=UVICORN_LOGGING_LEVEL) diff --git a/pact/message_pact.py b/pact/message_pact.py index a53e7f2429..7f67dc14f6 100644 --- a/pact/message_pact.py +++ b/pact/message_pact.py @@ -7,6 +7,7 @@ from .broker import Broker from .constants import MESSAGE_PATH +from .matchers import from_term class MessagePact(Broker): @@ -136,7 +137,7 @@ def with_content(self, contents): :rtype: Pact """ self._insert_message_if_complete() - self._messages[0]['contents'] = contents + self._messages[0]['contents'] = from_term(contents) return self def expects_to_receive(self, description): @@ -165,8 +166,8 @@ def write_to_pact_file(self): json.dumps(self._messages[0]), "--pact-dir", self.pact_dir, f"--pact-specification-version={self.version}", - "--consumer", f"{self.consumer.name}_message", - "--provider", f"{self.provider.name}_message", + "--consumer", f"{self.consumer.name}", + "--provider", f"{self.provider.name}", ] self._message_process = Popen(command) diff --git a/pact/message_provider.py b/pact/message_provider.py new file mode 100644 index 0000000000..d3da7ee572 --- /dev/null +++ b/pact/message_provider.py @@ -0,0 +1,138 @@ +"""Contract Message Provider.""" +import os +import time + +import requests +from requests.adapters import HTTPAdapter +from requests.packages.urllib3 import Retry +from multiprocessing import Process +from .verifier import Verifier +from .http_proxy import run_proxy + +import logging +logging.getLogger("urllib3").setLevel(logging.ERROR) + + +class MessageProvider(object): + """ + A Pact message provider. + + provider = MessageProvider( + message_providers = { + "a document created successfully": handler + }, + provider='DocumentService', + pact_dir='pacts', + version='3.0.0' + ) + """ + + def __init__( + self, + message_providers, + provider, + consumer, + pact_dir=os.getcwd(), + version="3.0.0", + proxy_host='localhost', + proxy_port='1234' + ): + """Create a Message Provider instance.""" + self.message_providers = message_providers + self.provider = provider + self.consumer = consumer + self.version = version + self.pact_dir = pact_dir + self.proxy_host = proxy_host + self.proxy_port = proxy_port + self._process = None + + def _proxy_url(self): + return f'http://{self.proxy_host}:{self.proxy_port}' + + def _pact_file(self): + return f'{self.consumer}-{self.provider}.json'.lower().replace(' ', '_') + + def _setup_states(self): + message_handlers = {} + for key, handler in self.message_providers.items(): + message_handlers[f'{key}'] = handler() + + resp = requests.post(f'{self._proxy_url()}/setup', + verify=False, + json={"messageHandlers": message_handlers}) + assert resp.status_code == 201, resp.text + return message_handlers + + def _wait_for_server_start(self): + """ + Wait for the Http Proxy to be ready. + + :rtype: None + :raises RuntimeError: If there is a problem starting the Http Proxy. + """ + s = requests.Session() + retries = Retry(total=9, backoff_factor=0.5) + http_mount = 'http://' + s.mount(http_mount, HTTPAdapter(max_retries=retries)) + resp = s.get(f'{self._proxy_url()}/ping', verify=False) + if resp.status_code != 200: + self._stop_proxy() + raise RuntimeError( + 'There was a problem starting the proxy: %s', resp.text + ) + + def _wait_for_server_stop(self): + """Wait for server to finish, or raise exception after timeout.""" + retry = 20 + while True: + self._process.terminate() + time.sleep(0.1) + try: + assert not self._process.is_alive() + return 0 + except AssertionError: + if retry == 0: + raise RuntimeError("Process timed out") + retry -= 1 + + def _start_proxy(self): + self._process = Process(target=run_proxy, args=(), daemon=True) + self._process.start() + self._wait_for_server_start() + self._setup_states() + + def _stop_proxy(self): + """Stop the Http Proxy.""" + if isinstance(self._process, Process): + self._wait_for_server_stop() + + def verify(self): + """Verify pact files with executable verifier.""" + pact_files = f'{self.pact_dir}/{self._pact_file()}' + verifier = Verifier(provider=self.provider, + provider_base_url=self._proxy_url()) + return_code, _ = verifier.verify_pacts(pact_files, verbose=False) + assert (return_code == 0), f'Expected returned_code = 0, actual = {return_code}' + + def __enter__(self): + """ + Enter a Python context. + + Sets up the Http Proxy. + """ + self._start_proxy() + + def __exit__(self, exc_type, exc_val, exc_tb): + """ + Exit a Python context. + + Return False to cascade the exception in context manager's body. + Otherwise it will be supressed and the test will always pass. + """ + if (exc_type, exc_val, exc_tb) != (None, None, None): + if exc_type is not None: + self._stop_proxy() + return False + self._stop_proxy() + return True diff --git a/requirements_dev.txt b/requirements_dev.txt index 0a22776312..7816f931ab 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,6 +1,6 @@ Click>=2.0.0 coverage==5.4 -Flask==1.0 +Flask==1.1.1 configparser==3.5.0 flake8==3.8.3 mock==3.0.5 diff --git a/setup.py b/setup.py index de556bfc83..075150b3b5 100644 --- a/setup.py +++ b/setup.py @@ -117,6 +117,8 @@ def read(filename): 'psutil>=2.0.0', 'requests>=2.5.0', 'six>=1.9.0', + 'fastapi==0.67.0', + 'uvicorn==0.14.0' ] if __name__ == '__main__': diff --git a/tests/test_http_proxy.py b/tests/test_http_proxy.py new file mode 100644 index 0000000000..36bac25a8f --- /dev/null +++ b/tests/test_http_proxy.py @@ -0,0 +1,88 @@ +from unittest import TestCase +from pact.http_proxy import app +from fastapi.testclient import TestClient +client = TestClient(app) + + +class HttpProxyTestCase(TestCase): + + def test_ping(self): + res = client.get('/ping') + self.assertEqual(res.status_code, 200) + assert res.json() == {"ping": "pong"} + + def test_handle_http_error(self): + res = client.get( + '/something_does_not_exist' + ) + self.assertEqual(res.status_code, 404) + json_res = res.json() + json_res['code'] = 404 + json_res['name'] = 'Not Found' + + def test_setup(self): + payload = {'anyPayload': 'really'} + res = client.post( + '/setup', + json=payload + ) + + self.assertEqual(res.status_code, 201) + json_res = res.json() + assert json_res == payload + + def setup_state(self, payload): + setup_res = client.post( + '/setup', + json=payload + ) + self.assertEqual(setup_res.status_code, 201) + + def test_home_should_return_expected_response(self): + message = { + 'event': 'ObjectCreated:Put', + 'bucket': 'bucket_name', + 'key': 'path_to_file_in_s3.pdf', + 'documentType': 'application/pdf' + } + + data = { + 'messageHandlers': { + 'A document created successfully': message + } + } + + self.setup_state(data) + + payload = { + 'providerStates': [{'name': 'A document created successfully'}] + } + + res = client.post( + '/', + json=payload + ) + + self.assertEqual(res.json(), {'contents': message}) + + def test_home_raise_runtime_error_if_no_matched(self): + data = { + 'messageHandlers': { + 'A document created successfully': { + 'event': 'ObjectCreated:Put' + } + } + } + self.setup_state(data) + payload = { + 'providerStates': [{'name': 'New state to raise RuntimeError'}] + } + res = client.post( + '/', + json=payload + ) + + self.assertEqual(res.status_code, 500) + assert res.json() == { + 'detail': 'No matched handler.' + } diff --git a/tests/test_message_pact.py b/tests/test_message_pact.py index ee0f0d0d67..3187465d04 100644 --- a/tests/test_message_pact.py +++ b/tests/test_message_pact.py @@ -125,6 +125,7 @@ def test_definition_without_given(self): target._messages[0]['metaData'], {'source': 'legacy_api'}) + class MessagePactContextManagerTestCase(MessagePactTestCase): def setUp(self): super(MessagePactContextManagerTestCase, self).setUp() @@ -194,6 +195,6 @@ def test_call_pact_message_to_generate_pact_file(self): json.dumps(target._messages[0]), '--pact-dir', '/pacts', '--pact-specification-version=3.0.0', - '--consumer', 'TestConsumer_message', - '--provider', 'TestProvider_message', + '--consumer', 'TestConsumer', + '--provider', 'TestProvider', ]) diff --git a/tests/test_message_provider.py b/tests/test_message_provider.py new file mode 100644 index 0000000000..dd38c06d30 --- /dev/null +++ b/tests/test_message_provider.py @@ -0,0 +1,148 @@ +import os +# import pytest +from mock import patch, Mock +from unittest import TestCase + +from pact.message_provider import MessageProvider +from pact import message_provider as message_provider + +class MessageProviderTestCase(TestCase): + def _mock_response( + self, + status=200, + content="fake response", + raise_for_status=None): + + mock_resp = Mock() + mock_resp.raise_for_status = Mock() + if raise_for_status: + mock_resp.raise_for_status.side_effect = raise_for_status + + mock_resp.status_code = status + mock_resp.text = content + return mock_resp + + def message_handler(self): + return {'success': True} + + def setUp(self): + self.provider = MessageProvider( + provider='DocumentService', + consumer='DetectContentLambda', + message_providers={ + 'a document created successfully': self.message_handler + } + ) + + def test_init(self): + self.assertIsInstance(self.provider, MessageProvider) + self.assertEqual(self.provider.provider, 'DocumentService') + self.assertEqual(self.provider.consumer, 'DetectContentLambda') + self.assertEqual(self.provider.pact_dir, os.getcwd()) + self.assertEqual(self.provider.version, '3.0.0') + self.assertEqual(self.provider.proxy_host, 'localhost') + self.assertEqual(self.provider.proxy_port, '1234') + + @patch('pact.Verifier.verify_pacts', return_value=(0, 'logs')) + def test_verify(self, mock_verify_pacts): + self.provider.verify() + + assert mock_verify_pacts.call_count == 1 + mock_verify_pacts.assert_called_with(f'{self.provider.pact_dir}/{self.provider._pact_file()}', verbose=False) + + +class MessageProviderContextManagerTestCase(MessageProviderTestCase): + def setUp(self): + super(MessageProviderContextManagerTestCase, self).setUp() + + @patch('pact.MessageProvider._start_proxy', return_value=0) + @patch('pact.MessageProvider._stop_proxy', return_value=0) + def test_successful(self, mock_stop_proxy, mock_start_proxy): + with self.provider: + pass + + mock_start_proxy.assert_called_once() + mock_stop_proxy.assert_called_once() + + @patch('pact.MessageProvider._wait_for_server_start', side_effect=RuntimeError('boom!')) + @patch('pact.MessageProvider._start_proxy', return_value=0) + @patch('pact.MessageProvider._stop_proxy', return_value=0) + def test_stop_proxy_on_runtime_error(self, mock_stop_proxy, mock_start_proxy, mock_wait_for_server_start,): + with self.provider: + pass + + mock_start_proxy.assert_called_once() + mock_stop_proxy.assert_called_once() + + +class StartProxyTestCase(MessageProviderTestCase): + def setUp(self): + super(StartProxyTestCase, self).setUp() + + +class StopProxyTestCase(MessageProviderTestCase): + def setUp(self): + super(StopProxyTestCase, self).setUp() + + @patch('requests.post') + def test_shutdown_successfully(self, mock_requests): + mock_requests.return_value = self._mock_response(content="success") + self.provider._stop_proxy() + + +class SetupStateTestCase(MessageProviderTestCase): + def setUp(self): + super(SetupStateTestCase, self).setUp() + + @patch('requests.post') + def test_shutdown_successfully(self, mock_requests): + mock_requests.return_value = self._mock_response(status=201) + self.provider._setup_states() + expected_payload = { + 'messageHandlers': { + 'a document created successfully': self.message_handler() + } + } + + mock_requests.assert_called_once_with(f'{self.provider._proxy_url()}/setup', verify=False, json=expected_payload) + + +class WaitForServerStartTestCase(MessageProviderTestCase): + def setUp(self): + super(WaitForServerStartTestCase, self).setUp() + + @patch.object(message_provider.requests, 'Session') + @patch.object(message_provider, 'Retry') + @patch.object(message_provider, 'HTTPAdapter') + @patch('pact.MessageProvider._stop_proxy') + def test_wait_for_server_start_success(self, mock_stop_proxy, mock_HTTPAdapter, mock_Retry, mock_Session): + mock_Session.return_value.get.return_value.status_code = 200 + self.provider._wait_for_server_start() + + session = mock_Session.return_value + session.mount.assert_called_once_with( + 'http://', mock_HTTPAdapter.return_value) + session.get.assert_called_once_with(f'{self.provider._proxy_url()}/ping', verify=False) + mock_HTTPAdapter.assert_called_once_with( + max_retries=mock_Retry.return_value) + mock_Retry.assert_called_once_with(total=9, backoff_factor=0.5) + mock_stop_proxy.assert_not_called() + + @patch.object(message_provider.requests, 'Session') + @patch.object(message_provider, 'Retry') + @patch.object(message_provider, 'HTTPAdapter') + @patch('pact.MessageProvider._stop_proxy') + def test_wait_for_server_start_failure(self, mock_stop_proxy, mock_HTTPAdapter, mock_Retry, mock_Session): + mock_Session.return_value.get.return_value.status_code = 500 + + with self.assertRaises(RuntimeError): + self.provider._wait_for_server_start() + + session = mock_Session.return_value + session.mount.assert_called_once_with( + 'http://', mock_HTTPAdapter.return_value) + session.get.assert_called_once_with(f'{self.provider._proxy_url()}/ping', verify=False) + mock_HTTPAdapter.assert_called_once_with( + max_retries=mock_Retry.return_value) + mock_Retry.assert_called_once_with(total=9, backoff_factor=0.5) + mock_stop_proxy.assert_called_once() diff --git a/tests/test_pact.py b/tests/test_pact.py index 76e3dd3468..b5405cceb3 100644 --- a/tests/test_pact.py +++ b/tests/test_pact.py @@ -486,7 +486,6 @@ def setUp(self): .will_respond_with(200, body='success')) self.get_verification_call = call( 'get', 'http://localhost:1234/interactions/verification', - allow_redirects=True, headers={'X-Pact-Mock-Service': 'true'}, verify=False, params=None)