From 51eb33884c26c45207c8d02b5dec28ca4209f1e8 Mon Sep 17 00:00:00 2001 From: Matthew Balvanz Date: Fri, 19 May 2017 22:24:06 -0500 Subject: [PATCH] Command line application for verifying pacts - Added `pact-verifier` which is a command line application that calls the Ruby verifier - It supports the same options as the JS version of Pact, including provider state and broker options - Removed all Docker related requirements --- Makefile | 41 ++++++--- README.md | 88 +++++++++--------- e2e/app.py | 2 +- e2e/docker-compose.yml | 28 ------ pact/constants.py | 11 +++ pact/test/test_constants.py | 17 ++++ pact/test/test_verify.py | 173 ++++++++++++++++++++++++++++++++++++ pact/verify.py | 108 ++++++++++++++++++++++ requirements.txt | 1 + setup.cfg | 2 - setup.py | 11 ++- tox.ini | 5 +- 12 files changed, 394 insertions(+), 93 deletions(-) delete mode 100644 e2e/docker-compose.yml create mode 100644 pact/test/test_verify.py create mode 100644 pact/verify.py diff --git a/Makefile b/Makefile index a5f9f1211..c39408e3f 100644 --- a/Makefile +++ b/Makefile @@ -34,17 +34,33 @@ deps: pip install -r requirements_dev.txt +define E2E + set -e + cd e2e + nosetests ./contracts + python app.py & + APP_PID=$$! + function teardown { + echo 'Tearing down Flask server' + kill $$APP_PID + } + trap teardown EXIT + while ! nc -z localhost 5000; do + sleep 0.1 + done + pact-verifier \ + --provider-base-url=http://localhost:5000 \ + --pact-urls=./pacts/consumer-provider.json \ + --provider-states-url=http://localhost:5000/_pact/provider-states \ + --provider-states-setup-url=http://localhost:5000/_pact/provider-states/active +endef + + +export E2E .PHONY: e2e e2e: - sh -c '\ - cd e2e; \ - docker-compose pull > /dev/null; \ - nosetests ./contracts; \ - docker-compose down; \ - docker-compose up -d app pactverifier; \ - docker-compose logs --follow >> ./pact/verifier-logs.txt & \ - docker-compose exec pactverifier bundle exec rake verify_pacts; \ - docker-compose down' + sh -c "$$E2E" + .PHONY: package package: pact/bin @@ -61,11 +77,8 @@ test: deps pact/bin @echo "Checking version consistency..." python -c "$$VERSION_CHECK" - @echo "flake8..." flake8 - - @echo "pydocstyle..." pydocstyle pact - - @echo "testing..." + coverage erase tox + coverage report --fail-under=100 diff --git a/README.md b/README.md index e823b2d1e..53c2d203e 100644 --- a/README.md +++ b/README.md @@ -225,59 +225,56 @@ services: > the command line to be `8080`, your tests would need to contact `localhost:8080`. ## Verifying Pacts Against a Service -> pact-python does not yet have any involvement in the process of verifying a contract against -> a provider. This section is included to provide insight into the full cycle of a -> contract for those getting started. -Like the mock service, the provider verifier can be run in two ways: +In addition to writing Pacts for Python consumers, you can also verify those Pacts +against a provider of any language. After installing pact-python a `pact-verifier` +application should be available. To get details about its use you can call it with the +help argument: -1. [Install and use it as a Ruby application][pact-provider-verifier] -2. Run it as a Docker container - -> Both choices have very similar configuration options. We will illustrate the Docker -> method below, but the Ruby method supports the same features. +```bash +pact-verifier --help +``` -When verifying your contracts, you may find it easier to run the provider application -and the verifier in separate Docker containers. This gives you a nice isolated -network, where you can set the DNS records of the services to anything you desire -and not have to worry about port conflicts with other services on your computer. -Launching the provider verifier in a `docker-compose.yml` might look like this: +The simplest example is verifying a server with locally stored Pact files and no provider +states: -```yaml -version: '2' -services: - app: - image: the-provider-application-to-test - - pactverifier: - command: ['tail', '-f', '/dev/null'] - image: dius/pact-provider-verifier-docker - depends_on: - - app - volumes: - - ./contracts:/tmp/pacts - environment: - - pact_urls=/tmp/pacts/consumer-provider.json - - provider_base_url=http://app - - provider_states_url=http://app/_pact/provider-states - - provider_states_active_url=http://app/_pact/provider-states/active +```bash +pact-verifier --provider-base-url=http://localhost:8080 --pact-urls=./pacts/consumer-provider.json ``` -In this example, our `app` container may take a few moments to start, so we don't -immediately start running the verification, and instead `tail -f /dev/null` which will keep -the container running forever. We can then use `docker-compose` to run the tests like so: +Which will immediately invoke the Pact verifier, making HTTP requests to the server located +at `http://localhost:8080` based on the Pacts in `./pacts/consumer-provider.json` and +reporting the results. -``` -docker-compose up -d -# Insert code to check that `app` has finished starting and is ready for requests -docker-compose exec pactverifier bundle exec rake verify_pacts -``` +There are several options for configuring how the Pacts are verified: + +###### --provider-base-url + +Required. Defines the URL of the server to make requests to when verifying the Pacts. + +###### --pact-urls + +Required. The location of the Pact files you want to verify. This can be a URL to a [Pact Broker] +or one or more local paths, separated by a comma. + +###### --provider-states-url + +The URL where your provider application will produce the list of available provider states. +The verifier calls this URL to ensure the Pacts specify valid states before making the HTTP +requests. + +###### --provider-states-setup-url + +The URL which should be called to setup a specific provider state before a Pact is verified. + +###### --pact-broker-username + +The username to use when contacting the Pact Broker. + +###### --pact-broker-password -You configure the verifier in Docker using 4 environment variables: -- `pact_urls` - a comma delimited list of pact file urls -- `provider_base_url` - the base url of the pact provider -- `provider_states_url` - the full url of the endpoint which returns provider states by consumer -- `provider_states_active_url` - the full url of the endpoint which sets the active pact consumer and provider state +The password to use when contacting the Pact Broker. You can also specify this value +as the environment variable `PACT_BROKER_PASSWORD`. ### Provider States In many cases, your contracts will need very specific data to exist on the provider @@ -310,6 +307,7 @@ End to end: `make e2e` [context manager]: https://en.wikibooks.org/wiki/Python_Programming/Context_Managers [Pact]: https://www.gitbook.com/book/pact-foundation/pact/details +[Pact Broker]: https://docs.pact.io/documentation/sharings_pacts.html [Pact documentation]: https://docs.pact.io/ [Pact Mock Service]: https://github.com/bethesque/pact-mock_service [Provider States]: https://docs.pact.io/documentation/provider_states.html diff --git a/e2e/app.py b/e2e/app.py index e3a0654e7..a91a1c0a7 100644 --- a/e2e/app.py +++ b/e2e/app.py @@ -51,4 +51,4 @@ def catch_all(path): if __name__ == '__main__': - app.run(host='0.0.0.0', port='80') + app.run(port='5000') diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml deleted file mode 100644 index 4275473fb..000000000 --- a/e2e/docker-compose.yml +++ /dev/null @@ -1,28 +0,0 @@ -version: '2' -services: - app: - command: ['-d'] - image: jazzdd/alpine-flask - volumes: - - ./app.py:/app/app.py - - pactverifier: - command: ['tail', '-f', '/dev/null'] - image: dius/pact-provider-verifier-docker - depends_on: - - app - volumes: - - ./pacts:/tmp/pacts - environment: - - pact_urls=/tmp/pacts/consumer-provider.json - - provider_base_url=http://app - - provider_states_url=http://app/_pact/provider-states - - provider_states_active_url=http://app/_pact/provider-states/active - - pactmockservice: - image: madkom/pact-mock-service - ports: - - "1234:1234" - volumes: - - ./pacts:/var/log/pacto - - ./pacts:/opt/contracts diff --git a/pact/constants.py b/pact/constants.py index 11c35f5a2..9310cac4a 100644 --- a/pact/constants.py +++ b/pact/constants.py @@ -11,5 +11,16 @@ def mock_service_exe(): return 'pact-mock-service' +def provider_verifier_exe(): + """Get the appropriate provider executable name for this platform.""" + if os.name == 'nt': + return 'pact-provider-verifier.bat' + else: + return 'pact-provider-verifier' + + MOCK_SERVICE_PATH = normpath(join( dirname(__file__), 'bin', 'mock-service', 'bin', mock_service_exe())) + +VERIFIER_PATH = normpath(join( + dirname(__file__), 'bin', 'verifier', 'bin', provider_verifier_exe())) diff --git a/pact/test/test_constants.py b/pact/test/test_constants.py index 699d823d1..f72ddd724 100644 --- a/pact/test/test_constants.py +++ b/pact/test/test_constants.py @@ -18,3 +18,20 @@ def test_other(self): def test_windows(self): self.mock_os.name = 'nt' self.assertEqual(constants.mock_service_exe(), 'pact-mock-service.bat') + + +class provider_verifier_exeTestCase(TestCase): + def setUp(self): + super(provider_verifier_exeTestCase, self).setUp() + self.addCleanup(patch.stopall) + self.mock_os = patch.object(constants, 'os', autospec=True).start() + + def test_other(self): + self.mock_os.name = 'posix' + self.assertEqual( + constants.provider_verifier_exe(), 'pact-provider-verifier') + + def test_windows(self): + self.mock_os.name = 'nt' + self.assertEqual( + constants.provider_verifier_exe(), 'pact-provider-verifier.bat') diff --git a/pact/test/test_verify.py b/pact/test/test_verify.py new file mode 100644 index 000000000..b30c51359 --- /dev/null +++ b/pact/test/test_verify.py @@ -0,0 +1,173 @@ +import os +import sys +from unittest import TestCase + +from click.testing import CliRunner +from mock import patch + +from .. import verify +from ..constants import VERIFIER_PATH + +if sys.version_info.major == 2: + from subprocess32 import PIPE, Popen, TimeoutExpired +else: + from subprocess import PIPE, Popen, TimeoutExpired + + +class mainTestCase(TestCase): + @classmethod + def setUpClass(cls): + # In Python 3 Click makes a call to locale to determine how the + # terminal wants to handle unicode. Because we mock Popen to avoid + # calling the real verifier, we need to get the actual result of + # locale to provide it to Click during the test run. + cls.locale = Popen( + ['locale', '-a'], stdout=PIPE, stderr=PIPE).communicate()[0] + + def setUp(self): + super(mainTestCase, self).setUp() + self.addCleanup(patch.stopall) + self.mock_Popen = patch.object( + verify.subprocess, 'Popen', spec=verify.subprocess.Popen).start() + self.mock_Popen.return_value.communicate.return_value = self.locale + + self.mock_isfile = patch.object( + verify, 'isfile', autospec=True).start() + + self.runner = CliRunner() + self.default_call = [ + '--provider-base-url=http://localhost', + '--pact-urls=./pacts/consumer-provider.json'] + + self.default_opts = [ + '--provider-base-url=http://localhost', + '--pact-urls=./pacts/consumer-provider.json'] + + def assertProcess(self, *expected): + self.assertEqual(self.mock_Popen.call_count, 1) + actual = self.mock_Popen.mock_calls[0][1][0] + self.assertEqual(actual[0], VERIFIER_PATH) + self.assertEqual(len(set(actual)), len(expected) + 1) + self.assertEqual(set(actual[1:]), set(expected)) + + def test_provider_base_url_is_required(self): + result = self.runner.invoke(verify.main, []) + self.assertEqual(result.exit_code, 2) + self.assertIn(b'--provider-base-url', result.output_bytes) + self.assertFalse(self.mock_Popen.called) + + def test_pact_urls_are_required(self): + result = self.runner.invoke( + verify.main, ['--provider-base-url=http://localhost']) + self.assertEqual(result.exit_code, 2) + self.assertIn(b'--pact-urls', result.output_bytes) + self.assertFalse(self.mock_Popen.called) + + def test_local_pact_urls_must_exist(self): + self.mock_isfile.return_value = False + result = self.runner.invoke(verify.main, self.default_opts) + self.assertEqual(result.exit_code, 1) + self.assertIn(b'./pacts/consumer-provider.json', result.output_bytes) + self.assertFalse(self.mock_Popen.called) + + def test_must_provide_both_provide_states_options(self): + result = self.runner.invoke(verify.main, [ + '--provider-base-url=http://localhost', + '--pact-urls=./pacts/consumer-provider.json', + '--provider-states-url=http://localhost/provider-state' + ]) + self.assertEqual(result.exit_code, 1) + self.assertIn(b'--provider-states-url', result.output_bytes) + self.assertIn(b'--provider-states-setup-url', result.output_bytes) + self.assertFalse(self.mock_Popen.called) + + def test_verification_timeout(self): + self.mock_Popen.return_value.communicate.side_effect = TimeoutExpired( + [], 30) + + result = self.runner.invoke(verify.main, self.default_opts) + self.assertEqual(result.exit_code, -1) + self.assertIsInstance(result.exception, TimeoutExpired) + self.assertProcess(*self.default_call) + self.mock_Popen.return_value.communicate.assert_called_once_with( + timeout=30) + + def test_failed_verification(self): + self.mock_Popen.return_value.returncode = 3 + result = self.runner.invoke(verify.main, self.default_opts) + self.assertEqual(result.exit_code, 3) + self.assertProcess(*self.default_call) + self.mock_Popen.return_value.communicate.assert_called_once_with( + timeout=30) + + def test_successful_verification(self): + self.mock_Popen.return_value.returncode = 0 + result = self.runner.invoke(verify.main, self.default_opts) + self.assertEqual(result.exit_code, 0) + self.assertProcess(*self.default_call) + self.mock_Popen.return_value.communicate.assert_called_once_with( + timeout=30) + + @patch.dict(os.environ, {'PACT_BROKER_PASSWORD': 'pwd'}) + def test_password_from_env_var(self): + self.mock_Popen.return_value.returncode = 0 + result = self.runner.invoke(verify.main, self.default_opts) + self.assertEqual(result.exit_code, 0) + self.assertProcess(*self.default_call + ['--pact-broker-password=pwd']) + self.mock_Popen.return_value.communicate.assert_called_once_with( + timeout=30) + + def test_all_options(self): + self.mock_Popen.return_value.returncode = 0 + result = self.runner.invoke(verify.main, [ + '--provider-base-url=http://localhost', + '--pact-urls=./pacts/consumer-provider.json', + '--provider-states-url=http=//localhost/provider-states', + '--provider-states-setup-url=http://localhost/provider-states/set', + '--pact-broker-username=user', + '--pact-broker-password=pass', + '--timeout=60' + ]) + self.assertEqual(result.exit_code, 0) + self.assertEqual(self.mock_Popen.call_count, 1) + self.assertProcess( + '--provider-base-url=http://localhost', + '--pact-urls=./pacts/consumer-provider.json', + '--provider-states-url=http=//localhost/provider-states', + '--provider-states-setup-url=http://localhost/provider-states/set', + '--pact-broker-username=user', + '--pact-broker-password=pass') + self.mock_Popen.return_value.communicate.assert_called_once_with( + timeout=60) + + +class path_existsTestCase(TestCase): + def setUp(self): + super(path_existsTestCase, self).setUp() + self.addCleanup(patch.stopall) + self.mock_isfile = patch.object( + verify, 'isfile', autospec=True).start() + + def test_http(self): + result = verify.path_exists('http://localhost') + self.assertIs(result, True) + self.assertFalse(self.mock_isfile.called) + + def test_https(self): + result = verify.path_exists('https://example.com') + self.assertIs(result, True) + self.assertFalse(self.mock_isfile.called) + + def test_file_does_exist(self): + self.mock_isfile.return_value = True + result = verify.path_exists('./pacts/consumer-provider.json') + self.assertIs(result, True) + self.mock_isfile.assert_called_once_with( + './pacts/consumer-provider.json') + + def test_file_does_not_exist(self): + self.mock_isfile.return_value = False + result = verify.path_exists('./pacts/consumer-provider.json') + self.assertIs(result, False) + self.mock_isfile.assert_called_once_with( + './pacts/consumer-provider.json') diff --git a/pact/verify.py b/pact/verify.py new file mode 100644 index 000000000..ce33b2cf2 --- /dev/null +++ b/pact/verify.py @@ -0,0 +1,108 @@ +"""Methods to verify previously created pacts.""" +import sys +from os.path import isfile + +import click + +from .constants import VERIFIER_PATH + +if sys.version_info.major == 2: + import subprocess32 as subprocess +else: + import subprocess + + +@click.command() +@click.option( + 'base_url', '--provider-base-url', + help='Base URL of the provider to verify against.', + required=True) +@click.option( + 'pact_urls', '--pact-urls', + help='The URI of the pact to verify.' + ' Can be an HTTP URI or a local file path.' + ' It can be specified multiple times to verify several pacts.', + multiple=True, + required=True) +@click.option( + 'states_url', '--provider-states-url', + help='URL to fetch the provider states for the given provider API.') +@click.option( + 'states_setup_url', '--provider-states-setup-url', + help='URL to send PUT requests to setup a given provider state.') +@click.option( + 'username', '--pact-broker-username', + help='Username for Pact Broker basic authentication.') +@click.option( + 'password', '--pact-broker-password', + envvar='PACT_BROKER_PASSWORD', + help='Password for Pact Broker basic authentication. Can also be specified' + ' via the environment variable PACT_BROKER_PASSWORD') +@click.option( + 'timeout', '-t', '--timeout', + default=30, + help='The duration in seconds we should wait to confirm verification' + ' process was successful. Defaults to 30.', + type=int) +def main(base_url, pact_urls, states_url, states_setup_url, username, + password, timeout): + """ + Verify one or more contracts against a provider service. + + Minimal example: + + pact-verifier --provider-base-url=http://localhost:8080 --pact-urls=./pacts + """ # NOQA + error = click.style('Error:', fg='red') + if bool(states_url) != bool(states_setup_url): + click.echo( + error + + ' To use provider states you must provide both' + ' --provider-states-url and --provider-states-setup-url.') + raise click.Abort() + + missing_files = [path for path in pact_urls if not path_exists(path)] + if missing_files: + click.echo( + error + + ' The following Pact files could not be found:\n' + + '\n'.join(missing_files)) + raise click.Abort() + + options = { + '--provider-base-url': base_url, + '--pact-urls': ','.join(pact_urls), + '--provider-states-url': states_url, + '--provider-states-setup-url': states_setup_url, + '--pact-broker-username': username, + '--pact-broker-password': password + } + + command = [VERIFIER_PATH] + [ + '{}={}'.format(k, v) for k, v in options.items() if v] + + p = subprocess.Popen(command) + p.communicate(timeout=timeout) + sys.exit(p.returncode) + + +def path_exists(path): + """ + Determine if a particular path exists. + + Can be provided a URL or local path. URLs always result in a True. Local + paths are True only if a file exists at that location. + + :param path: The path to check. + :type path: str + :return: True if the path exists and is a file, otherwise False. + :rtype: bool + """ + if path.startswith('http://') or path.startswith('https://'): + return True + + return isfile(path) + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/requirements.txt b/requirements.txt index 040a6618e..44c7cfae5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ +click==6.7 requests==2.12.3 six==1.10.0 diff --git a/setup.cfg b/setup.cfg index 994d6202b..017aafc3c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -10,8 +10,6 @@ max-line-length=79 [nosetests] with-coverage=true cover-package=pact -cover-min-percentage=100 -cover-erase=true cover-branches=true with-xunit=true xunit-file=nosetests.xml diff --git a/setup.py b/setup.py index 3206c29e0..ad2661ee7 100644 --- a/setup.py +++ b/setup.py @@ -81,8 +81,11 @@ def read(filename): return f.read().decode('utf-8') -dependencies = [ - dep.strip() for dep in read('requirements.txt').split('\n') if dep.strip()] +dependencies = read('requirements.txt').split() + +if sys.version_info.major == 2: + dependencies.append('subprocess32') + setup_args = dict( cmdclass={'install': PactPythonInstallCommand}, name='pact-python', @@ -93,6 +96,10 @@ def read(filename): author='Matthew Balvanz', author_email='matthew.balvanz@workiva.com', url='https://github.com/pact-foundation/pact-python', + entry_points=''' + [console_scripts] + pact-verifier=pact.verify:main + ''', install_requires=dependencies, packages=['pact'], package_data={'pact': ['bin/*']}, diff --git a/tox.ini b/tox.ini index 8047c9f5d..edf82af39 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,8 @@ [tox] envlist=py27,py33,py34,py35,py36 +skipsdist=True [testenv] -deps=-rrequirements_dev.txt +deps= + -rrequirements_dev.txt + py27: subprocess32 commands=nosetests