diff --git a/.cirrus.yml b/.cirrus.yml index 0ad7b2988..7e50abef5 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -2,50 +2,56 @@ BUILD_TEST_TASK_TEMPLATE: &BUILD_TEST_TASK_TEMPLATE arch_check_script: - uname -am test_script: + - script/install_plugins.sh - python --version - python -m pip install --upgrade pip - python -m pip install -r requirements_dev.txt - - python -m flake8 + - python -m flake8 --exclude '*pb2*',.git,__pycache__,build,dist,.tox --show-source - python -m pydocstyle pact - python -m tox -e test - # - make examples + - make todo -linux_arm64_task: +env: + RUN_BROKER: 0 + USE_HOSTED_PACT_BROKER: 1 + USE_STANDALONE: 1 + +PYTHON_VERSION_MATRIX: &PYTHON_VERSION_MATRIX + - VERSION: 3.11 + # - VERSION: 3.6 + # - VERSION: 3.7 + # - VERSION: 3.8 + # - VERSION: 3.9 + # - VERSION: 3.10 + +linux_arm64_task: + only_if: $CIRRUS_CHANGE_TITLE !=~ 'ci\(gha\).*' env: + # PACT_FFI_PATH: .tox/test/lib/python$VERSION/site-packages/pact/bin + LANG: C.UTF-8 # required for python 3.6 docker image matrix: - # - IMAGE: python:3.6-slim # This works locally, with cirrus run, but fails in CI - - IMAGE: python:3.7-slim - - IMAGE: python:3.8-slim - - IMAGE: python:3.9-slim - - IMAGE: python:3.10-slim + <<: *PYTHON_VERSION_MATRIX arm_container: - image: $IMAGE + image: python:$VERSION-slim install_script: - - apt update --yes && apt install --yes gcc make + - apt update --yes && apt install --yes gcc make curl << : *BUILD_TEST_TASK_TEMPLATE - -macosx_arm64_task: +macos_arm64_task: + only_if: $CIRRUS_CHANGE_TITLE !=~ 'ci\(gha\).*' macos_instance: image: ghcr.io/cirruslabs/macos-ventura-base:latest env: PATH: ${HOME}/.pyenv/shims:${PATH} + # PACT_FFI_PATH: .tox/test/lib/python$VERSION/site-packages/pact/bin matrix: - - PYTHON: 3.6 - - PYTHON: 3.7 - - PYTHON: 3.8 - - PYTHON: 3.9 - - PYTHON: 3.10 + <<: *PYTHON_VERSION_MATRIX install_script: - # Per the pyenv homebrew recommendations. - # https://github.com/pyenv/pyenv/wiki#suggested-build-environment - # - xcode-select --install # Unnecessary on Cirrus - brew update - # - brew install openssl readline sqlite3 xz zlib - - brew install pyenv - - pyenv install ${PYTHON} - - pyenv global ${PYTHON} + - brew install pyenv protobuf shared-mime-info # protobuf only needed if using grpc plugins + - pyenv install ${VERSION} + - pyenv global ${VERSION} - pyenv rehash - ## To install rosetta - # - softwareupdate --install-rosetta --agree-to-license + - chmod +x script/install_plugins.sh + - find examples -name '*.sh' -exec chmod +x {} \; << : *BUILD_TEST_TASK_TEMPLATE diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index 26e8651ed..c96a7715f 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -14,18 +14,26 @@ jobs: matrix: python-version: - - '3.7' - - '3.8' - - '3.9' - - '3.10' + # - '3.7' + # - '3.8' + # - '3.9' + # - '3.10' - '3.11' - os: [ ubuntu-latest, windows-latest, macos-latest ] + os: [ + ubuntu-latest, + windows-latest, + macos-latest + ] # These versions are no longer supported by Python team, and may # eventually be dropped from GitHub Actions. - include: - - python-version: '3.6' - os: ubuntu-20.04 + # include: + # - python-version: '3.6' + # os: ubuntu-20.04 + # - python-version: '3.6' + # os: windows-latest + # - python-version: '3.6' + # os: macos-latest steps: - name: Check out code @@ -43,12 +51,29 @@ jobs: - name: Lint with flake8, pydocstyle run: | - flake8 + flake8 --show-source --exclude '*pb2*',.git,__pycache__,build,dist,.tox pydocstyle pact + - name: Install Pact plugins for tests + run: script/install_plugins.sh + shell: bash + - name: Test with pytest run: tox -e test + # env: + # PACT_FFI_PATH: .tox/test/lib/python${{ matrix.python-version }}/site-packages/pact/bin - name: Test examples if: runner.os == 'Linux' - run: make examples + run: make todo + + - name: Test examples + # no docker so we use a hosted pact broker and pact-ruby-standalone for publishing + if: runner.os != 'Linux' + run: make todo + env: + RUN_BROKER: 0 + USE_HOSTED_PACT_BROKER: 1 + USE_STANDALONE: 1 + PACT_LOG_LEVEL: DEBUG + PACT_LOG_OUTPUT: STDOUT diff --git a/.gitignore b/.gitignore index ba0d76014..c972e0780 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ # pact-python specific ignores e2e/pacts -userserviceclient-userservice.json -detectcontentlambda-contentprovider.json +# userserviceclient-userservice.json # unexcluded for cirrus ci locally +# detectcontentlambda-contentprovider.json # unexcluded for cirrus ci locally pact/bin # Byte-compiled / optimized / DLL files @@ -10,7 +10,7 @@ __pycache__/ *$py.class # C extensions -*.so +# *.so # Distribution / packaging .Python @@ -91,6 +91,7 @@ celerybeat-schedule venv/ .venv/ ENV/ +.venv/ # Spyder project settings .spyderproject @@ -105,3 +106,7 @@ ENV/ .noseids +# MacOS stuff +.DS_Store +# Generated pact directories +# /pacts/ diff --git a/MANIFEST b/MANIFEST index c8f174abe..843181427 100644 --- a/MANIFEST +++ b/MANIFEST @@ -18,14 +18,21 @@ pact/message_consumer.py pact/message_pact.py pact/message_provider.py pact/pact.py +pact/pact_exception.py pact/provider.py pact/verifier.py pact/verify_wrapper.py +pact/bin/libpact_ffi-linux-aarch64.so.gz +pact/bin/libpact_ffi-linux-x86_64.so.gz +pact/bin/libpact_ffi-osx-aarch64-apple-darwin.dylib.gz +pact/bin/libpact_ffi-osx-x86_64.dylib.gz pact/bin/pact-2.0.3-linux-arm64.tar.gz pact/bin/pact-2.0.3-linux-x86_64.tar.gz pact/bin/pact-2.0.3-osx-arm64.tar.gz pact/bin/pact-2.0.3-osx-x86_64.tar.gz pact/bin/pact-2.0.3-windows-x86.zip pact/bin/pact-2.0.3-windows-x86_64.zip +pact/bin/pact.h +pact/bin/pact_ffi-windows-x86_64.dll.gz pact/cli/__init__.py pact/cli/verify.py diff --git a/Makefile b/Makefile index 10f39ede5..c152cca56 100644 --- a/Makefile +++ b/Makefile @@ -16,6 +16,8 @@ help: @echo " fastapi to run the example FastApi provider tests" @echo " flask to run the example Flask provider tests" @echo " messaging to run the example messaging e2e tests" + @echo " grpc to run the example grpc e2e tests" + @echo " todo to run the example todo tests" @echo " package to create a distribution package in /dist/" @echo " release to perform a release build, including deps, test, and package targets" @echo " test to run all tests" @@ -78,6 +80,24 @@ define MESSAGING endef export MESSAGING +define GRPC + echo "grpc make" + cd examples/grpc + pip install -q -r requirements.txt + pip install -e ../../ + ./run_pytest.sh +endef +export GRPC + +define TODO + echo "todo make" + cd examples/todo + pip install -r requirements.txt + pip install -e ../../ + ./run_pytest.sh +endef +export TODO + .PHONY: consumer consumer: @@ -98,9 +118,18 @@ fastapi: messaging: bash -c "$$MESSAGING" +.PHONY: grpc +grpc: + bash -c "$$GRPC" + +.PHONY: todo +todo: + bash -c "$$TODO" + .PHONY: examples -examples: consumer flask fastapi messaging +examples: consumer flask fastapi messaging grpc todo +# examples: consumer flask fastapi messaging todo .PHONY: package @@ -110,7 +139,7 @@ package: .PHONY: test test: deps - flake8 + flake8 --exclude examples/area_calculator/area_calculator_pb2.py,examples/area_calculator/area_calculator_pb2_grpc.py pydocstyle pact coverage erase tox diff --git a/README.md b/README.md index 230d6fd9d..b95dff97b 100644 --- a/README.md +++ b/README.md @@ -17,17 +17,19 @@ Note: As of Version 1.0 deprecates support for python 2.7 to allow us to incorpo # How to use pact-python ## Installation + ``` pip install pact-python ``` ## Getting started + A guide follows but if you go to the [examples](https://github.com/pact-foundation/pact-python/tree/master/examples). This has a consumer, provider and pact-broker set of tests for both FastAPI and Flask. ## Writing a Pact -Creating a complete contract is a two step process: +Creating a complete contract is a two-step process: 1. Create a test on the consumer side that declares the expectations it has of the provider 2. Create a provider state that allows the contract to pass when replayed against the provider @@ -82,7 +84,6 @@ class GetUserInfoContract(unittest.TestCase): result = user('UserA') self.assertEqual(result, expected) - ``` This does a few important things: @@ -164,11 +165,13 @@ The mock service offers you several important features when building your contra - Finally, it will record your contracts as a JSON file that you can store in your repository or publish to a Pact broker. ## Expecting Variable Content + The above test works great if that user information is always static, but what happens if the user has a last updated field that is set to the current time every time the object is modified? To handle variable data and make your tests more robust, there are 3 helpful matchers: ### Term(matcher, generate) + Asserts the value should match the given regular expression. You could use this to expect a timestamp with a particular format in the request or response where you know you need a particular format, but are unconcerned about the exact date: @@ -194,6 +197,7 @@ provider, the regex will be used to search the response from the real provider s and the test will be considered successful if the regex finds a match in the response. ### Like(matcher) + Asserts the element's type matches the matcher. For example: ```python @@ -202,6 +206,7 @@ Like(123) # Matches if the value is an integer Like('hello world') # Matches if the value is a string Like(3.14) # Matches if the value is a float ``` + The argument supplied to `Like` will be what the mock service responds with. When a dictionary is used as an argument for Like, all the child objects (and their child objects etc.) will be matched according to their types, unless you use a more specific matcher like a Term. @@ -453,7 +458,8 @@ The provider application version. Required for publishing verification results. Publish verification results to the broker. ### Python API -You can use the Verifier class. This allows you to write native python code and the test framework of your choice. + +You can use the Verifier class. This has all the same parameters as the cli tool but allows you to write native python code and the test framework of your choice. ```python verifier = Verifier(provider='UserService', @@ -507,6 +513,7 @@ You can see more details in the examples - [FastAPI Provider Verifier Test](https://github.com/pact-foundation/pact-python/tree/master/examples/fastapi_provider/tests/provider/test_provider.py) ### Provider States + In many cases, your contracts will need very specific data to exist on the provider to pass successfully. If you are fetching a user profile, that user needs to exist, if querying a list of records, one or more records needs to exist. To support @@ -525,18 +532,20 @@ on the provider application or a separate one. Some strategies for managing stat For more information about provider states, refer to the [Pact documentation] on [Provider States]. # Development + Please read [CONTRIBUTING.md](https://github.com/pact-foundation/pact-python/blob/master/CONTRIBUTING.md) To setup a development environment: -1. If you want to run tests for all Python versions, install 2.7, 3.3, 3.4, 3.5, and 3.6 from source or using a tool like [pyenv] -2. Its recommended to create a Python [virtualenv] for the project +1. If you want to run tests for all Python versions, install 3.6, 3.7, 3.8, and 3.9 from source or using a tool like [pyenv] +2. It's recommended to create a Python [virtualenv] for the project. +3. We are now using FFI bindings. For mac you might want to read these [setup FFI](https://cffi.readthedocs.io/en/latest/installation.html) To setup the environment, run tests, and package the application, run: `make release` -If you are just interested in packaging pact-python so you can install it using pip: +If you are just interested in packaging pact-python you can install it using pip: `make package` diff --git a/UPGRADE_FFI.md b/UPGRADE_FFI.md new file mode 100644 index 000000000..c4cffef9a --- /dev/null +++ b/UPGRADE_FFI.md @@ -0,0 +1,163 @@ +# FFI Upgrade + +## Verifier + +Replace `from pact import Verifier` +with `from pact import VerifierV3` + +Replace `verify` or `verify_with_broker` +with `verify_pacts` + +### Pact Sources + +Pact-Python can now retrieve pacts from + +- File Source +- Directory Source +- Url Source +- Broker Source + +It is possible to set all 4 sources, and 4 verification tasks will be performed. + +Publishing results will only be performed for Url and Broker source, if `publish_verification_results=True` + +#### User Provided Sources + +For the following + +- File Source +- Directory Source +- Url Source + +* You should provide them in the `sources` argument and not set the `broker_url` + +```python + verifier = VerifierV3(provider="area-calculator-provider", + provider_base_url="tcp://127.0.0.1:37757", + ) + result = verifier.verify_pacts( + sources=["./examples/pacts/v4-grpc.json"], + ) +``` + +- You can provide multiple sources in the `sources` argument +- You can provide auth credentials, if a pact broker requires it + +```python + verifier = VerifierV3(provider="area-calculator-provider", + provider_base_url="tcp://127.0.0.1:37757", + ) + result = verifier.verify_pacts( + sources=[ + "./examples/pacts/v3-http.json", + "./examples/pacts/v4-grpc.json", + 'https://test.pactbroker.io/pacts/provider/Example%20API/consumer/Example%20App/latest' + ], + broker_username="username", + broker_password="password", + ) +``` + +#### Dynamically Fetched Sources + +- You can dynamically fetch pacts from the broker. +- You should not provide anything in the `sources` argument and set the `broker_url` + +- The following will retrieve the latest pacts for the named `provider` + +```python + verifier = VerifierV3(provider="area-calculator-provider", + provider_base_url="tcp://127.0.0.1:37757", + ) + result = verifier.verify_pacts( + broker_url="https://test.pactbroker.io", + broker_username="username", + broker_password="password", + ) +``` + +- You can dynamically fetch pacts from the broker. + - Provide dynamic fetching arguments + - `consumer_version_selectors` + - `consumer_version_tags` + +```python + verifier = VerifierV3(provider="area-calculator-provider", + provider_base_url="tcp://127.0.0.1:37757", + ) + result = verifier.verify_pacts( + broker_url="https://test.pactbroker.io", + broker_username="username", + broker_password="password", + ) +``` + +For different schemes or base paths for your provider, use `provider_base_url` + +- grpc `provider_base_url="tcp://127.0.0.1:37757"` +- http without a port `provider_base_url="http://127.0.0.1"` (defaults to `8080`) +- provide a base path `provider_base_url="http://127.0.0.1/api/internal"` (defaults to `/`) + +## Message Provider + +Default `proxy_host` now changes from `localhost` to `127.0.0.1` + +- `localhost` appears to work for macos/linux but not windows. + +Now utilises `VerifierV3` rather than the old ruby implementation. + +No changes to interface. + +Ideally should + +- change to utilise all options of `VerifierV3` +- not hardcode `pact_dir` (use `sources` instead) +- replace `verify` & `verify_with_broker` with `verify_pacts` + +## Consumer + +WIP - Need to build out consumer interface + +Rough examples in + +`./tests/ffi/test_ffi_grpc_consumer.py` +`./tests/ffi/test_ffi_http_consumer.py` +`./tests/ffi/test_ffi_message_consumer.py` + +### PactV3 Consumer + +New imports and matchers/generators + +```python +from pact import PactV3 +from pact.matchers_v3 import Like, Regex, Format +``` + +- lifecyle changes + - start_provider moves to inside test, after interaction setup + - stop_provider no longer called + - verify moves to inside test, after client has issued request + - can be pre or post unit test assertions. + +## Message Consumer + +Not started + +## Logging + +By default, pact-python will log to a buffer, at INFO level + +you can set + +- PACT_LOG_LEVEL + - NONE | ERROR | INFO | DEBUG | TRACE +- PACT_LOG_OUTPUT + - STDOUT | FILE | BUFFER +- PACT_LOG_FILE + - Location to logfile if PACT_LOG_OUTPUT=FILE (default `./log/pact.log`) + +examples + +`PACT_LOG_LEVEL=TRACE PACT_LOG_OUTPUT=STDOUT pytest tests/test_todo_consumer.py -rP` +`PACT_LOG_LEVEL=INFO PACT_LOG_OUTPUT=FILE pytest tests/test_todo_consumer.py -rP` +`PACT_LOG_LEVEL=DEBUG PACT_LOG_OUTPUT=BUFFER pytest tests/test_todo_consumer.py -rP` diff --git a/examples/common/sharedfixtures.py b/examples/common/sharedfixtures.py index 4cdece923..0690cd44c 100644 --- a/examples/common/sharedfixtures.py +++ b/examples/common/sharedfixtures.py @@ -1,5 +1,8 @@ +from os.path import join, dirname +from os import getenv import platform import pathlib +import subprocess import docker import pytest @@ -58,30 +61,64 @@ def publish_existing_pact(broker): :revision_number=>nil, :consumer_version_number=>"1", :pact_version_sha=>nil, \ :consumer_name_in_pact=>"UserServiceClient", :provider_name_in_pact=>"UserService"} """ + if int(getenv('SKIP_PUBLISH', '1')) == 0: + return + source = str(pathlib.Path.cwd().joinpath("..", "pacts").resolve()) pacts = [f"{source}:/pacts"] - envs = { - "PACT_BROKER_BASE_URL": "http://broker_app:9292", - "PACT_BROKER_USERNAME": "pactbroker", - "PACT_BROKER_PASSWORD": "pactbroker", - } + + use_hosted_pact_broker = int(getenv('USE_HOSTED_PACT_BROKER', '0')) + if use_hosted_pact_broker == 1: + envs = { + "PACT_BROKER_BASE_URL": "https://test.pactflow.io", + "PACT_BROKER_USERNAME": "dXfltyFMgNOFZAxr8io9wJ37iUpY42M", + "PACT_BROKER_PASSWORD": "O5AIZWxelWbLvqMd8PkAVycBJh2Psyg1", + } + else: + envs = { + "PACT_BROKER_BASE_URL": "http://broker_app:9292", + "PACT_BROKER_USERNAME": "pactbroker", + "PACT_BROKER_PASSWORD": "pactbroker", + } target_platform = platform.platform().lower() - if 'macos' in target_platform or 'windows' in target_platform: + if ('macos' in target_platform or 'windows' in target_platform) and use_hosted_pact_broker != 1: envs["PACT_BROKER_BASE_URL"] = "http://host.docker.internal:80" - client = docker.from_env() - print("Publishing existing Pact") - client.containers.run( - remove=True, - network="broker_default", - volumes=pacts, - image="pactfoundation/pact-cli:latest-multi", - environment=envs, - command="publish /pacts --consumer-app-version 1", - ) + use_standalone = int(getenv('USE_STANDALONE', '0')) + if use_standalone == 1: + executable = 'pact-broker' + if 'windows' in target_platform: + executable = executable + ".bat" + + if use_hosted_pact_broker != 1: + envs["PACT_BROKER_BASE_URL"] = "http://localhost" + result = subprocess.run([join(dirname(__file__), '..', '..', 'pact', 'bin', 'pact', 'bin', executable), + 'publish', + join('..', 'pacts'), + '--consumer-app-version', '1', + '--broker-base-url', envs["PACT_BROKER_BASE_URL"], + '--broker-username', envs["PACT_BROKER_USERNAME"], + '--broker-password', envs["PACT_BROKER_PASSWORD"] + ], + ) + print(result.stdout) + print(result.stderr) + + else: + client = docker.from_env() + + client.containers.run( + remove=True, + network="broker_default" if use_hosted_pact_broker == 0 else None, + volumes=pacts, + image="pactfoundation/pact-cli:latest-multi", + environment=envs, + command="publish /pacts --consumer-app-version 1", + ) + print("Finished publishing") diff --git a/examples/consumer/run_pytest.sh b/examples/consumer/run_pytest.sh index f997e1583..0bd899386 100755 --- a/examples/consumer/run_pytest.sh +++ b/examples/consumer/run_pytest.sh @@ -1,4 +1,8 @@ #!/bin/bash set -o pipefail -pytest tests --run-broker True --publish-pact 1 +if [ "$RUN_BROKER" = '0' ]; then + pytest tests --publish-pact 1 -rP +else + pytest tests --run-broker True --publish-pact 1 +fi \ No newline at end of file diff --git a/examples/consumer/tests/consumer/test_user_consumer.py b/examples/consumer/tests/consumer/test_user_consumer.py index 72ebaf589..62ad53d9d 100644 --- a/examples/consumer/tests/consumer/test_user_consumer.py +++ b/examples/consumer/tests/consumer/test_user_consumer.py @@ -1,12 +1,15 @@ """pact test for user service client""" -import atexit +# import atexit import logging import os import pytest -from pact import Consumer, Like, Provider, Term, Format +# from pact import Consumer, Like, Provider, Term, Format +# from pact.matchers import Format +from pact import PactV3 +from pact.matchers_v3 import Like, Regex, Format from src.consumer import UserConsumer log = logging.getLogger(__name__) @@ -15,9 +18,15 @@ # If publishing the Pact(s), they will be submitted to the Pact Broker here. # For the purposes of this example, the broker is started up as a fixture defined # in conftest.py. For normal usage this would be self-hosted or using PactFlow. -PACT_BROKER_URL = "http://localhost" -PACT_BROKER_USERNAME = "pactbroker" -PACT_BROKER_PASSWORD = "pactbroker" +use_pactflow = int(os.getenv('USE_HOSTED_PACT_BROKER', '0')) +if use_pactflow == 1: + PACT_BROKER_URL = os.getenv("PACT_BROKER_URL", "https://test.pactflow.io") + PACT_BROKER_USERNAME = os.getenv("PACT_BROKER_USERNAME", "dXfltyFMgNOFZAxr8io9wJ37iUpY42M") + PACT_BROKER_PASSWORD = os.getenv("PACT_BROKER_PASSWORD", "O5AIZWxelWbLvqMd8PkAVycBJh2Psyg1") +else: + PACT_BROKER_URL = os.getenv("PACT_BROKER_URL", "http://localhost") + PACT_BROKER_USERNAME = os.getenv("PACT_BROKER_USERNAME", "pactbroker") + PACT_BROKER_PASSWORD = os.getenv("PACT_BROKER_PASSWORD", "pactbroker") # Define where to run the mock server, for the consumer to connect to. These # are the defaults so may be omitted @@ -41,45 +50,53 @@ def pact(request): # When publishing a Pact to the Pact Broker, a version number of the Consumer # is required, to be able to construct the compatability matrix between the # Consumer versions and Provider versions - version = request.config.getoption("--publish-pact") - publish = True if version else False - - pact = Consumer("UserServiceClient", version=version).has_pact_with( - Provider("UserService"), - host_name=PACT_MOCK_HOST, - port=PACT_MOCK_PORT, - pact_dir=PACT_DIR, - publish_to_broker=publish, - broker_base_url=PACT_BROKER_URL, - broker_username=PACT_BROKER_USERNAME, - broker_password=PACT_BROKER_PASSWORD, - ) - - pact.start_service() + # version = request.config.getoption("--publish-pact") + # publish = True if version else False + + pact = PactV3('UserServiceClient', 'UserService', + hostname=PACT_MOCK_HOST, + port=PACT_MOCK_PORT, + pact_dir=PACT_DIR, + # version=version + ) + return pact + + # pact = Consumer("UserServiceClient", version=version).has_pact_with( + # Provider("UserService"), + # host_name=PACT_MOCK_HOST, + # port=PACT_MOCK_PORT, + # pact_dir=PACT_DIR, + # publish_to_broker=publish, + # broker_base_url=PACT_BROKER_URL, + # broker_username=PACT_BROKER_USERNAME, + # broker_password=PACT_BROKER_PASSWORD, + # ) + + # pact.start_service() # Make sure the Pact mocked provider is stopped when we finish, otherwise # port 1234 may become blocked - atexit.register(pact.stop_service) + # atexit.register(pact.stop_service) - yield pact + # yield pact # This will stop the Pact mock server, and if publish is True, submit Pacts # to the Pact Broker - pact.stop_service() + # pact.stop_service() # Given we have cleanly stopped the service, we do not want to re-submit the # Pacts to the Pact Broker again atexit, since the Broker may no longer be # available if it has been started using the --run-broker option, as it will # have been torn down at that point - pact.publish_to_broker = False + # pact.publish_to_broker = False -def test_get_user_non_admin(pact, consumer): +def test_get_user_non_admin(pact: PactV3, consumer): # Define the Matcher; the expected structure and content of the response expected = { "name": "UserA", "id": Format().uuid, - "created_on": Term(r"\d+-\d+-\d+T\d+:\d+:\d+", "2016-12-15T20:16:01"), + "created_on": Regex(r"\d+-\d+-\d+T\d+:\d+:\d+", "2016-12-15T20:16:01"), "ip_address": Format().ip_address, "admin": False, } @@ -90,13 +107,17 @@ def test_get_user_non_admin(pact, consumer): # return the EXACT content where defined, e.g. UserA for name, and SOME # appropriate content e.g. for ip_address. ( - pact.given("UserA exists and is not an administrator") + pact + .new_http_interaction('a request for UserA') + .given("UserA exists and is not an administrator") .upon_receiving("a request for UserA") .with_request("get", "/users/UserA") - .will_respond_with(200, body=Like(expected)) + .will_respond_with(200, + headers=[{"name": "Content-Type", "value": 'application/json'}], + body=Like(expected)) ) - with pact: + pact.start_service() # Perform the actual request user = consumer.get_user("UserA") @@ -107,17 +128,20 @@ def test_get_user_non_admin(pact, consumer): pact.verify() -def test_get_non_existing_user(pact, consumer): +def test_get_non_existing_user(pact: PactV3, consumer): # Define the expected behaviour of the Provider. This determines how the # Pact mock provider will behave. In this case, we expect a 404 ( - pact.given("UserA does not exist") + pact + .new_http_interaction('a request for UserA') + .given("UserA does not exist") .upon_receiving("a request for UserA") .with_request("get", "/users/UserA") .will_respond_with(404) ) with pact: + pact.start_service() # Perform the actual request user = consumer.get_user("UserA") diff --git a/examples/consumer/tests/consumer/userserviceclient-userservice.json b/examples/consumer/tests/consumer/userserviceclient-userservice.json new file mode 100644 index 000000000..2014beb16 --- /dev/null +++ b/examples/consumer/tests/consumer/userserviceclient-userservice.json @@ -0,0 +1,184 @@ +{ + "consumer": { + "name": "UserServiceClient" + }, + "interactions": [ + { + "description": "a request for UserA", + "providerStates": [ + { + "name": "UserA does not exist" + } + ], + "request": { + "method": "GET", + "path": "/users/UserA" + }, + "response": { + "status": 404 + } + }, + { + "description": "a request for UserA", + "providerStates": [ + { + "name": "UserA exists and is not an administrator" + } + ], + "request": { + "method": "GET", + "path": "/users/UserA" + }, + "response": { + "body": { + "admin": false, + "created_on": "2016-12-15T20:16:01", + "id": "fc763eba-0905-41c5-a27f-3934ab26786c", + "ip_address": "127.0.0.1", + "name": "UserA" + }, + "headers": { + "Content-Type": "application/json" + }, + "matchingRules": { + "body": { + "$": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.created_on": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "\\d+-\\d+-\\d+T\\d+:\\d+:\\d+" + } + ] + }, + "$.id": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" + } + ] + }, + "$.ip_address": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "(\\d{1,3}\\.)+\\d{1,3}" + } + ] + } + }, + "header": {} + }, + "status": 200 + } + }, + { + "description": "a request for UserA", + "providerStates": [ + { + "name": "UserA does not exist" + } + ], + "request": { + "method": "GET", + "path": "/users/UserA" + }, + "response": { + "headers": {}, + "status": 404 + } + }, + { + "description": "new-http-interaction", + "providerStates": [ + { + "name": "UserA exists and is not an administrator" + } + ], + "request": { + "method": "GET", + "path": "/users/UserA" + }, + "response": { + "body": { + "admin": false, + "created_on": "2016-12-15T20:16:01", + "id": "fc763eba-0905-41c5-a27f-3934ab26786c", + "ip_address": "127.0.0.1", + "name": "UserA" + }, + "headers": { + "Content-Type": "application/json" + }, + "matchingRules": { + "body": { + "$": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.created_on": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "\\d+-\\d+-\\d+T\\d+:\\d+:\\d+" + } + ] + }, + "$.id": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" + } + ] + }, + "$.ip_address": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "(\\d{1,3}\\.)+\\d{1,3}" + } + ] + } + }, + "header": {} + }, + "status": 200 + } + } + ], + "metadata": { + "pact-python": { + "version": "2.0.1b0" + }, + "pactRust": { + "ffi": "0.4.7", + "mockserver": "1.2.3", + "models": "1.1.9" + }, + "pactSpecification": { + "version": "3.0.0" + } + }, + "provider": { + "name": "UserService" + } +} \ No newline at end of file diff --git a/examples/fastapi_provider/run_pytest.sh b/examples/fastapi_provider/run_pytest.sh index 447e0c4b3..bd3fcd55a 100755 --- a/examples/fastapi_provider/run_pytest.sh +++ b/examples/fastapi_provider/run_pytest.sh @@ -3,4 +3,8 @@ set -o pipefail # Unlike in the Flask example, here the FastAPI service is started up as a pytest fixture. This is then including the # main and pact routes via fastapi_provider.py to run the tests against -pytest --run-broker True --publish-pact 1\ +if [ "$RUN_BROKER" = '0' ]; then + pytest tests --publish-pact 1 +else + pytest tests --run-broker True --publish-pact 1 +fi \ No newline at end of file diff --git a/examples/fastapi_provider/tests/provider/test_provider.py b/examples/fastapi_provider/tests/provider/test_provider.py index bc903b481..7d3d18968 100644 --- a/examples/fastapi_provider/tests/provider/test_provider.py +++ b/examples/fastapi_provider/tests/provider/test_provider.py @@ -1,9 +1,10 @@ """pact test for user service client""" import logging +import os import pytest -from pact import Verifier +from pact import VerifierV3 log = logging.getLogger(__name__) logging.basicConfig(level=logging.INFO) @@ -11,9 +12,15 @@ # For the purposes of this example, the broker is started up as a fixture defined # in conftest.py. For normal usage this would be self-hosted or using PactFlow. -PACT_BROKER_URL = "http://localhost" -PACT_BROKER_USERNAME = "pactbroker" -PACT_BROKER_PASSWORD = "pactbroker" +use_pactflow = int(os.getenv('USE_HOSTED_PACT_BROKER', '0')) +if use_pactflow == 1: + PACT_BROKER_URL = os.getenv("PACT_BROKER_URL", "https://test.pactflow.io") + PACT_BROKER_USERNAME = os.getenv("PACT_BROKER_USERNAME", "dXfltyFMgNOFZAxr8io9wJ37iUpY42M") + PACT_BROKER_PASSWORD = os.getenv("PACT_BROKER_PASSWORD", "O5AIZWxelWbLvqMd8PkAVycBJh2Psyg1") +else: + PACT_BROKER_URL = os.getenv("PACT_BROKER_URL", "http://localhost") + PACT_BROKER_USERNAME = os.getenv("PACT_BROKER_USERNAME", "pactbroker") + PACT_BROKER_PASSWORD = os.getenv("PACT_BROKER_PASSWORD", "pactbroker") # For the purposes of this example, the FastAPI provider will be started up as # a fixture in conftest.py ("server"). Alternatives could be, for example @@ -40,16 +47,18 @@ def broker_opts(): def test_user_service_provider_against_broker(server, broker_opts): - verifier = Verifier(provider="UserService", provider_base_url=PROVIDER_URL) + verifier = VerifierV3(provider="UserService", + provider_base_url=PROVIDER_URL) # Request all Pact(s) from the Pact Broker to verify this Provider against. # In the Pact Broker logs, this corresponds to the following entry: # PactBroker::Api::Resources::ProviderPactsForVerification -- Fetching pacts for verification by UserService -- {:provider_name=>"UserService", :params=>{}} - success, logs = verifier.verify_with_broker( + success, logs = verifier.verify_pacts( **broker_opts, verbose=True, provider_states_setup_url=f"{PROVIDER_URL}/_pact/provider_states", enable_pending=False, + state_change_as_query=True ) # If publish_verification_results is set to True, the results will be # published to the Pact Broker. @@ -69,8 +78,8 @@ def test_user_service_provider_against_broker(server, broker_opts): def test_user_service_provider_against_pact(server): - verifier = Verifier(provider="UserService", provider_base_url=PROVIDER_URL) - + verifier = VerifierV3(provider="UserService", + provider_base_url=PROVIDER_URL) # Rather than requesting the Pact interactions from the Pact Broker, this # will perform the verification based on the Pact file locally. # @@ -79,9 +88,10 @@ def test_user_service_provider_against_pact(server): # is for), if the verification of an interaction fails then the success # result will be != 0, and so the test will FAIL. output, _ = verifier.verify_pacts( - "../pacts/userserviceclient-userservice.json", + sources=["../pacts/userserviceclient-userservice.json"], verbose=False, provider_states_setup_url="{}/_pact/provider_states".format(PROVIDER_URL), + state_change_as_query=True ) assert output == 0 diff --git a/examples/ffi/README.md b/examples/ffi/README.md new file mode 100644 index 000000000..cc2835238 --- /dev/null +++ b/examples/ffi/README.md @@ -0,0 +1,17 @@ +# FFI Examples + +This contains the following files which are for reference/information purposes +only i.e. they are not functionally used by end users. They may be used where +documented by developer helper scripts. The files are included here to make it +easier to identify when any changes have occurred in a new version of the Pact +FFI library. + +### pact_ffi_verifier_args.json + +The various arguments available to the Pact Verifier, both options and flags. +This format is used to construct the CLI arguments for `pact-verifier`, which +calls the method that produces this data each time during runtime. + +### pact_ffi_verifier_help.txt + +The output from ``pact-verifier --help`` \ No newline at end of file diff --git a/examples/ffi/pact_ffi_verifier_args.json b/examples/ffi/pact_ffi_verifier_args.json new file mode 100644 index 000000000..dde1fcc80 --- /dev/null +++ b/examples/ffi/pact_ffi_verifier_args.json @@ -0,0 +1,187 @@ +{ + "options": [ + { + "long": "loglevel", + "short": "l", + "help": "Log level (defaults to warn)", + "possible_values": [ + "error", + "warn", + "info", + "debug", + "trace", + "none" + ], + "multiple": false + }, + { + "long": "file", + "short": "f", + "help": "Pact file to verify (can be repeated)", + "multiple": true + }, + { + "long": "dir", + "short": "d", + "help": "Directory of pact files to verify (can be repeated)", + "multiple": true + }, + { + "long": "url", + "short": "u", + "help": "URL of pact file to verify (can be repeated)", + "multiple": true + }, + { + "long": "broker-url", + "short": "b", + "help": "URL of the pact broker to fetch pacts from to verify (requires the provider name parameter)", + "multiple": false, + "env": "PACT_BROKER_BASE_URL" + }, + { + "long": "hostname", + "short": "h", + "help": "Provider hostname (defaults to localhost)", + "multiple": false + }, + { + "long": "port", + "short": "p", + "help": "Provider port (defaults to protocol default 80/443)", + "multiple": false + }, + { + "long": "scheme", + "help": "Provider URI scheme (defaults to http)", + "possible_values": [ + "http", + "https" + ], + "default_value": "http", + "multiple": false + }, + { + "long": "provider-name", + "short": "n", + "help": "Provider name (defaults to provider)", + "multiple": false + }, + { + "long": "state-change-url", + "short": "s", + "help": "URL to post state change requests to", + "multiple": false + }, + { + "long": "filter-description", + "help": "Only validate interactions whose descriptions match this filter", + "multiple": false, + "env": "PACT_DESCRIPTION" + }, + { + "long": "filter-state", + "help": "Only validate interactions whose provider states match this filter", + "multiple": false, + "env": "PACT_PROVIDER_STATE" + }, + { + "long": "filter-no-state", + "help": "Only validate interactions that have no defined provider state", + "multiple": false, + "env": "PACT_PROVIDER_NO_STATE" + }, + { + "long": "filter-consumer", + "short": "c", + "help": "Consumer name to filter the pacts to be verified (can be repeated)", + "multiple": true + }, + { + "long": "user", + "help": "Username to use when fetching pacts from URLS", + "multiple": false, + "env": "PACT_BROKER_USERNAME" + }, + { + "long": "password", + "help": "Password to use when fetching pacts from URLS", + "multiple": false, + "env": "PACT_BROKER_PASSWORD" + }, + { + "long": "token", + "short": "t", + "help": "Bearer token to use when fetching pacts from URLS", + "multiple": false, + "env": "PACT_BROKER_TOKEN" + }, + { + "long": "provider-version", + "help": "Provider version that is being verified. This is required when publishing results.", + "multiple": false + }, + { + "long": "build-url", + "help": "URL of the build to associate with the published verification results.", + "multiple": false + }, + { + "long": "provider-tags", + "help": "Provider tags to use when publishing results. Accepts comma-separated values.", + "multiple": false + }, + { + "long": "base-path", + "help": "Base path to add to all requests", + "multiple": false + }, + { + "long": "consumer-version-tags", + "help": "Consumer tags to use when fetching pacts from the Broker. Accepts comma-separated values.", + "multiple": false + }, + { + "long": "consumer-version-selectors", + "help": "Consumer version selectors to use when fetching pacts from the Broker. Accepts a JSON string as per https://docs.pact.io/pact_broker/advanced_topics/consumer_version_selectors/", + "multiple": false + }, + { + "long": "include-wip-pacts-since", + "help": "Allow pacts that don't match given consumer selectors (or tags) to be verified, without causing the overall task to fail. For more information, see https://pact.io/wip", + "multiple": false + }, + { + "long": "request-timeout", + "help": "Sets the HTTP request timeout in milliseconds for requests to the target API and for state change requests.", + "multiple": false + } + ], + "flags": [ + { + "long": "state-change-as-query", + "help": "State change request data will be sent as query parameters instead of in the request body", + "multiple": false + }, + { + "long": "state-change-teardown", + "help": "State change teardown requests are to be made after each interaction", + "multiple": false + }, + { + "long": "publish", + "help": "Enables publishing of verification results back to the Pact Broker. Requires the broker-url and provider-version parameters.", + "multiple": false + }, + { + "long": "disable-ssl-verification", + "help": "Disables validation of SSL certificates", + "multiple": false + }, + { + "long": "enable-pending", + "help": "Enables Pending Pacts", + "multiple": false + } + ] +} \ No newline at end of file diff --git a/examples/ffi/pact_ffi_verifier_help.txt b/examples/ffi/pact_ffi_verifier_help.txt new file mode 100644 index 000000000..2c354c663 --- /dev/null +++ b/examples/ffi/pact_ffi_verifier_help.txt @@ -0,0 +1,37 @@ +Usage: pact-verifier [OPTIONS] + +Options: + --debug-click Display arguments passed to the FFI library + --enable-pending Enables Pending Pacts + --disable-ssl-verification Disables validation of SSL certificates + --publish Enables publishing of verification results back to the Pact Broker. Requires the broker-url and provider-version parameters. + --state-change-teardown State change teardown requests are to be made after each interaction + --state-change-as-query State change request data will be sent as query parameters instead of in the request body + --request-timeout TEXT Sets the HTTP request timeout in milliseconds for requests to the target API and for state change requests. + --include-wip-pacts-since TEXT Allow pacts that don't match given consumer selectors (or tags) to be verified, without causing the overall task to fail. For more information, see https://pact.io/wip + --consumer-version-selectors TEXT + Consumer version selectors to use when fetching pacts from the Broker. Accepts a JSON string as per https://docs.pact.io/pact_broker/advanced_topics/consumer_version_selectors/ + --consumer-version-tags TEXT Consumer tags to use when fetching pacts from the Broker. Accepts comma-separated values. + --base-path TEXT Base path to add to all requests + --provider-tags TEXT Provider tags to use when publishing results. Accepts comma-separated values. + --build-url TEXT URL of the build to associate with the published verification results. + --provider-version TEXT Provider version that is being verified. This is required when publishing results. + -t, --token TEXT Bearer token to use when fetching pacts from URLS. Alternatively: $PACT_BROKER_TOKEN + --password TEXT Password to use when fetching pacts from URLS. Alternatively: $PACT_BROKER_PASSWORD + --user TEXT Username to use when fetching pacts from URLS. Alternatively: $PACT_BROKER_USERNAME + -c, --filter-consumer TEXT Consumer name to filter the pacts to be verified (can be repeated) + --filter-no-state TEXT Only validate interactions that have no defined provider state. Alternatively: $PACT_PROVIDER_NO_STATE + --filter-state TEXT Only validate interactions whose provider states match this filter. Alternatively: $PACT_PROVIDER_STATE + --filter-description TEXT Only validate interactions whose descriptions match this filter. Alternatively: $PACT_DESCRIPTION + -s, --state-change-url TEXT URL to post state change requests to + -n, --provider-name TEXT Provider name (defaults to provider) + --scheme [http|https] Provider URI scheme (defaults to http) + -p, --port TEXT Provider port (defaults to protocol default 80/443) + -h, --hostname TEXT Provider hostname (defaults to localhost) + -b, --broker-url TEXT URL of the pact broker to fetch pacts from to verify (requires the provider name parameter). Alternatively: $PACT_BROKER_BASE_URL + -u, --url TEXT URL of pact file to verify (can be repeated) + -d, --dir TEXT Directory of pact files to verify (can be repeated) + -f, --file TEXT Pact file to verify (can be repeated) + -l, --loglevel [error|warn|info|debug|trace|none] + Log level (defaults to warn) + --help Show this message and exit. \ No newline at end of file diff --git a/examples/flask_provider/run_pytest.sh b/examples/flask_provider/run_pytest.sh index 37aa783a0..b79d17cec 100755 --- a/examples/flask_provider/run_pytest.sh +++ b/examples/flask_provider/run_pytest.sh @@ -17,4 +17,8 @@ trap teardown EXIT sleep 1 # Now run the tests -pytest tests --run-broker True --publish-pact 1 +if [ "$RUN_BROKER" == '0' ]; then + pytest tests -rP +else + pytest tests --run-broker True --publish-pact 1 +fi diff --git a/examples/flask_provider/tests/provider/test_provider.py b/examples/flask_provider/tests/provider/test_provider.py index f87257785..f30508e5b 100644 --- a/examples/flask_provider/tests/provider/test_provider.py +++ b/examples/flask_provider/tests/provider/test_provider.py @@ -1,25 +1,32 @@ """pact test for user service provider""" import logging +import os import pytest -from pact import Verifier +from pact import VerifierV3 log = logging.getLogger(__name__) logging.basicConfig(level=logging.INFO) # For the purposes of this example, the broker is started up as a fixture defined # in conftest.py. For normal usage this would be self-hosted or using PactFlow. -PACT_BROKER_URL = "http://localhost" -PACT_BROKER_USERNAME = "pactbroker" -PACT_BROKER_PASSWORD = "pactbroker" +use_pactflow = int(os.getenv('USE_HOSTED_PACT_BROKER', '0')) +if use_pactflow == 1: + PACT_BROKER_URL = os.getenv("PACT_BROKER_URL", "https://test.pactflow.io") + PACT_BROKER_USERNAME = os.getenv("PACT_BROKER_USERNAME", "dXfltyFMgNOFZAxr8io9wJ37iUpY42M") + PACT_BROKER_PASSWORD = os.getenv("PACT_BROKER_PASSWORD", "O5AIZWxelWbLvqMd8PkAVycBJh2Psyg1") +else: + PACT_BROKER_URL = os.getenv("PACT_BROKER_URL", "http://localhost") + PACT_BROKER_USERNAME = os.getenv("PACT_BROKER_USERNAME", "pactbroker") + PACT_BROKER_PASSWORD = os.getenv("PACT_BROKER_PASSWORD", "pactbroker") # For the purposes of this example, the Flask provider will be started up as part # of run_pytest.sh when running the tests. Alternatives could be, for example # running a Docker container with a database of test data configured. # This is the "real" provider to verify against. -PROVIDER_HOST = "localhost" +PROVIDER_HOST = "127.0.0.1" PROVIDER_PORT = 5001 PROVIDER_URL = f"http://{PROVIDER_HOST}:{PROVIDER_PORT}" @@ -36,16 +43,19 @@ def broker_opts(): def test_user_service_provider_against_broker(broker_opts): - verifier = Verifier(provider="UserService", provider_base_url=PROVIDER_URL) + verifier = VerifierV3(provider="UserService", provider_base_url=PROVIDER_URL) # Request all Pact(s) from the Pact Broker to verify this Provider against. # In the Pact Broker logs, this corresponds to the following entry: # PactBroker::Api::Resources::ProviderPactsForVerification -- Fetching pacts for verification by UserService -- {:provider_name=>"UserService", :params=>{}} - success, logs = verifier.verify_with_broker( + success, logs = verifier.verify_pacts( **broker_opts, + provider="UserService", + provider_base_url=PROVIDER_URL, verbose=True, provider_states_setup_url=f"{PROVIDER_URL}/_pact/provider_states", enable_pending=False, + state_change_as_query=True ) # If publish_verification_results is set to True, the results will be # published to the Pact Broker. @@ -65,7 +75,7 @@ def test_user_service_provider_against_broker(broker_opts): def test_user_service_provider_against_pact(): - verifier = Verifier(provider="UserService", provider_base_url=PROVIDER_URL) + verifier = VerifierV3(provider="UserService", provider_base_url=PROVIDER_URL) # Rather than requesting the Pact interactions from the Pact Broker, this # will perform the verification based on the Pact file locally. @@ -74,10 +84,35 @@ def test_user_service_provider_against_pact(): # if it has been successful in the past (since this is what the Pact Broker # is for), if the verification of an interaction fails then the success # result will be != 0, and so the test will FAIL. - output, _ = verifier.verify_pacts( - "../pacts/userserviceclient-userservice.json", + success, _ = verifier.verify_pacts( + sources=[os.path.abspath("../pacts/userserviceclient-userservice.json")], + provider="UserService", + provider_base_url=PROVIDER_URL, verbose=False, provider_states_setup_url="{}/_pact/provider_states".format(PROVIDER_URL), + state_change_as_query=True ) - assert output == 0 + assert success == 0 + +def test_user_service_provider_against_pact_url(broker_opts): + verifier = VerifierV3(provider="UserService", provider_base_url=PROVIDER_URL) + + # Rather than requesting the Pact interactions from the Pact Broker, this + # will perform the verification based on the Pact file locally. + # + # Because there is no way of knowing the previous state of an interaction, + # if it has been successful in the past (since this is what the Pact Broker + # is for), if the verification of an interaction fails then the success + # result will be != 0, and so the test will FAIL. + success, _ = verifier.verify_pacts( + sources=[f"{PACT_BROKER_URL}/pacts/provider/UserService/consumer/UserServiceClient/latest"], + **broker_opts, + provider="UserService", + provider_base_url=PROVIDER_URL, + log_level='DEBUG', + provider_states_setup_url="{}/_pact/provider_states".format(PROVIDER_URL), + state_change_as_query=True + ) + + assert success == 0 diff --git a/examples/grpc/area_calculator_client.py b/examples/grpc/area_calculator_client.py new file mode 100644 index 000000000..ab39bfaf3 --- /dev/null +++ b/examples/grpc/area_calculator_client.py @@ -0,0 +1,31 @@ +"""The Python implementation of the GRPC Area Calculator client.""" + +from __future__ import print_function +import argparse +import logging +import grpc +import area_calculator_pb2 +import area_calculator_pb2_grpc + +def run(port=37757): + get_rectangle_area(f'localhost:{port}') + +def get_rectangle_area(address): + print("Getting rectangle area.") + with grpc.insecure_channel(address) as channel: + stub = area_calculator_pb2_grpc.CalculatorStub(channel) + rect = { + "length": 3, + "width": 4 + } + response = stub.calculateOne(area_calculator_pb2.ShapeMessage(rectangle=rect)) + print(f"AreaCalculator client received: {response.value[0]}") + return response.value[0] + + +if __name__ == '__main__': + logging.basicConfig() + parser = argparse.ArgumentParser(description='Description of your program') + parser.add_argument('-p', '--port', help='Port_number', required=False, default=37757) + args = vars(parser.parse_args()) + run(port=args['port']) diff --git a/examples/grpc/area_calculator_pb2.py b/examples/grpc/area_calculator_pb2.py new file mode 100644 index 000000000..9d65740af --- /dev/null +++ b/examples/grpc/area_calculator_pb2.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: area_calculator.proto +"""Generated protocol buffer code.""" +from google.protobuf.internal import builder as _builder +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import symbol_database as _symbol_database +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x15\x61rea_calculator.proto\x12\x0f\x61rea_calculator\"\x86\x02\n\x0cShapeMessage\x12)\n\x06square\x18\x01 \x01(\x0b\x32\x17.area_calculator.SquareH\x00\x12/\n\trectangle\x18\x02 \x01(\x0b\x32\x1a.area_calculator.RectangleH\x00\x12)\n\x06\x63ircle\x18\x03 \x01(\x0b\x32\x17.area_calculator.CircleH\x00\x12-\n\x08triangle\x18\x04 \x01(\x0b\x32\x19.area_calculator.TriangleH\x00\x12\x37\n\rparallelogram\x18\x05 \x01(\x0b\x32\x1e.area_calculator.ParallelogramH\x00\x42\x07\n\x05shape\"\x1d\n\x06Square\x12\x13\n\x0b\x65\x64ge_length\x18\x01 \x01(\x02\"*\n\tRectangle\x12\x0e\n\x06length\x18\x01 \x01(\x02\x12\r\n\x05width\x18\x02 \x01(\x02\"\x18\n\x06\x43ircle\x12\x0e\n\x06radius\x18\x01 \x01(\x02\":\n\x08Triangle\x12\x0e\n\x06\x65\x64ge_a\x18\x01 \x01(\x02\x12\x0e\n\x06\x65\x64ge_b\x18\x02 \x01(\x02\x12\x0e\n\x06\x65\x64ge_c\x18\x03 \x01(\x02\"4\n\rParallelogram\x12\x13\n\x0b\x62\x61se_length\x18\x01 \x01(\x02\x12\x0e\n\x06height\x18\x02 \x01(\x02\"<\n\x0b\x41reaRequest\x12-\n\x06shapes\x18\x01 \x03(\x0b\x32\x1d.area_calculator.ShapeMessage\"\x1d\n\x0c\x41reaResponse\x12\r\n\x05value\x18\x01 \x03(\x02\x32\xad\x01\n\nCalculator\x12N\n\x0c\x63\x61lculateOne\x12\x1d.area_calculator.ShapeMessage\x1a\x1d.area_calculator.AreaResponse\"\x00\x12O\n\x0e\x63\x61lculateMulti\x12\x1c.area_calculator.AreaRequest\x1a\x1d.area_calculator.AreaResponse\"\x00\x42\x1cZ\x17io.pact/area_calculator\xd0\x02\x01\x62\x06proto3') + +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'area_calculator_pb2', globals()) +if _descriptor._USE_C_DESCRIPTORS == False: + + DESCRIPTOR._options = None + DESCRIPTOR._serialized_options = b'Z\027io.pact/area_calculator\320\002\001' + _SHAPEMESSAGE._serialized_start=43 + _SHAPEMESSAGE._serialized_end=305 + _SQUARE._serialized_start=307 + _SQUARE._serialized_end=336 + _RECTANGLE._serialized_start=338 + _RECTANGLE._serialized_end=380 + _CIRCLE._serialized_start=382 + _CIRCLE._serialized_end=406 + _TRIANGLE._serialized_start=408 + _TRIANGLE._serialized_end=466 + _PARALLELOGRAM._serialized_start=468 + _PARALLELOGRAM._serialized_end=520 + _AREAREQUEST._serialized_start=522 + _AREAREQUEST._serialized_end=582 + _AREARESPONSE._serialized_start=584 + _AREARESPONSE._serialized_end=613 + _CALCULATOR._serialized_start=616 + _CALCULATOR._serialized_end=789 +# @@protoc_insertion_point(module_scope) diff --git a/examples/grpc/area_calculator_pb2.pyi b/examples/grpc/area_calculator_pb2.pyi new file mode 100644 index 000000000..7dae3bc60 --- /dev/null +++ b/examples/grpc/area_calculator_pb2.pyi @@ -0,0 +1,70 @@ +from google.protobuf.internal import containers as _containers +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from typing import ClassVar as _ClassVar, Iterable as _Iterable, Mapping as _Mapping, Optional as _Optional, Union as _Union + +DESCRIPTOR: _descriptor.FileDescriptor + +class AreaRequest(_message.Message): + __slots__ = ["shapes"] + SHAPES_FIELD_NUMBER: _ClassVar[int] + shapes: _containers.RepeatedCompositeFieldContainer[ShapeMessage] + def __init__(self, shapes: _Optional[_Iterable[_Union[ShapeMessage, _Mapping]]] = ...) -> None: ... + +class AreaResponse(_message.Message): + __slots__ = ["value"] + VALUE_FIELD_NUMBER: _ClassVar[int] + value: _containers.RepeatedScalarFieldContainer[float] + def __init__(self, value: _Optional[_Iterable[float]] = ...) -> None: ... + +class Circle(_message.Message): + __slots__ = ["radius"] + RADIUS_FIELD_NUMBER: _ClassVar[int] + radius: float + def __init__(self, radius: _Optional[float] = ...) -> None: ... + +class Parallelogram(_message.Message): + __slots__ = ["base_length", "height"] + BASE_LENGTH_FIELD_NUMBER: _ClassVar[int] + HEIGHT_FIELD_NUMBER: _ClassVar[int] + base_length: float + height: float + def __init__(self, base_length: _Optional[float] = ..., height: _Optional[float] = ...) -> None: ... + +class Rectangle(_message.Message): + __slots__ = ["length", "width"] + LENGTH_FIELD_NUMBER: _ClassVar[int] + WIDTH_FIELD_NUMBER: _ClassVar[int] + length: float + width: float + def __init__(self, length: _Optional[float] = ..., width: _Optional[float] = ...) -> None: ... + +class ShapeMessage(_message.Message): + __slots__ = ["circle", "parallelogram", "rectangle", "square", "triangle"] + CIRCLE_FIELD_NUMBER: _ClassVar[int] + PARALLELOGRAM_FIELD_NUMBER: _ClassVar[int] + RECTANGLE_FIELD_NUMBER: _ClassVar[int] + SQUARE_FIELD_NUMBER: _ClassVar[int] + TRIANGLE_FIELD_NUMBER: _ClassVar[int] + circle: Circle + parallelogram: Parallelogram + rectangle: Rectangle + square: Square + triangle: Triangle + def __init__(self, square: _Optional[_Union[Square, _Mapping]] = ..., rectangle: _Optional[_Union[Rectangle, _Mapping]] = ..., circle: _Optional[_Union[Circle, _Mapping]] = ..., triangle: _Optional[_Union[Triangle, _Mapping]] = ..., parallelogram: _Optional[_Union[Parallelogram, _Mapping]] = ...) -> None: ... + +class Square(_message.Message): + __slots__ = ["edge_length"] + EDGE_LENGTH_FIELD_NUMBER: _ClassVar[int] + edge_length: float + def __init__(self, edge_length: _Optional[float] = ...) -> None: ... + +class Triangle(_message.Message): + __slots__ = ["edge_a", "edge_b", "edge_c"] + EDGE_A_FIELD_NUMBER: _ClassVar[int] + EDGE_B_FIELD_NUMBER: _ClassVar[int] + EDGE_C_FIELD_NUMBER: _ClassVar[int] + edge_a: float + edge_b: float + edge_c: float + def __init__(self, edge_a: _Optional[float] = ..., edge_b: _Optional[float] = ..., edge_c: _Optional[float] = ...) -> None: ... diff --git a/examples/grpc/area_calculator_pb2_grpc.py b/examples/grpc/area_calculator_pb2_grpc.py new file mode 100644 index 000000000..08baa6b9f --- /dev/null +++ b/examples/grpc/area_calculator_pb2_grpc.py @@ -0,0 +1,98 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +"""Client and server classes corresponding to protobuf-defined services.""" +import grpc + +import area_calculator_pb2 as area__calculator__pb2 + +class CalculatorStub(object): + """Missing associated documentation comment in .proto file.""" + + def __init__(self, channel): + """Constructor. + + Args: + channel: A grpc.Channel. + """ + self.calculateOne = channel.unary_unary( + '/area_calculator.Calculator/calculateOne', + request_serializer=area__calculator__pb2.ShapeMessage.SerializeToString, + response_deserializer=area__calculator__pb2.AreaResponse.FromString, + ) + self.calculateMulti = channel.unary_unary( + '/area_calculator.Calculator/calculateMulti', + request_serializer=area__calculator__pb2.AreaRequest.SerializeToString, + response_deserializer=area__calculator__pb2.AreaResponse.FromString, + ) + + +class CalculatorServicer(object): + """Missing associated documentation comment in .proto file.""" + + def calculateOne(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def calculateMulti(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + +def add_CalculatorServicer_to_server(servicer, server): + rpc_method_handlers = { + 'calculateOne': grpc.unary_unary_rpc_method_handler( + servicer.calculateOne, + request_deserializer=area__calculator__pb2.ShapeMessage.FromString, + response_serializer=area__calculator__pb2.AreaResponse.SerializeToString, + ), + 'calculateMulti': grpc.unary_unary_rpc_method_handler( + servicer.calculateMulti, + request_deserializer=area__calculator__pb2.AreaRequest.FromString, + response_serializer=area__calculator__pb2.AreaResponse.SerializeToString, + ), + } + generic_handler = grpc.method_handlers_generic_handler( + 'area_calculator.Calculator', rpc_method_handlers) + server.add_generic_rpc_handlers((generic_handler,)) + + + # This class is part of an EXPERIMENTAL API. +class Calculator(object): + """Missing associated documentation comment in .proto file.""" + + @staticmethod + def calculateOne(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary(request, target, '/area_calculator.Calculator/calculateOne', + area__calculator__pb2.ShapeMessage.SerializeToString, + area__calculator__pb2.AreaResponse.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + + @staticmethod + def calculateMulti(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary(request, target, '/area_calculator.Calculator/calculateMulti', + area__calculator__pb2.AreaRequest.SerializeToString, + area__calculator__pb2.AreaResponse.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) diff --git a/examples/grpc/area_calculator_server.py b/examples/grpc/area_calculator_server.py new file mode 100644 index 000000000..a1459d606 --- /dev/null +++ b/examples/grpc/area_calculator_server.py @@ -0,0 +1,30 @@ +"""The Python implementation of the GRPC AreaCalculator server.""" + +from concurrent import futures +import logging + +import grpc +import area_calculator_pb2 +import area_calculator_pb2_grpc + +class AreaCalculator(area_calculator_pb2_grpc.CalculatorServicer): + + def calculateOne(self, request, context): + print(request.rectangle) + area = request.rectangle.length * request.rectangle.width + return area_calculator_pb2.AreaResponse(value=[area]) + + +def serve(): + port = '37757' + server = grpc.server(futures.ThreadPoolExecutor(max_workers=10)) + area_calculator_pb2_grpc.add_CalculatorServicer_to_server(AreaCalculator(), server) + server.add_insecure_port('[::]:' + port) + server.start() + print("Server started, listening on " + port) + server.wait_for_termination() + + +if __name__ == '__main__': + logging.basicConfig() + serve() diff --git a/examples/grpc/requirements.txt b/examples/grpc/requirements.txt new file mode 100644 index 000000000..c1008e10c --- /dev/null +++ b/examples/grpc/requirements.txt @@ -0,0 +1,4 @@ +grpcio==1.48.2; python_version < '3.7' +grpcio==1.56.2; python_version >= '3.7' +grpcio-tools==1.48.2; python_version < '3.7' +grpcio-tools==1.56.2; python_version >= '3.7' \ No newline at end of file diff --git a/examples/grpc/run_pytest.sh b/examples/grpc/run_pytest.sh new file mode 100755 index 000000000..8b48ec93d --- /dev/null +++ b/examples/grpc/run_pytest.sh @@ -0,0 +1,4 @@ +#!/bin/bash +set -o pipefail + +pytest \ No newline at end of file diff --git a/examples/grpc/test_grpc_consumer.py b/examples/grpc/test_grpc_consumer.py new file mode 100644 index 000000000..2ef1a73bf --- /dev/null +++ b/examples/grpc/test_grpc_consumer.py @@ -0,0 +1,54 @@ +from pact.ffi.native_mock_server import MockServer, MockServerStatus +import json +import os +import pytest +from sys import version_info +if version_info < (3, 7): + try: + from examples.grpc.area_calculator_client import get_rectangle_area + except ImportError: + print("Skipping import for Python 3.6 due to protobuf error") +else: + from examples.grpc.area_calculator_client import get_rectangle_area + +@pytest.mark.skipif( + version_info < (3, 7), + reason="https://stackoverflow.com/questions/71759248/importerror-cannot-import-name-builder-from-google-protobuf-internal") +def test_ffi_grpc_consumer(): + # Setup pact for testing + m = MockServer() + PACT_FILE_DIR = '../pacts' + pact = m.new_pact('grpc-consumer-python', 'area-calculator-provider') + message_pact = m.new_sync_message_interaction(pact, 'A gRPC calculateMulti request') + m.with_specification(pact, 5) + m.using_plugin(pact, 'protobuf', '0.3.4') + + # our interaction contents + contents = { + "pact:proto": os.path.abspath('../proto/area_calculator.proto'), + "pact:proto-service": 'Calculator/calculateOne', + "pact:content-type": 'application/protobuf', + "request": { + "rectangle": { + "length": 'matching(number, 3)', + "width": 'matching(number, 4)' + } + }, + "response": { + "value": ['matching(number, 12)'] + } + } + + # Start mock server + m.interaction_contents(message_pact, "res", 'application/grpc', json.dumps(contents)) + mock_server_port = m.start_mock_server(pact, '0.0.0.0', 0, 'grpc', None) + + # Make our client call + expected_response = 12.0 + response = get_rectangle_area(f"localhost:{mock_server_port}") + print(f"Client response: {response}") + print(f"Client response - matched expected: {response == expected_response}") + + # Check our result and write pact to file + result = m.verify(mock_server_port, pact, PACT_FILE_DIR) + assert MockServerStatus(result.return_code) == MockServerStatus.SUCCESS diff --git a/examples/grpc/test_grpc_provider.py b/examples/grpc/test_grpc_provider.py new file mode 100644 index 000000000..5cf39ace7 --- /dev/null +++ b/examples/grpc/test_grpc_provider.py @@ -0,0 +1,31 @@ +from sys import version_info +from time import sleep +import pytest +from pact import VerifierV3 +import subprocess +from pact.ffi.verifier import VerifyStatus + +@pytest.mark.skipif( + version_info < (3, 7), + reason="https://stackoverflow.com/questions/71759248/importerror-cannot-import-name-builder-from-google-protobuf-internal") +def test_grpc_local_pact(): + + grpc_server_process = subprocess.Popen(['python', 'area_calculator_server.py'], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, universal_newlines=True) + + # grpc_server_process = subprocess.Popen(['python', 'area_calculator_server.py'], + # cwd=join(dirname(__file__), '..', '..', 'examples', 'area_calculator'), + # stdout=subprocess.PIPE, + # stderr=subprocess.STDOUT, universal_newlines=True) + sleep(3) + + verifier = VerifierV3(provider="area-calculator-provider-example", + provider_base_url="tcp://127.0.0.1:37757", + ) + result = verifier.verify_pacts( + sources=["../pacts/grpc-consumer-python-area-calculator-provider.json"], + ) + grpc_server_process.terminate() + assert VerifyStatus(result.return_code) == VerifyStatus.SUCCESS + # TODO - Plugin success or failure not returned in logs diff --git a/examples/message/pacts/detectcontentlambda-contentprovider.json b/examples/message/pacts/detectcontentlambda-contentprovider.json new file mode 100644 index 000000000..4709110a6 --- /dev/null +++ b/examples/message/pacts/detectcontentlambda-contentprovider.json @@ -0,0 +1,55 @@ +{ + "consumer": { + "name": "DetectContentLambda" + }, + "provider": { + "name": "ContentProvider" + }, + "messages": [ + { + "description": "Description", + "providerStates": [ + { + "name": "A document created successfully", + "params": null + } + ], + "contents": { + "event": "ObjectCreated:Put", + "documentName": "document.doc", + "creator": "TP", + "documentType": "microsoft-word" + }, + "matchingRules": { + }, + "metaData": { + "Content-Type": "application/json" + } + }, + { + "description": "Description with broker", + "providerStates": [ + { + "name": "A document deleted successfully", + "params": null + } + ], + "contents": { + "event": "ObjectCreated:Delete", + "documentName": "document.doc", + "creator": "TP", + "documentType": "microsoft-word" + }, + "matchingRules": { + }, + "metaData": { + "Content-Type": "application/json" + } + } + ], + "metadata": { + "pactSpecification": { + "version": "3.0.0" + } + } +} \ No newline at end of file diff --git a/examples/message/requirements.txt b/examples/message/requirements.txt index 021958ce1..c7e173f83 100644 --- a/examples/message/requirements.txt +++ b/examples/message/requirements.txt @@ -4,3 +4,5 @@ pytest==7.0.1; python_version < '3.7' pytest==7.1.3; python_version >= '3.7' requests==2.27.1; python_version < '3.7' requests>=2.28.0; python_version >= '3.7' +testcontainers==3.7.0; python_version < '3.7' +testcontainers==3.7.1; python_version >= '3.7' \ No newline at end of file diff --git a/examples/message/run_pytest.sh b/examples/message/run_pytest.sh index 35dade4fb..75de913cc 100755 --- a/examples/message/run_pytest.sh +++ b/examples/message/run_pytest.sh @@ -1,7 +1,11 @@ #!/bin/bash set -o pipefail -pytest --run-broker True --publish-pact 2 +if [ "$RUN_BROKER" = '0' ]; then + pytest tests --publish-pact 2 +else + pytest tests --run-broker True --publish-pact 2 +fi # publish to broker assuming broker is active # pytest tests/consumer/test_message_consumer.py::test_publish_to_broker --publish-pact 2 diff --git a/examples/message/tests/consumer/test_message_consumer.py b/examples/message/tests/consumer/test_message_consumer.py index 3c7de4c1a..33e9434b3 100644 --- a/examples/message/tests/consumer/test_message_consumer.py +++ b/examples/message/tests/consumer/test_message_consumer.py @@ -1,6 +1,7 @@ """pact test for a message consumer""" import logging +import os import pytest import time @@ -13,9 +14,16 @@ log = logging.getLogger(__name__) logging.basicConfig(level=logging.INFO) -PACT_BROKER_URL = "http://localhost" -PACT_BROKER_USERNAME = "pactbroker" -PACT_BROKER_PASSWORD = "pactbroker" +use_pactflow = int(os.getenv('USE_HOSTED_PACT_BROKER', '0')) +if use_pactflow == 1: + PACT_BROKER_URL = os.getenv("PACT_BROKER_URL", "https://test.pactflow.io") + PACT_BROKER_USERNAME = os.getenv("PACT_BROKER_USERNAME", "dXfltyFMgNOFZAxr8io9wJ37iUpY42M") + PACT_BROKER_PASSWORD = os.getenv("PACT_BROKER_PASSWORD", "O5AIZWxelWbLvqMd8PkAVycBJh2Psyg1") +else: + PACT_BROKER_URL = os.getenv("PACT_BROKER_URL", "http://localhost") + PACT_BROKER_USERNAME = os.getenv("PACT_BROKER_USERNAME", "pactbroker") + PACT_BROKER_PASSWORD = os.getenv("PACT_BROKER_PASSWORD", "pactbroker") + PACT_DIR = "pacts" CONSUMER_NAME = "DetectContentLambda" diff --git a/examples/message/tests/provider/test_message_provider.py b/examples/message/tests/provider/test_message_provider.py index ae20ab3a1..4657b0932 100644 --- a/examples/message/tests/provider/test_message_provider.py +++ b/examples/message/tests/provider/test_message_provider.py @@ -1,9 +1,16 @@ +import os import pytest from pact import MessageProvider -PACT_BROKER_URL = "http://localhost" -PACT_BROKER_USERNAME = "pactbroker" -PACT_BROKER_PASSWORD = "pactbroker" +use_pactflow = int(os.getenv('USE_HOSTED_PACT_BROKER', '0')) +if use_pactflow == 1: + PACT_BROKER_URL = os.getenv("PACT_BROKER_URL", "https://test.pactflow.io") + PACT_BROKER_USERNAME = os.getenv("PACT_BROKER_USERNAME", "dXfltyFMgNOFZAxr8io9wJ37iUpY42M") + PACT_BROKER_PASSWORD = os.getenv("PACT_BROKER_PASSWORD", "O5AIZWxelWbLvqMd8PkAVycBJh2Psyg1") +else: + PACT_BROKER_URL = os.getenv("PACT_BROKER_URL", "http://localhost") + PACT_BROKER_USERNAME = os.getenv("PACT_BROKER_USERNAME", "pactbroker") + PACT_BROKER_PASSWORD = os.getenv("PACT_BROKER_PASSWORD", "pactbroker") PACT_DIR = "pacts" @@ -14,7 +21,7 @@ def default_opts(): 'broker_password': PACT_BROKER_PASSWORD, 'broker_url': PACT_BROKER_URL, 'publish_version': '3', - 'publish_verification_results': False + 'publish_verification_results': True } @@ -48,10 +55,11 @@ def test_verify_success(): ) with provider: - provider.verify() + success, logs = provider.verify() + assert success == 0 -def test_verify_failure_when_a_provider_missing(): +def test_verify_failure_when_a_handler_missing(): provider = MessageProvider( message_providers={ 'A document created successfully': document_created_handler, @@ -62,9 +70,9 @@ def test_verify_failure_when_a_provider_missing(): ) - with pytest.raises(AssertionError): - with provider: - provider.verify() + with provider: + success, logs = provider.verify() + assert success == 1 def test_verify_from_broker(default_opts): @@ -75,9 +83,8 @@ def test_verify_from_broker(default_opts): }, provider='ContentProvider', consumer='DetectContentLambda', - pact_dir='pacts' - ) with provider: - provider.verify_with_broker(**default_opts) + success, logs = provider.verify_with_broker(**default_opts) + assert success == 0 diff --git a/examples/pacts/grpc-consumer-python-area-calculator-provider.json b/examples/pacts/grpc-consumer-python-area-calculator-provider.json new file mode 100644 index 000000000..7b80e7bc1 --- /dev/null +++ b/examples/pacts/grpc-consumer-python-area-calculator-provider.json @@ -0,0 +1,107 @@ +{ + "consumer": { + "name": "grpc-consumer-python" + }, + "interactions": [ + { + "description": "A gRPC calculateMulti request", + "interactionMarkup": { + "markup": "```protobuf\nmessage AreaResponse {\n repeated float value = 1;\n}\n```\n", + "markupType": "COMMON_MARK" + }, + "pending": false, + "pluginConfiguration": { + "protobuf": { + "descriptorKey": "a85dff8f82655a9681aad113575dcfbb", + "service": "Calculator/calculateOne" + } + }, + "request": { + "contents": { + "content": "EgoNAABAQBUAAIBA", + "contentType": "application/protobuf;message=ShapeMessage", + "contentTypeHint": "BINARY", + "encoded": "base64" + }, + "matchingRules": { + "body": { + "$.rectangle.length": { + "combine": "AND", + "matchers": [ + { + "match": "number" + } + ] + }, + "$.rectangle.width": { + "combine": "AND", + "matchers": [ + { + "match": "number" + } + ] + } + } + }, + "metadata": { + "contentType": "application/protobuf;message=ShapeMessage" + } + }, + "response": [ + { + "contents": { + "content": "CgQAAEBB", + "contentType": "application/protobuf;message=AreaResponse", + "contentTypeHint": "BINARY", + "encoded": "base64" + }, + "matchingRules": { + "body": { + "$.value[0].*": { + "combine": "AND", + "matchers": [ + { + "match": "number" + } + ] + } + } + }, + "metadata": { + "contentType": "application/protobuf;message=AreaResponse" + } + } + ], + "transport": "grpc", + "type": "Synchronous/Messages" + } + ], + "metadata": { + "pact-python": { + "version": "2.0.1b0" + }, + "pactRust": { + "ffi": "0.4.7", + "mockserver": "1.2.3", + "models": "1.1.9" + }, + "pactSpecification": { + "version": "4.0" + }, + "plugins": [ + { + "configuration": { + "a85dff8f82655a9681aad113575dcfbb": { + "protoDescriptors": "CsoHChVhcmVhX2NhbGN1bGF0b3IucHJvdG8SD2FyZWFfY2FsY3VsYXRvciK6AgoMU2hhcGVNZXNzYWdlEjEKBnNxdWFyZRgBIAEoCzIXLmFyZWFfY2FsY3VsYXRvci5TcXVhcmVIAFIGc3F1YXJlEjoKCXJlY3RhbmdsZRgCIAEoCzIaLmFyZWFfY2FsY3VsYXRvci5SZWN0YW5nbGVIAFIJcmVjdGFuZ2xlEjEKBmNpcmNsZRgDIAEoCzIXLmFyZWFfY2FsY3VsYXRvci5DaXJjbGVIAFIGY2lyY2xlEjcKCHRyaWFuZ2xlGAQgASgLMhkuYXJlYV9jYWxjdWxhdG9yLlRyaWFuZ2xlSABSCHRyaWFuZ2xlEkYKDXBhcmFsbGVsb2dyYW0YBSABKAsyHi5hcmVhX2NhbGN1bGF0b3IuUGFyYWxsZWxvZ3JhbUgAUg1wYXJhbGxlbG9ncmFtQgcKBXNoYXBlIikKBlNxdWFyZRIfCgtlZGdlX2xlbmd0aBgBIAEoAlIKZWRnZUxlbmd0aCI5CglSZWN0YW5nbGUSFgoGbGVuZ3RoGAEgASgCUgZsZW5ndGgSFAoFd2lkdGgYAiABKAJSBXdpZHRoIiAKBkNpcmNsZRIWCgZyYWRpdXMYASABKAJSBnJhZGl1cyJPCghUcmlhbmdsZRIVCgZlZGdlX2EYASABKAJSBWVkZ2VBEhUKBmVkZ2VfYhgCIAEoAlIFZWRnZUISFQoGZWRnZV9jGAMgASgCUgVlZGdlQyJICg1QYXJhbGxlbG9ncmFtEh8KC2Jhc2VfbGVuZ3RoGAEgASgCUgpiYXNlTGVuZ3RoEhYKBmhlaWdodBgCIAEoAlIGaGVpZ2h0IkQKC0FyZWFSZXF1ZXN0EjUKBnNoYXBlcxgBIAMoCzIdLmFyZWFfY2FsY3VsYXRvci5TaGFwZU1lc3NhZ2VSBnNoYXBlcyIkCgxBcmVhUmVzcG9uc2USFAoFdmFsdWUYASADKAJSBXZhbHVlMq0BCgpDYWxjdWxhdG9yEk4KDGNhbGN1bGF0ZU9uZRIdLmFyZWFfY2FsY3VsYXRvci5TaGFwZU1lc3NhZ2UaHS5hcmVhX2NhbGN1bGF0b3IuQXJlYVJlc3BvbnNlIgASTwoOY2FsY3VsYXRlTXVsdGkSHC5hcmVhX2NhbGN1bGF0b3IuQXJlYVJlcXVlc3QaHS5hcmVhX2NhbGN1bGF0b3IuQXJlYVJlc3BvbnNlIgBCHFoXaW8ucGFjdC9hcmVhX2NhbGN1bGF0b3LQAgFiBnByb3RvMw==", + "protoFile": "syntax = \"proto3\";\n\npackage area_calculator;\n\noption php_generic_services = true;\noption go_package = \"io.pact/area_calculator\";\n\nservice Calculator {\n rpc calculateOne (ShapeMessage) returns (AreaResponse) {}\n rpc calculateMulti (AreaRequest) returns (AreaResponse) {}\n}\n\nmessage ShapeMessage {\n oneof shape {\n Square square = 1;\n Rectangle rectangle = 2;\n Circle circle = 3;\n Triangle triangle = 4;\n Parallelogram parallelogram = 5;\n }\n}\n\nmessage Square {\n float edge_length = 1;\n}\n\nmessage Rectangle {\n float length = 1;\n float width = 2;\n}\n\nmessage Circle {\n float radius = 1;\n}\n\nmessage Triangle {\n float edge_a = 1;\n float edge_b = 2;\n float edge_c = 3;\n}\n\nmessage Parallelogram {\n float base_length = 1;\n float height = 2;\n}\n\nmessage AreaRequest {\n repeated ShapeMessage shapes = 1;\n}\n\nmessage AreaResponse {\n repeated float value = 1;\n}" + } + }, + "name": "protobuf", + "version": "0.3.4" + } + ] + }, + "provider": { + "name": "area-calculator-provider" + } +} \ No newline at end of file diff --git a/examples/pacts/http-consumer-1-http-provider.json b/examples/pacts/http-consumer-1-http-provider.json new file mode 100644 index 000000000..e31ccd816 --- /dev/null +++ b/examples/pacts/http-consumer-1-http-provider.json @@ -0,0 +1,154 @@ +{ + "consumer": { + "name": "http-consumer-1" + }, + "interactions": [ + { + "description": "A POST request to create book", + "providerStates": [ + { + "name": "No book fixtures required" + } + ], + "request": { + "body": { + "author": "Margaret Atwood", + "description": "Brilliantly conceived and executed, this powerful evocation of twenty-first century America gives full rein to Margaret Atwood's devastating irony, wit and astute perception.", + "isbn": "0099740915", + "publicationDate": "1985-07-31T00:00:00+00:00", + "title": "The Handmaid's Tale" + }, + "headers": { + "Content-Type": "application/json" + }, + "matchingRules": { + "body": { + "$.author": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.description": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.isbn": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.publicationDate": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "^\\d{4}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d([+-][0-2]\\d:[0-5]\\d|Z)$" + } + ] + }, + "$.title": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + }, + "header": {} + }, + "method": "POST", + "path": "/api/books" + }, + "response": { + "body": { + "@context": "/api/contexts/Book", + "@id": "/api/books/0114b2a8-3347-49d8-ad99-0e792c5a30e6", + "@type": "Book", + "author": "Melisa Kassulke", + "description": "Quaerat odit quia nisi accusantium natus voluptatem. Explicabo corporis eligendi ut ut sapiente ut qui quidem. Optio amet velit aut delectus. Sed alias asperiores perspiciatis deserunt omnis. Mollitia unde id in.", + "publicationDate": "1999-02-13T00:00:00+07:00", + "reviews": [], + "title": "Voluptas et tempora repellat corporis excepturi." + }, + "headers": { + "Content-Type": "application/ld+json; charset=utf-8" + }, + "matchingRules": { + "body": { + "$.author": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.description": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.publicationDate": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "^\\d{4}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d([+-][0-2]\\d:[0-5]\\d|Z)$" + } + ] + }, + "$.title": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$['@id']": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "^\\/api\\/books\\/[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$" + } + ] + } + }, + "header": {} + }, + "status": 200 + } + } + ], + "metadata": { + "pact-python": { + "version": "2.0.1b0" + }, + "pactRust": { + "ffi": "0.4.7", + "mockserver": "1.2.3", + "models": "1.1.9" + }, + "pactSpecification": { + "version": "3.0.0" + } + }, + "provider": { + "name": "http-provider" + } +} \ No newline at end of file diff --git a/examples/pacts/http-consumer-2-http-provider.json b/examples/pacts/http-consumer-2-http-provider.json new file mode 100644 index 000000000..2bad425f5 --- /dev/null +++ b/examples/pacts/http-consumer-2-http-provider.json @@ -0,0 +1,42 @@ +{ + "consumer": { + "name": "http-consumer-2" + }, + "interactions": [ + { + "description": "A PUT request to generate book cover", + "providerStates": [ + { + "name": "A book with id fb5a885f-f7e8-4a50-950f-c1a64a94d500 is required" + } + ], + "request": { + "body": [], + "headers": { + "Content-Type": "application/json" + }, + "method": "PUT", + "path": "/api/books/fb5a885f-f7e8-4a50-950f-c1a64a94d500/generate-cover" + }, + "response": { + "status": 204 + } + } + ], + "metadata": { + "pact-python": { + "version": "2.0.1b0" + }, + "pactRust": { + "ffi": "0.4.7", + "mockserver": "1.2.3", + "models": "1.1.9" + }, + "pactSpecification": { + "version": "3.0.0" + } + }, + "provider": { + "name": "http-provider" + } +} \ No newline at end of file diff --git a/examples/pacts/message-consumer-2-message-provider.json b/examples/pacts/message-consumer-2-message-provider.json new file mode 100644 index 000000000..bd810e992 --- /dev/null +++ b/examples/pacts/message-consumer-2-message-provider.json @@ -0,0 +1,49 @@ +{ + "consumer": { + "name": "message-consumer-2" + }, + "messages": [ + { + "contents": { + "uuid": "fb5a885f-f7e8-4a50-950f-c1a64a94d500" + }, + "description": "Book (id fb5a885f-f7e8-4a50-950f-c1a64a94d500) created message", + "matchingRules": { + "body": { + "$.uuid": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$" + } + ] + } + } + }, + "metadata": { + "contentType": "application/json" + }, + "providerStates": [ + { + "name": "A book with id fb5a885f-f7e8-4a50-950f-c1a64a94d500 is required" + } + ] + } + ], + "metadata": { + "pact-python": { + "version": "2.0.1b0" + }, + "pactRust": { + "ffi": "0.4.7", + "models": "1.1.9" + }, + "pactSpecification": { + "version": "3.0.0" + } + }, + "provider": { + "name": "message-provider" + } +} \ No newline at end of file diff --git a/examples/pacts/pact-consumer-one-pact-provider-one.json b/examples/pacts/pact-consumer-one-pact-provider-one.json new file mode 100644 index 000000000..79c031487 --- /dev/null +++ b/examples/pacts/pact-consumer-one-pact-provider-one.json @@ -0,0 +1,32 @@ +{ + "consumer": { + "name": "pact-consumer-one" + }, + "provider": { + "name": "pact-provider-one" + }, + "interactions": [ + { + "description": "Data is requested from provider-one", + "providerState": "Some data exists to be returned by provider-one endpoint", + "request": { + "method": "get", + "path": "/test-provider-one" + }, + "response": { + "status": 200, + "headers": { + "Content-type": "application/json" + }, + "body": { + "answer": 42 + } + } + } + ], + "metadata": { + "pactSpecification": { + "version": "2.0.0" + } + } +} \ No newline at end of file diff --git a/examples/pacts/v2-http.json b/examples/pacts/v2-http.json new file mode 100644 index 000000000..3cc870c0a --- /dev/null +++ b/examples/pacts/v2-http.json @@ -0,0 +1,76 @@ +{ + "consumer": { + "name": "Example App" + }, + "provider": { + "name": "Example API" + }, + "interactions": [ + { + "description": "a request for an alligator", + "providerState": "there is an alligator named Mary", + "request": { + "method": "get", + "path": "/alligators/Mary", + "headers": { + "Accept": "application/json" + } + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json;charset=utf-8" + }, + "body": { + "name": "Mary" + }, + "matchingRules": { + "$.body.name": { + "match": "type" + } + } + } + }, + { + "description": "a request for an alligator", + "providerState": "there is not an alligator named Mary", + "request": { + "method": "get", + "path": "/alligators/Mary", + "headers": { + "Accept": "application/json" + } + }, + "response": { + "status": 404, + "headers": {} + } + }, + { + "_id": "e57e7ac251a8bd078fcb81cad1e577cbafebcef5", + "description": "a request for an alligator", + "providerState": "an error occurs retrieving an alligator", + "request": { + "method": "get", + "path": "/alligators/Mary", + "headers": { + "Accept": "application/json" + } + }, + "response": { + "status": 500, + "headers": { + "Content-Type": "application/json;charset=utf-8" + }, + "body": { + "error": "Argh!!!" + } + } + } + ], + "metadata": { + "pactSpecification": { + "version": "2.0.0" + } + } +} \ No newline at end of file diff --git a/examples/pacts/v3-http.json b/examples/pacts/v3-http.json new file mode 100644 index 000000000..982d91f19 --- /dev/null +++ b/examples/pacts/v3-http.json @@ -0,0 +1,154 @@ +{ + "consumer": { + "name": "http-consumer-1" + }, + "interactions": [ + { + "description": "A POST request to create book", + "providerStates": [ + { + "name": "No book fixtures required" + } + ], + "request": { + "body": { + "author": "Margaret Atwood", + "description": "Brilliantly conceived and executed, this powerful evocation of twenty-first century America gives full rein to Margaret Atwood's devastating irony, wit and astute perception.", + "isbn": "0099740915", + "publicationDate": "1985-07-31T00:00:00+00:00", + "title": "The Handmaid's Tale" + }, + "headers": { + "Content-Type": "application/json" + }, + "matchingRules": { + "body": { + "$.author": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.description": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.isbn": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.publicationDate": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "^\\d{4}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d([+-][0-2]\\d:[0-5]\\d|Z)$" + } + ] + }, + "$.title": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + }, + "header": {} + }, + "method": "POST", + "path": "/api/books" + }, + "response": { + "body": { + "@context": "/api/contexts/Book", + "@id": "/api/books/0114b2a8-3347-49d8-ad99-0e792c5a30e6", + "@type": "Book", + "author": "Melisa Kassulke", + "description": "Quaerat odit quia nisi accusantium natus voluptatem. Explicabo corporis eligendi ut ut sapiente ut qui quidem. Optio amet velit aut delectus. Sed alias asperiores perspiciatis deserunt omnis. Mollitia unde id in.", + "publicationDate": "1999-02-13T00:00:00+07:00", + "reviews": [], + "title": "Voluptas et tempora repellat corporis excepturi." + }, + "headers": { + "Content-Type": "application/ld+json; charset=utf-8" + }, + "matchingRules": { + "body": { + "$.author": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.description": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.publicationDate": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "^\\d{4}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d([+-][0-2]\\d:[0-5]\\d|Z)$" + } + ] + }, + "$.title": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$['@id']": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "^\\/api\\/books\\/[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$" + } + ] + } + }, + "header": {} + }, + "status": 200 + } + } + ], + "metadata": { + "pact-python": { + "ffi": "0.3.15" + }, + "pactRust": { + "ffi": "0.3.15", + "mockserver": "0.9.5", + "models": "1.0.0" + }, + "pactSpecification": { + "version": "3.0.0" + } + }, + "provider": { + "name": "http-provider" + } + } \ No newline at end of file diff --git a/examples/pacts/v3-message.json b/examples/pacts/v3-message.json new file mode 100644 index 000000000..ab1a3ced7 --- /dev/null +++ b/examples/pacts/v3-message.json @@ -0,0 +1,46 @@ +{ + "consumer": { + "name": "message-consumer-2" + }, + "messages": [ + { + "contents": { + "uuid": "fb5a885f-f7e8-4a50-950f-c1a64a94d500" + }, + "description": "Book (id fb5a885f-f7e8-4a50-950f-c1a64a94d500) created message", + "matchingRules": { + "body": { + "$.uuid": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$" + } + ] + } + } + }, + "metadata": { + "contentType": "application/json" + }, + "providerStates": [ + { + "name": "A book with id fb5a885f-f7e8-4a50-950f-c1a64a94d500 is required" + } + ] + } + ], + "metadata": { + "pactRust": { + "ffi": "0.3.15", + "models": "1.0.0" + }, + "pactSpecification": { + "version": "3.0.0" + } + }, + "provider": { + "name": "message-provider" + } + } \ No newline at end of file diff --git a/examples/proto/area_calculator.proto b/examples/proto/area_calculator.proto new file mode 100644 index 000000000..a622379e9 --- /dev/null +++ b/examples/proto/area_calculator.proto @@ -0,0 +1,53 @@ +syntax = "proto3"; + +package area_calculator; + +option php_generic_services = true; +option go_package = "io.pact/area_calculator"; + +service Calculator { + rpc calculateOne (ShapeMessage) returns (AreaResponse) {} + rpc calculateMulti (AreaRequest) returns (AreaResponse) {} +} + +message ShapeMessage { + oneof shape { + Square square = 1; + Rectangle rectangle = 2; + Circle circle = 3; + Triangle triangle = 4; + Parallelogram parallelogram = 5; + } +} + +message Square { + float edge_length = 1; +} + +message Rectangle { + float length = 1; + float width = 2; +} + +message Circle { + float radius = 1; +} + +message Triangle { + float edge_a = 1; + float edge_b = 2; + float edge_c = 3; +} + +message Parallelogram { + float base_length = 1; + float height = 2; +} + +message AreaRequest { + repeated ShapeMessage shapes = 1; +} + +message AreaResponse { + repeated float value = 1; +} \ No newline at end of file diff --git a/examples/todo/README.md b/examples/todo/README.md new file mode 100644 index 000000000..19ac807c0 --- /dev/null +++ b/examples/todo/README.md @@ -0,0 +1,34 @@ +## Example Consumer and provider test using Pact V3 features + +This is an example project with a test that uses V3 Pact features. It has an example test for both JSON +and XML format. It has been ported from the Pact-JS project. + +## To run it + +Using the beta version of Pact-Python with V3 support, you need to do the following: + +1. In the root of the GitHub repo + +```console +$ make deps +$ make package +``` + +2. Run the test + +```console +$ make todo +``` + +## V3 features + +This has 3 tests. The first uses generators and matchers for numbers and datetime values. The second +test deals with XML responses. The last one posts an image to the provider. + +## Enabling debug logs + +The `PACT_LOG_LEVEL` environment variable controls the log output from the Rust libs. + +```console +$ PACT_LOG_LEVEL=debug pytest --capture=no +``` diff --git a/examples/todo/__init__.py b/examples/todo/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/todo/requirements.txt b/examples/todo/requirements.txt new file mode 100644 index 000000000..68bad3878 --- /dev/null +++ b/examples/todo/requirements.txt @@ -0,0 +1,9 @@ +pytest-flask==1.1.0 +json2xml==3.8.0; python_version < '3.7' +json2xml==3.21.0; python_version >= '3.7' +Flask==2.0.3; python_version < '3.7' +Flask==2.2.5; python_version >= '3.7' +pytest==6.2.5; python_version < '3.7' +pytest==7.1.3; python_version >= '3.7' +requests==2.26.0; python_version < '3.7' +requests>=2.28.0; python_version >= '3.7' \ No newline at end of file diff --git a/examples/todo/run_pytest.sh b/examples/todo/run_pytest.sh new file mode 100755 index 000000000..ad6ed092d --- /dev/null +++ b/examples/todo/run_pytest.sh @@ -0,0 +1,4 @@ +#!/bin/bash +set -o pipefail + +pytest tests diff --git a/examples/todo/src/__init__.py b/examples/todo/src/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/todo/src/todo_consumer.py b/examples/todo/src/todo_consumer.py new file mode 100644 index 000000000..e12cc07c0 --- /dev/null +++ b/examples/todo/src/todo_consumer.py @@ -0,0 +1,39 @@ +import os +import requests +import xml.etree.ElementTree as ET +import platform +target_platform = platform.platform().lower() +is_not_win = any(substring in target_platform for substring in ['linux', 'macos']) +is_gha = os.getenv("ACT") == "true" or os.getenv("GITHUB_ACTIONS") == "true" +mime_type = 'image/jpeg' if is_not_win and is_gha else 'application/octet-stream' + +class TodoConsumer(object): + def __init__(self, base_uri): + self.base_uri = base_uri + + def get_projects(self, format='json'): + """Fetch all the projects from the server. Supports XML and JSON formats""" + uri = self.base_uri + '/projects' + response = requests.get(uri, + headers={'Accept': 'application/' + format}) + # TODO add query param support in PactV3 interface + # response = requests.get(uri, + # headers={'Accept': 'application/' + format}, + # params={'from': 'today'}) + response.raise_for_status() + if format == 'json': + print(f'returning json: {response.json()}') + return response.json() + else: + print(f'returning xml: {response.text}') + return ET.fromstring(response.text) + + def post_image(self, id, file_path): + """Store an image against a project""" + print(id) + print(file_path) + print(mime_type) + print(target_platform) + uri = self.base_uri + '/projects/' + str(id) + '/images' + response = requests.post(uri, data=open(file_path, 'rb'), headers={'Content-Type': mime_type}) + response.raise_for_status() diff --git a/examples/todo/src/todo_provider.py b/examples/todo/src/todo_provider.py new file mode 100644 index 000000000..83ac7296b --- /dev/null +++ b/examples/todo/src/todo_provider.py @@ -0,0 +1,64 @@ +from flask import Flask, Response, request, jsonify +from json2xml import json2xml +from json2xml.utils import readfromstring +import json + + +def create_app(): + app = Flask(__name__) + + @app.route('/') + def index(): + return '' + + @app.route('/projects') + def projects(): + # TODO:- Updated response to matche consumer req for xml + # should use provider states here or matchers + todo_response = [ + { + 'id': 1, + 'name': "Project 1", + 'type': "activity", + 'due': "2016-02-11T09:46:56.023Z", + 'tasks': [ + { + 'done': True, + 'id': 1, + 'name': "Do the laundry", + }, + { + 'done': False, + 'id': 2, + 'name': "Do the dishes", + }, + { + 'done': False, + 'id': 3, + 'name': "Do the backyard", + }, + { + 'done': False, + 'id': 4, + 'name': "Do nothing", + }, + ] + } + ] + if request.headers['accept'] == 'application/xml': + print("todo_response") + print(json2xml.Json2xml(readfromstring(json.dumps(todo_response)), wrapper='projects', pretty=True).to_xml()) + return Response(json2xml.Json2xml(readfromstring(json.dumps(todo_response)), wrapper='projects').to_xml(), mimetype='application/xml') + else: + return jsonify(todo_response) + + @app.route('/projects//images', methods=['POST']) + def images(id): + # TODO:- do something with the binary that has been uploaded + return Response(status=201) + + return app + + +if __name__ == '__main__': + create_app().run(debug=True, port=5001) diff --git a/examples/todo/tests/__init__.py b/examples/todo/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/todo/tests/example.jpg b/examples/todo/tests/example.jpg new file mode 100644 index 000000000..39ed39794 Binary files /dev/null and b/examples/todo/tests/example.jpg differ diff --git a/examples/todo/tests/test_todo_consumer.py b/examples/todo/tests/test_todo_consumer.py new file mode 100644 index 000000000..0fda3544a --- /dev/null +++ b/examples/todo/tests/test_todo_consumer.py @@ -0,0 +1,184 @@ +import os +import pytest +from ..src.todo_consumer import TodoConsumer +from pact import PactV3 +from pact.matchers_v3 import EachLike, Integer, Like, AtLeastOneLike +# from pact.matchers_v3 import EachLike, Integer, Like, DateTime, AtLeastOneLike +# import xml.etree.ElementTree as ET +import platform +target_platform = platform.platform().lower() +is_not_win = any(substring in target_platform for substring in ['linux', 'macos']) +is_gha = os.getenv("ACT") == "true" or os.getenv("GITHUB_ACTIONS") == "true" +mime_type = 'image/jpeg' if is_not_win and is_gha else 'application/octet-stream' +@pytest.fixture +def provider(): + return PactV3('TodoApp', 'TodoServiceV3') + + +def test_get_projects_as_json(provider: PactV3): + (provider + .new_http_interaction('same_as_upon_receiving') + .given('i have a list of projects') + .upon_receiving('a request for projects') + .with_request(method="GET", path="/projects", query=None, headers=[{"name": 'Accept', "value": "application/json"}]) + # .with_request(method="GET", path="/projects", query={'from': "today"}, headers=[{"name": 'Accept', "value": "application/json"}]) + .will_respond_with( + headers=[{"name": 'Content-Type', "value": "application/json"}], + body=EachLike({ + 'id': Integer(1), + 'name': Like("Project 1"), + # 'due': DateTime("yyyy-MM-dd'T'HH:mm:ss.SSSX", "2016-02-11T09:46:56.023Z"), + 'tasks': AtLeastOneLike({ + 'id': Integer(), + 'name': Like("Do the laundry"), + 'done': Like(True) + }, examples=4) + }))) + + with provider: + provider.start_service() + print(f"Mock server is running at {provider.mock_server_port}") + + todo = TodoConsumer(f"http://127.0.0.1:{provider.mock_server_port}") + projects = todo.get_projects() + + assert len(projects) == 1 + assert projects[0]['id'] == 1 + assert len(projects[0]['tasks']) == 4 + assert projects[0]['tasks'][0]['id'] != 101 + provider.verify() + +@pytest.mark.skipif( + True, + reason="https://github.com/pact-foundation/pact-reference/issues/305") +# TODO:- This test in unreliable, sometimes xml is not returned from the mock provider +def test_with_xml_requests(provider: PactV3): + (provider + .new_http_interaction('same_as_upon_receiving') + .given('i have a list of projects') + .upon_receiving('a request for projects in XML') + # TODO:- support passing query to native_mock_server + .with_request(method="GET", path="/projects", query=None, headers=[{"name": 'Accept', "value": "application/xml"}]) + # .with_request(method="GET", path="/projects", query={'from': "today"}, headers=[{"name":'Accept',"value": "application/xml"}]) + .will_respond_with( + headers=[ + # TODO:- if content-type not set, xml body not returned + # TODO:- Test is unreliable, ran locally + # TODO:- Probably easier just to ask for a content_type argument? + # TODO:- Getting written to pact file as + # TODO:- "headers": { + # TODO:- "Content-Type": "application/xml", + # TODO:- "content-type": ", application/xml" + # TODO:- }, + {"name": "Content-Type", "value": "application/xml"}, + {"name": "content-type", "value": "application/xml"} + ], + body=''' + + + 1 + + + 1 + Do the laundry + true + + + 2 + Do the dishes + false + + + 3 + Do the backyard + false + + + 4 + Do nothing + false + + + + ''' + + )) + with provider: + mock_server_port = provider.start_service() + print(f"Mock server is running at {mock_server_port}") + + todo = TodoConsumer(f"http://127.0.0.1:{mock_server_port}") + projects = todo.get_projects('xml') + assert len(projects) == 1 + # print(ET.tostring(projects[0], encoding='utf8').decode('utf8')) + assert projects[0][0].text == '1' + tasks = projects[0].findall('tasks')[0] + assert len(tasks) == 4 + assert tasks[0][0].text == '1' + assert tasks[0][1].text == 'Do the laundry' + provider.verify() + + +def test_with_image_upload(provider: PactV3): + binary_file_path = os.path.abspath(os.path.join(os.path.dirname(__file__), 'example.jpg')) + (provider + .new_http_interaction('same_as_upon_receiving').given('i have a project', {'id': 1001, 'name': 'Home Chores'}) + .upon_receiving('a request to store an image against the project') + .with_request_with_binary_file( + headers=[{"name": 'content-type', "value": mime_type}], + file=binary_file_path, + path="/projects/1001/images") + .will_respond_with(status=201)) + + with provider: + provider.start_service() + print(f"Mock server is running at {provider.mock_server_port}") + + todo = TodoConsumer(f"http://127.0.0.1:{provider.mock_server_port}") + todo.post_image(1001, binary_file_path) + provider.verify() + + +# TODO Create XMLBuilder which supports matchers. + + # (provider.given('i have a list of projects') + # .upon_receiving('a request for projects in XML') + # .with_request(method="GET", path="/projects", query={'from': "today"}, headers={'Accept': "application/xml"}) + # .will_respond_with( + # headers={"Content-Type": "application/xml"}, + # body= new XmlBuilder("1.0", "UTF-8", "ns1:projects").build(el => { + # el.setAttributes({ + # id: "1234", + # "xmlns:ns1": "http://some.namespace/and/more/stuff", + # }) + # el.eachLike( + # "ns1:project", + # { + # id: integer(1), + # type: "activity", # noqa: F821 + # name: string("Project 1"), + # // TODO: implement XML generators + # // due: timestamp( + # // "yyyy-MM-dd'T'HH:mm:ss.SZ", + # // "2016-02-11T09:46:56.023Z" + # // ), + # }, + # project => { + # project.appendElement("ns1:tasks", {}, task => { + # task.eachLike( + # "ns1:task", + # { + # id: integer(1), + # name: string("Task 1"), + # done: boolean(true), + # }, + # null, + # { examples: 5 } + # ) + # }) + # }, + # { examples: 2 } + # ) + # }), + + # )) diff --git a/examples/todo/tests/test_todo_provider.py b/examples/todo/tests/test_todo_provider.py new file mode 100644 index 000000000..f44f5fa6c --- /dev/null +++ b/examples/todo/tests/test_todo_provider.py @@ -0,0 +1,60 @@ +import multiprocessing +import pytest +from ..src.todo_provider import create_app +from pact import VerifierV3 +from flask import url_for +import platform +target_platform = platform.platform().lower() + +@pytest.mark.skipif( + 'windows' in target_platform, + reason="https://github.com/jarus/flask-testing/issues/44") +@pytest.fixture(scope='session') +def app(): + if 'macos' in target_platform: + multiprocessing.set_start_method("fork") # Issue on MacOS - https://github.com/pytest-dev/pytest-flask/issues/104 + # Also an issue on windows - using fork or using spawn + # see https://github.com/jarus/flask-testing/issues/44 + # and https://github.com/uqfoundation/dill/issues/245 + return create_app() + +@pytest.mark.skipif( + 'windows' in target_platform, + reason="https://github.com/jarus/flask-testing/issues/44") +@pytest.mark.usefixtures('live_server') +def test_pact_json(): + verifier = VerifierV3(provider='TodoServiceV3', + provider_base_url=url_for('index', _external=True)) + result, logs = verifier.verify_pacts( + sources=['./pacts/TodoApp-TodoServiceV3.json'], + filter_description='"a request for projects"' + # Note filter_description takes a regex, so it also matches a request for projects in XML + # unless wrapped in quotes. + ) + assert result == 0 + +@pytest.mark.skipif( + 'windows' in target_platform, + reason="https://github.com/jarus/flask-testing/issues/44") +@pytest.mark.usefixtures('live_server') +def test_pact_image(): + verifier = VerifierV3(provider='TodoServiceV3', + provider_base_url=url_for('index', _external=True)) + result, logs = verifier.verify_pacts( + sources=['./pacts/TodoApp-TodoServiceV3.json'], + filter_description='a request to store an image against the project' + ) + assert result == 0 + +@pytest.mark.skipif( + 'windows' in target_platform, + reason="https://github.com/pact-foundation/pact-reference/issues/305") +@pytest.mark.usefixtures('live_server') +def test_pact_xml(): + verifier = VerifierV3(provider='TodoServiceV3', + provider_base_url=url_for('index', _external=True)) + result, logs = verifier.verify_pacts( + sources=['./pacts/TodoApp-TodoServiceV3.json'], + filter_description='a request for projects in XML', + ) + assert result == 0 diff --git a/pact/__init__.py b/pact/__init__.py index e81016348..6bdef3b2f 100644 --- a/pact/__init__.py +++ b/pact/__init__.py @@ -8,9 +8,15 @@ from .pact import Pact from .provider import Provider from .verifier import Verifier +from .pact_v3 import PactV3 +from .matchers_v3 import V3Matcher +from .verifier_v3 import VerifierV3 from .__version__ import __version__ # noqa: F401 -__all__ = ('Broker', 'Consumer', 'EachLike', 'Like', - 'MessageConsumer', 'MessagePact', 'MessageProvider', - 'Pact', 'Provider', 'SomethingLike', 'Term', 'Format', 'Verifier') +__all__ = ('Broker', 'Consumer', 'EachLike', 'Like', 'MessageConsumer', 'MessagePact', "MessageProvider", + 'Pact', 'Provider', 'SomethingLike', 'Term', 'Format', 'Verifier', + 'PactV3', + 'VerifierV3', + V3Matcher + ) diff --git a/pact/__version__.py b/pact/__version__.py index a9c04f731..e132b2f25 100644 --- a/pact/__version__.py +++ b/pact/__version__.py @@ -1,3 +1,3 @@ """Pact version info.""" -__version__ = '2.0.1' +__version__ = '2.0.1b0' diff --git a/pact/cli/verify.py b/pact/cli/verify.py index b388a5300..ad8dca66f 100644 --- a/pact/cli/verify.py +++ b/pact/cli/verify.py @@ -1,5 +1,6 @@ """Methods to verify previously created pacts.""" import sys +from setup import PACT_STANDALONE_VERSION from pact.verify_wrapper import path_exists, expand_directories, VerifyWrapper @@ -8,6 +9,7 @@ @click.command() @click.argument('pacts', nargs=-1) +@click.version_option("pact-ruby-standalone-v{}".format(PACT_STANDALONE_VERSION)) @click.option( 'base_url', '--provider-base-url', help='Base URL of the provider to verify against.', @@ -193,12 +195,12 @@ def main(pacts, base_url, pact_url, pact_urls, states_url, states_setup_url, options = dict(filter(lambda item: item[1] != '', options.items())) options = dict(filter(lambda item: is_empty_list(item), options.items())) - success, logs = VerifyWrapper().call_verify(*all_pact_urls, - provider=provider, - provider_base_url=base_url, - enable_pending=enable_pending, - include_wip_pacts_since=include_wip_pacts_since, - **options) + success, logs = VerifyWrapper().verify(*all_pact_urls, + provider=provider, + provider_base_url=base_url, + enable_pending=enable_pending, + include_wip_pacts_since=include_wip_pacts_since, + **options) sys.exit(success) diff --git a/pact/constants.py b/pact/constants.py index 9c6f7b117..c0677101f 100644 --- a/pact/constants.py +++ b/pact/constants.py @@ -46,3 +46,6 @@ def provider_verifier_exe(): VERIFIER_PATH = normpath(join( dirname(__file__), 'bin', 'pact', 'bin', provider_verifier_exe())) + +FFI_LIB_PATH = normpath(join( + dirname(__file__), 'bin')) diff --git a/pact/ffi/cli/verify.py b/pact/ffi/cli/verify.py new file mode 100644 index 000000000..9ec1df5cc --- /dev/null +++ b/pact/ffi/cli/verify.py @@ -0,0 +1,126 @@ +"""Methods to verify previously created pacts.""" +import re +import sys +from typing import Callable + +import click +from click.core import ParameterSource +from pact.ffi.verifier import Verifier, Arguments + + +def cli_options(): + """ + Dynamically construct the Click CLI options available to interface with the current version of the FFI library. + + This attempts to ensure there cannot be a mismatch between the two, and + means there doesn't need to be a duplication of logic. + """ + def inner_func(function: Callable) -> Callable: + verifier = Verifier() + args: Arguments = verifier.cli_args() + + # Handle the options requiring values + for opt in args.options: + type_choice = click.Choice(opt.possible_values) if opt.possible_values else None + + # Let the user know if an ENV can be used here instead + _help = "{}. Alternatively: {} if opt.env else ''".format(opt.help, click.style(opt.env, bold=True)) + + if opt.short: + function = click.option( + "-{}".format(opt.short), + "--{}".format(opt.long), + help=_help, + type=type_choice, + default=opt.default_value, + multiple=opt.multiple, + envvar=opt.env, + )(function) + else: + function = click.option( + "--{}".format(opt.long), + help=_help, + type=type_choice, + default=opt.default_value, + multiple=opt.multiple, + envvar=opt.env, + )(function) + + # Handle the boolean flags + for flag in args.flags: + # Let the user know if an ENV can be used here instead + # Note: future proofing, there do not seem to be any as of Pact FFI Library 0.0.2 + _help = "{}. Alternatively: {} if flag.env else ''".format(flag.help, click.style(flag.env, bold=True)) + + function = click.option("--{}".format(flag.long), help=_help, envvar=flag.env, is_flag=True)(function) + + function = click.option( + f'{"--debug-click"}', + help="Display arguments passed to the Pact Rust FFI library, for debugging pact-verifier wrapper", + is_flag=True, + )(function) + + return function + + return inner_func + + +@click.command(name="pact-verifier-ffi", context_settings=dict(max_content_width=120)) +@click.version_option("libpact_ffi-v{}".format(Verifier().version())) +@cli_options() +def main(**kwargs): + """ + Verify one or more contracts against a provider service. + + Minimal example: pact-verifier --hostname localhost --port 8080 -d ./pacts + """ + # Since we may only have default args, which are SOME args and we don't know + # which are required, make sure we have at least one CLI argument + ctx = click.get_current_context() + if not [key for key, value in kwargs.items() if ctx.get_parameter_source(key) != ParameterSource.DEFAULT]: + click.echo(ctx.get_help()) + sys.exit(0) + + verifier = Verifier() + + cli_args = verifier.args_dict_to_str(kwargs) + + if kwargs.get("debug_click"): + click.echo("kwargs received:") + click.echo(kwargs) + click.echo("") + + # To try and avoid confusion and help with debugging, notify the user when ENVs are being used + arguments_from_envs = [ + key for key, value in kwargs.items() if ctx.get_parameter_source(key) == ParameterSource.ENVIRONMENT + ] + if arguments_from_envs: + click.echo("The following arguments are using values provided by ENVs: {}".format(arguments_from_envs)) + click.echo("") + + click.echo("CLI args to send via FFI:") + click.echo(cli_args) + click.echo("") + + result = verifier.verify(cli_args) + + if kwargs.get("debug_click"): + click.echo("Result from FFI call to verify:") + click.echo("{}=".format(result.return_code)) + click.echo("{}=".format(result.logs)) + + # If the FFI method returned some log output + if result.logs: + for log in result.logs: + m = re.search('.*error verifying Pact: "error: (.*)", kind: .*', log) + if m: + for line in m.group(1).split("\\n"): + click.echo(line) + else: + click.echo(log) + + sys.exit(result.return_code) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pact/ffi/log.py b/pact/ffi/log.py new file mode 100644 index 000000000..113cc30fb --- /dev/null +++ b/pact/ffi/log.py @@ -0,0 +1,34 @@ +"""For handling the logging setup and output from the FFI library. + +As per: https://docs.rs/pact_ffi/0.0.2/pact_ffi/log/index.html +""" +from enum import unique, Enum + + +@unique +class LogToBufferStatus(Enum): + """Return codes from a request to setup a logger. + + As per: https://docs.rs/pact_ffi/0.0.2/pact_ffi/log/fn.pactffi_logger_attach_sink.html#error-handling + """ + + SUCCESS = 0 # Operation succeeded + CANT_SET_LOGGER_OR_LOGGER_SET = -1 # Can't set logger (applying the logger failed, perhaps because one is applied already). + NO_LOGGER = -2 # No logger has been initialized + SPECIFIER_NOT_UTF8 = -3 # The sink specifier was not UTF-8 encoded + UNKNOWN_SINK_TYPE = -4 # The sink type specified is not a known type + MISSING_FILE_PATH = -5 # No file path was specified in the sink specification + CANT_OPEN_SINK_TO_FILE = -6 # Opening a sink to the given file failed + CANT_CONSTRUCT_SINK = -7 # Can't construct sink + + +@unique +class LogLevel(Enum): + """Log levels which can be used by the Verifier.""" + + OFF = 0 + ERROR = 1 + WARN = 2 + INFO = 3 + DEBUG = 4 + TRACE = 5 diff --git a/pact/ffi/native_mock_server.py b/pact/ffi/native_mock_server.py new file mode 100644 index 000000000..bb3491468 --- /dev/null +++ b/pact/ffi/native_mock_server.py @@ -0,0 +1,627 @@ +"""Mock Server Wrapper to pact reference dynamic libraries using FFI.""" +from enum import Enum, unique +from typing import List, NamedTuple +from pact.__version__ import __version__ +from pact.ffi.pact_ffi import PactFFI +from pact.ffi.utils import se +import json + +@unique +class MockServerStatus(Enum): + """Return codes from a verify request. + + As per: https://docs.rs/pact_ffi/0.3.4/pact_ffi/mock_server/index.html#mock_server_matched + """ + + SUCCESS = True # Operation succeeded + MOCK_SERVER_FAILED = False # The mock server process failed, see output for errors + # NULL_POINTER = 2 # A null pointer was received + # PANIC = 3 # The method panicked + # INVALID_ARGS = 4 # Invalid arguments were provided to the verification process + + +class MockServerResult(NamedTuple): + """Wrap up the return code, and log output.""" + + return_code: MockServerStatus + logs: List[str] + +class MockServer(PactFFI): + """A Pact MockServer Wrapper. + + This interfaces with the Rust FFI crate pact_ffi, specifically the + `mock_server` & `plugins` modules. + + .. mock_server: + https://docs.rs/pact_ffi/0.3.4/pact_ffi/mock_server/index.html + .. plugins: + https://docs.rs/pact_ffi/0.3.4/pact_ffi/plugins/index.html + """ + + def __new__(cls): + """Create a new instance of the MockServer.""" + return super(MockServer, cls).__new__(cls) + + def new_pact(self, consumer: str, provider: str): + """ + Create a new Pact model and returns a handle to it. + + * `consumer_name` - The name of the consumer for the pact. + * `provider_name` - The name of the provider for the pact. + + Returns a new `PactHandle`. The handle will need to be freed with the `pactffi_free_pact_handle` + method to release its resources. + """ + pact_handle = self.lib.pactffi_new_pact(se(consumer), se(provider)) + self.lib.pactffi_with_pact_metadata(pact_handle, b'pact-python', b'version', se(__version__)) + return pact_handle + + def with_specification(self, pact_handle, version=int): + """ + Set the specification version for a given Pact model. + + Returns false if the interaction or Pact can't be + modified (i.e. the mock server for it has already started) or the version is invalid. + + * `pact` - Handle to a Pact model + * `version` - the spec version to use + """ + return self.lib.pactffi_with_specification(pact_handle, version) + + def using_plugin(self, pact_handle, name=str, version=str): + """ + Add a plugin to be used by the test. + + The plugin needs to be installed correctly for this function to work. + + * `plugin_name` is the name of the plugin to load. + * `plugin_version` is the version of the plugin to load. It is optional, and can be NULL. + + Returns zero on success, and a positive integer value on failure. + + Note that plugins run as separate processes, so will need to be cleaned up afterwards by + calling `pactffi_cleanup_plugins` otherwise you will have plugin processes left running. + + # Safety + + `plugin_name` must be a valid pointer to a NULL terminated string. `plugin_version` may be null, + and if not NULL must also be a valid pointer to a NULL terminated string. Invalid + pointers will result in undefined behaviour. + + # Errors + + * `1` - A general panic was caught. + * `2` - Failed to load the plugin. + * `3` - Pact Handle is not valid. + + When an error errors, LAST_ERROR will contain the error message. + """ + return self.lib.pactffi_using_plugin(pact_handle, se(name), se(version)) + + def new_interaction(self, pact_handle, description=str): + """ + Create a new HTTP Interaction and returns a handle to it. + + * `description` - The interaction description. It needs to be unique for each interaction. + + Returns a new `InteractionHandle`. + """ + return self.lib.pactffi_new_interaction(pact_handle, se(description)) + + def new_message(self, pact_handle, description=str): + """ + Create a new Pact Message model and returns a handle to it. + + * `consumer_name` - The name of the consumer for the pact. + * `provider_name` - The name of the provider for the pact. + + Returns a new `MessagePactHandle`. The handle will need to be freed with the `pactffi_free_message_pact_handle` + function to release its resources. + """ + return self.lib.pactffi_new_message(pact_handle, se(description)) + + def upon_receiving(self, interaction_handle, description=str): + """ + Set the description for the Interaction. + + Returns false if the interaction or Pact can't be + modified (i.e. the mock server for it has already started) + + * `description` - The interaction description. It needs to be unique for each interaction. + """ + return self.lib.pactffi_upon_receiving(interaction_handle, se(description)) + + def given(self, interaction_handle, description=str): + """ + Add a provider state to the Interaction. + + Returns false if the interaction or Pact can't be + modified (i.e. the mock server for it has already started) + + * `description` - The provider state description. It needs to be unique. + """ + return self.lib.pactffi_given(interaction_handle, se(description)) + + def with_request(self, interaction_handle, method=str, path=str): + r""" + Configure the request for the Interaction. + + Returns false if the interaction or Pact can't be + modified (i.e. the mock server for it has already started) + + * `method` - The request method. Defaults to GET. + * `path` - The request path. Defaults to `/`. + + To include matching rules for the path (only regex really makes sense to use), include the + matching rule JSON format with the value as a single JSON document. I.e. + + ```c + const char* value = "{\"value\":\"/path/to/100\", \"pact:matcher:type\":\"regex\", \"regex\":\"\\/path\\/to\\/\\\\d+\"}"; + pactffi_with_request(handle, "GET", value); + ``` + See [IntegrationJson.md](https://github.com/pact-foundation/pact-reference/blob/master/rust/pact_ffi/IntegrationJson.md) + """ + return self.lib.pactffi_with_request(interaction_handle, se(method), se(path)) + + def with_header(self, interaction_handle, req_or_res="res" or "req", name=str, index=int, value=str): + r""" + Configure a header for the Interaction. + + Returns false if the interaction or Pact can't be + modified (i.e. the mock server for it has already started) + + * `part` - The part of the interaction to add the header to (Request or Response). + * `name` - the header name. + * `value` - the header value. + * `index` - the index of the value (starts at 0). You can use this to create a header with multiple values + + To setup a header with multiple values, you can either call this function multiple times + with a different index value, i.e. to create `x-id=2, 3` + + ```c + pactffi_with_header_v2(handle, InteractionPart::Request, "x-id", 0, "2"); + pactffi_with_header_v2(handle, InteractionPart::Request, "x-id", 1, "3"); + ``` + + Or you can call it once with a JSON value that contains multiple values: + + ```c + const char* value = "{\"value\": [\"2\",\"3\"]}"; + pactffi_with_header_v2(handle, InteractionPart::Request, "x-id", 0, value); + ``` + + To include matching rules for the header, include the matching rule JSON format with + the value as a single JSON document. I.e. + + ```c + const char* value = "{\"value\":\"2\", \"pact:matcher:type\":\"regex\", \"regex\":\"\\\\d+\"}"; + pactffi_with_header_v2(handle, InteractionPart::Request, "id", 0, value); + ``` + See [IntegrationJson.md](https://github.com/pact-foundation/pact-reference/blob/master/rust/pact_ffi/IntegrationJson.md) + + # Safety + The name and value parameters must be valid pointers to NULL terminated strings. + """ + return self.lib.pactffi_with_header_v2(interaction_handle, 0 if req_or_res == 'req' else 1, se(name), index, se(value)) + + def with_request_header(self, interaction_handle, name=str, index=int, value=str): + """ + Configure a request header for the Interaction. + + See with_header for full detail + """ + return self.with_header(interaction_handle, "req", name, index, value) + + def with_response_header(self, interaction_handle, name=str, index=int, value=str): + """ + Configure a response header for the Interaction. + + See with_header for full detail + """ + return self.with_header(interaction_handle, "res", name, index, value) + + def with_body(self, interaction_handle, req_or_res="res" or "req", content_type=str, body=str): + r""" + Add the body for the interaction. + + Returns false if the interaction or Pact can't be + modified (i.e. the mock server for it has already started) + + * `part` - The part of the interaction to add the body to (Request or Response). + * `content_type` - The content type of the body. Defaults to `text/plain`. Will be ignored if a content type + header is already set. + * `body` - The body contents. For JSON payloads, matching rules can be embedded in the body. See + [IntegrationJson.md](https://github.com/pact-foundation/pact-reference/blob/master/rust/pact_ffi/IntegrationJson.md) + + For HTTP and async message interactions, this will overwrite the body. With asynchronous messages, the + part parameter will be ignored. With synchronous messages, the request contents will be overwritten, + while a new response will be appended to the message. + + # Safety + + The interaction contents and content type must either be NULL pointers, or point to valid + UTF-8 encoded NULL-terminated strings. Otherwise, behaviour is undefined. + + # Error Handling + + If the contents is a NULL pointer, it will set the body contents as null. If the content + type is a null pointer, or can't be parsed, it will set the content type as TEXT. + Returns false if the interaction or Pact can't be modified (i.e. the mock server for it has + already started) or an error has occurred. + """ + if 'json' in content_type: + encoded_body = se(json.dumps(body)) + else: + encoded_body = se(body) + # print('testing pactffi_with_body') + # print(req_or_res) + # print(content_type) + # print(encoded_body) + return self.lib.pactffi_with_body(interaction_handle, 0 if req_or_res == 'req' else 1, se(content_type), encoded_body) + + def with_binary_file(self, interaction_handle, req_or_res="res" or "req", content_type=str, body=str): + """ + Add a binary file as the body with the expected content type and example contents. + + Will use a mime type matcher to match the body. + + Returns false if the interaction or Pact can't be modified (i.e. the mock server for it has already started) + + * `interaction` - Interaction handle to set the body for. + * `part` - Request or response part. + * `content_type` - Expected content type. + * `body` - example body contents in bytes + * `size` - number of bytes in the body + """ + length = len(body) + size = length + 1 + print(length) + print(size) + body_ptr = self.ffi.new("char[]", body) + return self.lib.pactffi_with_binary_file(interaction_handle, 0 if req_or_res == 'req' else 1, se(content_type), body_ptr, size) + + def with_request_with_binary_file(self, interaction_handle, content_type=str, body=str): + """ + Add a binary file as the request body with the expected content type and example contents. + + Will use a mime type matcher to match the body. + + Returns false if the interaction or Pact can't be modified (i.e. the mock server for it has already started) + + * `interaction` - Interaction handle to set the body for. + * `content_type` - Expected content type. + * `body` - example body contents in bytes + """ + return self.lib.with_binary_file(interaction_handle, 0, content_type, body) + + def with_response_with_binary_file(self, interaction_handle, content_type=str, body=str): + """ + Add a binary file as the response body with the expected content type and example contents. + + Will use a mime type matcher to match the body. + + Returns false if the interaction or Pact can't be modified (i.e. the mock server for it has already started) + + * `interaction` - Interaction handle to set the body for. + * `content_type` - Expected content type. + * `body` - example body contents in bytes + """ + return self.lib.with_binary_file(interaction_handle, 1, content_type, body) + + def with_request_body(self, interaction_handle, content_type=str, body=str): + """ + Add the request body for the interaction. + + See with_body for full detail + + """ + return self.with_body(interaction_handle, "req", content_type, body) + + def with_response_body(self, interaction_handle, content_type=str, body=str): + """ + Add the response body for the interaction. + + See with_body for full detail + + """ + return self.with_body(interaction_handle, "res", content_type, body) + + def response_status(self, interaction_handle, code=int): + """ + Configure the response for the Interaction. + + Returns false if the interaction or Pact can't be + modified (i.e. the mock server for it has already started) + + `status` - the response status. Defaults to 200. + """ + return self.lib.pactffi_response_status(interaction_handle, code) + + def message_expects_to_receive(self, message_handle, description=str): + """ + Set the description for the Message. + + * `description` - The message description. It needs to be unique for each message. + """ + self.lib.pactffi_message_expects_to_receive(message_handle, se(description)) + + def message_given(self, message_handle, description=str): + """ + Add a provider state to the Interaction. + + * `description` - The provider state description. It needs to be unique for each message + """ + self.lib.pactffi_message_given(message_handle, se(description)) + + def message_with_contents(self, message_handle, content_type=str, body=str): + """ + Add the contents of the Message. + + Accepts JSON, binary and other payload types. Binary data will be base64 encoded when serialised. + + Note: For text bodies (plain text, JSON or XML), you can pass in a C string (NULL terminated) + and the size of the body is not required (it will be ignored). For binary bodies, you need to + specify the number of bytes in the body. + + * `content_type` - The content type of the body. Defaults to `text/plain`, supports JSON structures with matchers and binary data. + * `body` - The body contents as bytes. For text payloads (JSON, XML, etc.), a C string can be used and matching rules can be embedded in the body. + * `content_type` - Expected content type (e.g. application/json, application/octet-stream) + * `size` - number of bytes in the message body to read. This is not required for text bodies (JSON, XML, etc.). + """ + if 'json' in content_type: + length = len(json.dumps(body)) + size = length + 1 + encoded_body = self.ffi.new("char[]", se(json.dumps(body))) + else: + length = len(body) + size = length + 1 + encoded_body = self.ffi.new("char[]", se(body)) + return self.lib.pactffi_message_with_contents(message_handle, se(content_type), encoded_body, size) + + def start_mock_server(self, pact_handle, hostname=str, port=int, transport=str, transport_config=str or None): + """ + Create a mock server for the provided Pact handle and transport. + + If the transport is not + provided (it is a NULL pointer or an empty string), will default to an HTTP transport. The + address is the interface bind to, and will default to the loopback adapter if not specified. + Specifying a value of zero for the port will result in the operating system allocating the port. + + Parameters: + * `pact` - Handle to a Pact model created with created with `pactffi_new_pact`. + * `addr` - Address to bind to (i.e. `127.0.0.1` or `[::1]`). + Must be a valid UTF-8 NULL-terminated string, or NULL or empty, + in which case the loopback adapter is used. + * `port` - Port number to bind to. A value of zero will result in the operating system allocating an available port. + * `transport` - The transport to use (i.e. http, https, grpc). + Must be a valid UTF-8 NULL-terminated string, or NULL or empty, + in which case http will be used. + * `transport_config` - (OPTIONAL) Configuration for the transport as a valid JSON string. Set to NULL or empty if not required. + + The port of the mock server is returned. + + # Safety + NULL pointers or empty strings can be passed in for the address, transport and transport_config, + in which case a default value will be used. Passing in an invalid pointer will result in undefined behaviour. + + # Errors + + Errors are returned as negative values. + + | Error | Description | + |-------|-------------| + | -1 | An invalid handle was received. Handles should be created with `pactffi_new_pact` | + | -2 | transport_config is not valid JSON | + | -3 | The mock server could not be started | + | -4 | The method panicked | + | -5 | The address is not valid | + + int32_t pactffi_create_mock_server_for_transport(PactHandle pact, + const char *addr, + uint16_t port, + const char *transport, + const char *transport_config); + + """ + # print(hostname) + # print(port) + # print(transport) + # print(transport_config) + mock_server_port = self.lib.pactffi_create_mock_server_for_transport(pact_handle, se(hostname), port, se( + transport), se('{}') if transport_config is None else se(transport_config)) + assert mock_server_port not in ['-1', '-2', '-3', '-4', '-5'] + print(f"Mock server started: {mock_server_port}") + return mock_server_port + + def message_reify(self, message_handle): + """ + Reify the given message. + + Reification is the process of stripping away any matchers, and returning the original contents. + NOTE: the returned string needs to be deallocated with the `free_string` function + """ + reified = self.lib.pactffi_message_reify(message_handle) + return self.ffi.string(reified).decode('utf-8') + + def new_sync_message_interaction(self, pact_handle, description: str): + """ + Create a new synchronous message interaction (request/response) and return a handle to it. + + * `description` - The interaction description. It needs to be unique for each interaction. + + Returns a new `InteractionHandle`. + """ + return self.lib.pactffi_new_sync_message_interaction(pact_handle, se(description)) + + def interaction_contents(self, message_handle, req_or_res="res" or "req", content_type=str, body=str or json): + """ + Set the interaction part using a plugin. + + The contents is a JSON string that will be passed on to + the plugin to configure the interaction part. Refer to the plugin documentation on the format + of the JSON contents. + + Returns zero on success, and a positive integer value on failure. + + * `interaction` - Handle to the interaction to configure. + * `part` - The part of the interaction to configure (request or response). It is ignored for messages. + * `content_type` - NULL terminated C string of the content type of the part. + * `contents` - NULL terminated C string of the JSON contents that gets passed to the plugin. + + # Safety + + `content_type` and `contents` must be a valid pointers to NULL terminated strings. Invalid + pointers will result in undefined behaviour. + + # Errors + + * `1` - A general panic was caught. + * `2` - The mock server has already been started. + * `3` - The interaction handle is invalid. + * `4` - The content type is not valid. + * `5` - The contents JSON is not valid JSON. + * `6` - The plugin returned an error. + + When an error errors, LAST_ERROR will contain the error message. + """ + if 'json' in content_type: + encoded_body = self.ffi.new("char[]", se(json.dumps(body))) + else: + encoded_body = self.ffi.new("char[]", se(body)) + return self.lib.pactffi_interaction_contents(message_handle, 0 if req_or_res == 'req' else 1, se(content_type), encoded_body) + + def with_message_request_contents(self, message_handle, content_type=str, body=str or json): + """ + Set the request interaction part using a plugin. + + see interaction_contents for details + """ + self.interaction_contents(message_handle, "req", content_type, body) + + def with_message_response_contents(self, message_handle, content_type=str, body=str or json): + """ + Set the response interaction part using a plugin. + + see interaction_contents for details + """ + self.interaction_contents(message_handle, "req", content_type, body) + + def mock_server_matched(self, mock_server_port=int): + """ + External interface to check if a mock server has matched all its requests. + + The port number is passed in, and if all requests have been matched, true is returned. + + False is returned if + - there is no mock server on the given port + - any request has not been successfully matched + - the method panics. + """ + result = self.lib.pactffi_mock_server_matched(mock_server_port) + print(f"Pact - Got matching client requests: {result}") + return result + + def write_pact_file(self, mock_server_port=int, directory=str, overwrite=bool): + """ + External interface to trigger a mock server to write out its pact file. + + This function should be called if all the consumer tests have passed. + The directory to write the file to is passed as the second parameter. + If a NULL pointer is passed, the current working directory is used. + + If overwrite is true, the file will be overwritten with the contents of the current pact. + Otherwise, it will be merged with any existing pact file. + + Returns 0 if the pact file was successfully written. Returns a positive code if the file can + not be written, or there is no mock server running on that port or the function panics. + + # Errors + + Errors are returned as positive values. + + | Error | Description | + |-------|-------------| + | 1 | A general panic was caught | + | 2 | The pact file was not able to be written | + | 3 | A mock server with the provided port was not found | + """ + print(f"Writing pact file to {directory}") + result = self.lib.pactffi_write_pact_file(mock_server_port, se(directory), overwrite) + print(f"Pact file writing results: {result}") + return result + + def write_message_pact_file(self, message_pact=int, directory=str, overwrite=bool): + """ + External interface to write out the message pact file. + + This function should be called if all the consumer tests have passed. + The directory to write the file to is passed as the second parameter. + If a NULL pointer is passed, the current working directory is used. + + If overwrite is true, the file will be overwritten with the contents of the current pact. + Otherwise, it will be merged with any existing pact file. + + Returns 0 if the pact file was successfully written. Returns a positive code if the file can + not be written, or there is no mock server running on that port or the function panics. + + # Errors + + Errors are returned as positive values. + + | Error | Description | + |-------|-------------| + | 1 | The pact file was not able to be written | + | 2 | The message pact for the given handle was not found | + """ + print(f"Writing pact message file to {directory}") + result = self.lib.pactffi_write_message_pact_file(message_pact, se(directory), overwrite) + print(f"Pact message file writing results: {result}") + return result + + def verify(self, mock_server_port, pact_handle, PACT_FILE_DIR, message_pact=None): + """ + External interface to check if a mock server has matched all its requests. + + The port number is passed in, and if all requests have been matched, true is returned. + False is returned if there + * is no mock server on the given port, or if any request has not been successfully matched, or + * the method panics. + """ + result = self.mock_server_matched(mock_server_port) + if result is True: + self.write_pact_file(mock_server_port, PACT_FILE_DIR, False) + if message_pact is not None: + self.write_message_pact_file(message_pact, PACT_FILE_DIR, False) + logs = ["success"] + else: + logs = self.mock_server_mismatches(mock_server_port) + + # Cleanup + self.lib.pactffi_cleanup_mock_server(mock_server_port) + self.lib.pactffi_cleanup_plugins(pact_handle) + return MockServerResult(result, logs) + + def mock_server_mismatches(self, mock_server_port=int): + """ + External interface to get all the mismatches from a mock server. + + The port number of the mock + server is passed in, and a pointer to a C string with the mismatches in JSON format is + returned. + + **NOTE:** The JSON string for the result is allocated on the heap, and will have to be freed + once the code using the mock server is complete. The [`cleanup_mock_server`](fn.cleanup_mock_server.html) function is + provided for this purpose. + + # Errors + + If there is no mock server with the provided port number, or the function panics, a NULL + pointer will be returned. Don't try to dereference it, it will not end well for you. + """ + mismatches = self.lib.pactffi_mock_server_mismatches(mock_server_port) + if mismatches: + result = json.loads(self.ffi.string(mismatches)) + print(json.dumps(result, indent=4)) + return result + else: + return [] diff --git a/pact/ffi/native_verifier.py b/pact/ffi/native_verifier.py new file mode 100644 index 000000000..f62393535 --- /dev/null +++ b/pact/ffi/native_verifier.py @@ -0,0 +1,519 @@ +"""Wrapper to pact reference dynamic libraries using FFI.""" +import json +from pact.ffi.utils import ne, se +from typing import List +from pact.__version__ import __version__ +from pact.ffi.pact_ffi import PactFFI + +class VerifierHandle(int): + """A typedef for the VerifierHandler opaque pointer.""" + + handle = int + +class NativeVerifier(PactFFI): + """A Pact Verifier Wrapper. + + This interfaces with the Rust FFI crate pact_ffi, specifically the + `verifier`_ module. + + .. _verifier: + https://docs.rs/pact_ffi/0.0.2/pact_ffi/verifier/index.html + """ + + def __new__(cls): + """Create a new instance of the Verifier.""" + return super(NativeVerifier, cls).__new__(cls) + + def execute(self, verifier_handle: VerifierHandle) -> int: + """ + Run the verification. + + # Error Handling + + Errors will be reported with a non-zero return value. + int pactffi_verifier_execute(struct VerifierHandle *handle); + """ + return self.lib.pactffi_verifier_execute(verifier_handle) + + def new(self) -> VerifierHandle.handle: + """ + Get a Handle to a newly created verifier. + + You should call `pactffi_verifier_shutdown` when + done with the verifier to free all allocated resources. + + ### Safety + + This function is safe. + + # Error Handling + + Returns NULL on error. + + struct VerifierHandle *pactffi_verifier_new_for_application(const char *name, const char *version) + """ + # struct VerifierHandle *pactffi_verifier_new(void); + return self.lib.pactffi_verifier_new_for_application(se('pact-python'), se(__version__)) + + def shutdown(self, verifier_handle: VerifierHandle): + """ + Shutdown the verifier and release all resources. + + void pactffi_verifier_shutdown(struct VerifierHandle *handle); + """ + self.lib.pactffi_verifier_shutdown(verifier_handle) + + def set_provider_info(self, verifier_handle: VerifierHandle, name=str, scheme=str, host=str, port=int, path=str): + """ + Set the provider details for the Pact verifier. + + Set the provider details for the Pact verifier. Passing a NULL for any field will + use the default value for that field. + + ### Safety + + All string fields must contain valid UTF-8. Invalid UTF-8 + will be replaced with U+FFFD REPLACEMENT CHARACTER. + + void pactffi_verifier_set_provider_info(struct VerifierHandle *handle, + const char *name, + const char *scheme, + const char *host, + unsigned short port, + const char *path); + """ + self.lib.pactffi_verifier_set_provider_info(verifier_handle, se(name), se(scheme), se(host), port, se(path)) + + def add_provider_transport(self, verifier_handle: VerifierHandle, protocol=str, port=int, path=str, scheme=str): + """ + Add a new transport for the given provider. + + Passing a NULL for any field will use the default value for that field. + + For non-plugin based message interactions, set protocol to "message" and set scheme + to an empty string or "https" if secure HTTP is required. Communication to the calling + application will be over HTTP to the default provider hostname. + + ##### Safety + + All string fields must contain valid UTF-8. Invalid UTF-8 + will be replaced with U+FFFD REPLACEMENT CHARACTER. + + ### Original Method + + void pactffi_verifier_add_provider_transport(struct VerifierHandle *handle, + const char *protocol, + unsigned short port, + const char *path, + const char *scheme); + """ + self.lib.pactffi_verifier_add_provider_transport(verifier_handle, se(protocol), port, se(path), se(scheme)) + + def set_filter_info(self, verifier_handle: VerifierHandle, filter_description=str, filter_state=str, filter_no_state=bool): + """ + Set the filters for the Pact verifier. + + If `filter_description` is not empty, it needs to be as a regular expression. + + `filter_no_state` is a boolean value. Set it to greater than zero to turn the option on. + + ### Safety + + All string fields must contain valid UTF-8. Invalid UTF-8 + will be replaced with U+FFFD REPLACEMENT CHARACTER. + + + void pactffi_verifier_set_filter_info(struct VerifierHandle *handle, + const char *filter_description, + const char *filter_state, + unsigned char filter_no_state); + + """ + self.lib.pactffi_verifier_set_filter_info(verifier_handle, se(filter_description), se(filter_state), filter_no_state) + + def set_provider_state(self, verifier_handle: VerifierHandle, url=str, teardown=bool, body=bool): + """ + Set the provider state URL for the Pact verifier. + + * `teardown` is a boolean value. If teardown state change requests should be made after an + interaction is validated (default is false). Set it to greater than zero to turn the + option on. + + * `body` is a boolean value. Sets if state change request data should be sent in the body + (> 0, true) or as query parameters (== 0, false). Set it to greater than zero to turn the + option on. + + ### Safety + + All string fields must contain valid UTF-8. Invalid UTF-8 + will be replaced with U+FFFD REPLACEMENT CHARACTER. + + + void pactffi_verifier_set_provider_state(struct VerifierHandle *handle, + const char *url, + unsigned char teardown, + unsigned char body); + """ + self.lib.pactffi_verifier_set_provider_state(verifier_handle, se(url), teardown, body) + + def set_verification_options(self, verifier_handle: VerifierHandle, disable_ssl_verification: bool, request_timeout: int) -> int: + """ + Set the options used by the verifier when calling the provider. + + `disable_ssl_verification` is a boolean value. Set it to greater than zero to turn the option on. + + ### Safety + + All string fields must contain valid UTF-8. Invalid UTF-8 + will be replaced with U+FFFD REPLACEMENT CHARACTER. + + / + int pactffi_verifier_set_verification_options(struct VerifierHandle *handle, + unsigned char disable_ssl_verification, + unsigned long request_timeout); + """ + return self.lib.pactffi_verifier_set_verification_options(verifier_handle, disable_ssl_verification, request_timeout) + + def set_coloured_output(self, verifier_handle: VerifierHandle, coloured_output: bool) -> int: + """ + Enable or disable coloured output using ANSI escape codes in the verifier output. + + By default, coloured output is enabled. + + `coloured_output` is a boolean value. Set it to greater than zero to turn the option on. + + ### Safety + + This function is safe as long as the handle pointer points to a valid handle. + + + int pactffi_verifier_set_coloured_output(struct VerifierHandle *handle, + unsigned char coloured_output); + """ + return self.lib.pactffi_verifier_set_coloured_output(verifier_handle, coloured_output) + + def set_no_pacts_is_error(self, verifier_handle: VerifierHandle, is_error: bool) -> int: + """ + Enable or disable if no pacts are found to verify results in an error. + + `is_error` is a boolean value. Set it to greater than zero to enable an error when no pacts + are found to verify, and set it to zero to disable this. + + ### Safety + + This function is safe as long as the handle pointer points to a valid handle. + + int pactffi_verifier_set_no_pacts_is_error(struct VerifierHandle *handle, unsigned char is_error); + """ + return self.lib.pactffi_verifier_set_no_pacts_is_error(verifier_handle, is_error) + + def set_publish_options(self, verifier_handle: VerifierHandle, provider_version=str, build_url=str, provider_tags=List[str], provider_branch=str) -> int: + """ + Set the options used when publishing verification results to the Pact Broker. + + # Args + + - `handle` - The pact verifier handle to update + - `provider_version` - Version of the provider to publish + - `build_url` - URL to the build which ran the verification + - `provider_tags` - Collection of tags for the provider + - `provider_tags_len` - Number of provider tags supplied + - `provider_branch` - Name of the branch used for verification + + ### Safety + + All string fields must contain valid UTF-8. Invalid UTF-8 + will be replaced with U+FFFD REPLACEMENT CHARACTER. + + / + int pactffi_verifier_set_publish_options(struct VerifierHandle *handle, + const char *provider_version, + const char *build_url, + const char *const *provider_tags, + unsigned short provider_tags_len, + const char *provider_branch); + """ + if provider_tags is not None: + c_provider_tag_array = [] + for provider_tag in provider_tags: + c_provider_tag_array.append(self.ffi.new("char []", se(json.dumps(provider_tag)))) + c_provider_tags = self.ffi.new("char *[]", c_provider_tag_array) + return self.lib.pactffi_verifier_set_publish_options( + verifier_handle, + se(provider_version), + se(build_url), + c_provider_tags if provider_tags is not None else self.ffi.cast("void *", 0), + len(provider_tags) if provider_tags else 0, + se(provider_branch) + if provider_branch + else ne(), + + ) + + def set_consumer_filters(self, verifier_handle: VerifierHandle, consumer_filters=List[str]): + """ + Set the consumer filters for the Pact verifier. + + ### Safety + + All string fields must contain valid UTF-8. Invalid UTF-8 + will be replaced with U+FFFD REPLACEMENT CHARACTER. + + void pactffi_verifier_set_consumer_filters(struct VerifierHandle *handle, + const char *const *consumer_filters, + unsigned short consumer_filters_len); + """ + c_consumer_filter_array = [] + for consumer_filter in consumer_filters: + c_consumer_filter_array.append(self.ffi.new("char []", se(json.dumps(consumer_filter)))) + c_consumer_filters = self.ffi.new("char *[]", c_consumer_filter_array) + + self.lib.pactffi_verifier_set_consumer_filters(verifier_handle, + c_consumer_filters, + len(consumer_filters) + ) + + def add_custom_header(self, verifier_handle: VerifierHandle, header_name: str, header_value: str): + """ + + Add a custom header to be added to the requests made to the provider. + + ### Safety + + The header name and value must point to a valid NULL terminated string and must contain + valid UTF-8. + + void pactffi_verifier_add_custom_header(struct VerifierHandle *handle, + const char *header_name, + const char *header_value); + """ + self.lib.pactffi_verifier_add_custom_header(verifier_handle, se(header_name), se(header_value)) + + def add_file_source(self, verifier_handle: VerifierHandle, file=str): + """ + + Add a Pact file as a source to verify. + + ### Safety + + All string fields must contain valid UTF-8. Invalid UTF-8 + will be replaced with U+FFFD REPLACEMENT CHARACTER. + + + void pactffi_verifier_add_file_source(struct VerifierHandle *handle, const char *file); + """ + self.lib.pactffi_verifier_add_file_source(verifier_handle, se(file)) + + def add_directory_source(self, verifier_handle: VerifierHandle, directory: str): + """ + Add a Pact directory as a source to verify. + + All pacts from the directory that match the provider name will be verified. + + ### Safety + + All string fields must contain valid UTF-8. Invalid UTF-8 + will be replaced with U+FFFD REPLACEMENT CHARACTER. + + void pactffi_verifier_add_directory_source(struct VerifierHandle *handle, const char *directory); + + """ + self.lib.pactffi_verifier_add_directory_source(verifier_handle, se(directory)) + + def url_source(self, verifier_handle: VerifierHandle, url: str, username: str, password: str, token: str): + """ + + Add a URL as a source to verify. The Pact file will be fetched from the URL. + + If a username and password is given, then basic authentication will be used when fetching + the pact file. If a token is provided, then bearer token authentication will be used. + + ### Safety + + All string fields must contain valid UTF-8. Invalid UTF-8 + will be replaced with U+FFFD REPLACEMENT CHARACTER. + + + void pactffi_verifier_url_source(struct VerifierHandle *handle, + const char *url, + const char *username, + const char *password, + const char *token); + """ + self.lib.pactffi_verifier_url_source(verifier_handle, se(url), se(username), se(password), se(token)) + + def broker_source(self, verifier_handle: VerifierHandle, url: str, username: str, password: str, token: str): + """ + Add a Pact broker as a source to verify. + + This will fetch all the pact files from the broker that match the provider name. + + If a username and password is given, then basic authentication will be used when fetching + the pact file. If a token is provided, then bearer token authentication will be used. + + ### Safety + + All string fields must contain valid UTF-8. Invalid UTF-8 + will be replaced with U+FFFD REPLACEMENT CHARACTER. + + + void pactffi_verifier_broker_source(struct VerifierHandle *handle, + const char *url, + const char *username, + const char *password, + const char *token); + """ + self.lib.pactffi_verifier_broker_source(verifier_handle, se(url), se(username), se(password), b'NULL') + + def broker_source_with_selectors(self, verifier_handle: VerifierHandle, + url: str, + username: str, + password: str, + token: str, + enable_pending: bool, + include_wip_pacts_since: str, + provider_tags: List[str], + provider_branch: str, + consumer_version_selectors: List[str], + consumer_version_tags: List[str], + ): + """ + Add a Pact broker as a source to verify. + + This will fetch all the pact files from the broker that match the provider name and the consumer version selectors + (See `https://docs.pact.io/pact_broker/advanced_topics/consumer_version_selectors/`). + + The consumer version selectors must be passed in in JSON format. + + `enable_pending` is a boolean value. Set it to greater than zero to turn the option on. + + If the `include_wip_pacts_since` option is provided, it needs to be a date formatted in + ISO format (YYYY-MM-DD). + + If a username and password is given, then basic authentication will be used when fetching + the pact file. If a token is provided, then bearer token authentication will be used. + + ### Safety + + All string fields must contain valid UTF-8. Invalid UTF-8 + will be replaced with U+FFFD REPLACEMENT CHARACTER. + + / + void pactffi_verifier_broker_source_with_selectors(struct VerifierHandle *handle, + const char *url, + const char *username, + const char *password, + const char *token, + unsigned char enable_pending, + const char *include_wip_pacts_since, + const char *const *provider_tags, + unsigned short provider_tags_len, + const char *provider_branch, + const char *const *consumer_version_selectors, + unsigned short consumer_version_selectors_len, + const char *const *consumer_version_tags, + unsigned short consumer_version_tags_len); + + """ + if consumer_version_selectors is not None: + c_consumer_version_selector_array = [] + for consumer_version_selector in consumer_version_selectors: + c_consumer_version_selector_array.append(self.ffi.new("char []", se(json.dumps(consumer_version_selector)))) + c_consumer_version_selectors = self.ffi.new("char *[]", c_consumer_version_selector_array) + + if consumer_version_tags is not None: + c_consumer_version_tag_array = [] + for consumer_version_tag in consumer_version_tags: + c_consumer_version_tag_array.append(self.ffi.new("char []", se(json.dumps(consumer_version_tag)))) + c_consumer_version_tags = self.ffi.new("char *[]", c_consumer_version_tag_array) + + if provider_tags is not None: + c_provider_tag_array = [] + for provider_tag in provider_tags: + c_provider_tag_array.append(self.ffi.new("char []", se(json.dumps(provider_tag)))) + c_provider_tags = self.ffi.new("char *[]", c_provider_tag_array) + + self.lib.pactffi_verifier_broker_source_with_selectors( + verifier_handle, + se(url), + se(username), + se(password), + se(token), + enable_pending, + se(include_wip_pacts_since) + if include_wip_pacts_since + else se(""), + c_provider_tags if provider_tags is not None else self.ffi.cast("void *", 0), + len(provider_tags) if provider_tags else 0, + se(provider_branch) + if provider_branch + else ne(), + c_consumer_version_selectors if consumer_version_selectors is not None else self.ffi.cast("void *", 0), + len(consumer_version_selectors) if consumer_version_selectors else 0, + c_consumer_version_tags if consumer_version_tags is not None else self.ffi.cast("void *", 0), + len(consumer_version_tags) if consumer_version_tags else 0, + ) + + def logs(self, verifier_handle: VerifierHandle) -> str: + """ + Extract the logs for the verification run. + + This needs the memory buffer log sink to be setup before the verification is executed. + The returned string will need to be freed with the `free_string` function call to avoid leaking memory. + + Will return a NULL pointer if the logs for the verification can not be retrieved. + + const char *pactffi_verifier_logs(const struct VerifierHandle *handle); + """ + native_logs = self.lib.pactffi_verifier_logs(verifier_handle) + logs = self.ffi.string(native_logs).decode("utf-8").rstrip().split("\n") + self.lib.pactffi_string_delete(native_logs) + return logs + + def logs_for_provider(self, provider_name: str): + """ + Extract the logs for the verification run. + + This needs the memory buffer log sink to be setup before the verification is executed. + The returned string will need to be freed with the `free_string` function call to avoid leaking memory. + + Will return a NULL pointer if the logs for the verification can not be retrieved. + const char *pactffi_verifier_logs_for_provider(const char *provider_name); + """ + native_logs = self.lib.pactffi_verifier_logs_for_provider(provider_name) + logs = self.ffi.string(native_logs).decode("utf-8").rstrip().split("\n") + self.lib.pactffi_string_delete(native_logs) + return logs + + def output(self, verifier_handle: VerifierHandle, strip_ansi: bool) -> str: + """ + Extract the standard output for the verification run. + + The returned string will need to be freed with the `free_string` function call to avoid leaking memory. + + * `strip_ansi` - This parameter controls ANSI escape codes. Setting it to a non-zero value + will cause the ANSI control codes to be stripped from the output. + + Will return a NULL pointer if the handle is invalid. + + const char *pactffi_verifier_output(const struct VerifierHandle *handle, unsigned char strip_ansi); + """ + native_logs = self.lib.pactffi_verifier_output(verifier_handle, strip_ansi) + logs = self.ffi.string(native_logs).decode("utf-8").rstrip().split("\n") + self.lib.pactffi_string_delete(native_logs) + return logs + + def json(self, verifier_handle: VerifierHandle) -> str: + """ + Extract the verification result as a JSON document. + + The returned string will need to be freed with the `free_string` function call to avoid leaking memory. + + Will return a NULL pointer if the handle is invalid. + + const char *pactffi_verifier_json(const struct VerifierHandle *handle); + """ + native_logs = self.lib.pactffi_verifier_json(verifier_handle) + logs = self.ffi.string(native_logs).decode("utf-8").rstrip().split("\n") + self.lib.pactffi_string_delete(native_logs) + return logs diff --git a/pact/ffi/pact_ffi.py b/pact/ffi/pact_ffi.py new file mode 100644 index 000000000..9953cf331 --- /dev/null +++ b/pact/ffi/pact_ffi.py @@ -0,0 +1,114 @@ +"""Wrapper to pact reference dynamic libraries using FFI.""" +import os +import tempfile +from typing import List + +from cffi import FFI +import threading + +from pact.ffi.log import LogToBufferStatus +from pact.ffi.register_ffi import RegisterFfi +from pact.ffi.utils import se + +class PactFFI(object): + """This interfaces with the Rust crate `pact_ffi`. + + Interface with the pact_ffi API via a C Foreign Function Interface. In the case of python, the library + is then accessed using `CFFI`. + + This class will implement the shared library loading, along with a wrapper + for the functions provided by the base crate. For each of the Rust modules + exposed, a corresponding python class will extend this base class, and + provide the wrapper for the functions the module provides. + + .. _pact_ffi: + https://docs.rs/pact_ffi/0.0.1/pact_ffi/index.html + .. _CFFI: + https://cffi.readthedocs.io/en/latest/ + """ + + ffi: FFI = None + lib = None + + _instance = None + _lock = threading.Lock() + + # Required if outputting logs to a file, can be remove if using a buffer + output_dir: tempfile.TemporaryDirectory = None + output_file: str = None + + def __new__(cls): + """Initiate the ffi.""" + # Make sure we only initialise once, or the log setup will fail + if not cls._instance: + with cls._lock: + if not cls._instance: + cls._instance = super(PactFFI, cls).__new__(cls) + cls.ffi = FFI() + + # # Define all the functions from the various modules, since we + # # can only load the library once + # cls.lib = RegisterFfi().get_ffi_lib(cls.ffi) + cls.lib = cls._load_ffi_library(cls.ffi) + + # We can setup logs like this, if preferred to buffer: + # The output will be stored in a file in this directory, which + # will be cleaned up automatically at the end + PactFFI.output_dir = tempfile.TemporaryDirectory() + # Setup logging to a file in the output_dir + # PactFFI.output_file = os.path.join(PactFFI.output_dir.name, "output") + # output_c = cls.ffi.new("char[]", bytes(cls.output_file, "utf-8")) + # result = cls.lib.pactffi_log_to_file(output_c, LogLevel.INFO.value) + # assert LogToBufferStatus(result) == LogToBufferStatus.SUCCESS + + # Having problems with the buffer output, when running via CLI + # Reverting to log file output instead + log_level = os.getenv('PACT_LOG_LEVEL', 'INFO') + log_output = os.getenv('PACT_LOG_OUTPUT', 'BUFFER') + log_file = os.getenv('PACT_LOG_FILE', './log/pact.log') + LOG_LEVEL_MAPPING = { + "NONE": 0, + "ERROR": 1, + "WARN": 2, + "INFO": 3, + "DEBUG": 4, + "TRACE": 5, + } + if log_output == 'STDOUT': + result = cls.lib.pactffi_log_to_stdout(LOG_LEVEL_MAPPING[log_level]) + elif log_output == 'FILE': + result = cls.lib.pactffi_log_to_file(se(log_file), LOG_LEVEL_MAPPING[log_level]) + else: + result = cls.lib.pactffi_log_to_buffer(LOG_LEVEL_MAPPING[log_level]) + + assert LogToBufferStatus(result) in [LogToBufferStatus.SUCCESS, LogToBufferStatus.CANT_SET_LOGGER_OR_LOGGER_SET] + return cls._instance + + def version(self) -> str: + """Get the current library version. + + :return: pact_ffi library version, for example "0.0.1" + """ + result = self.lib.pactffi_version() + return self.ffi.string(result).decode("utf-8") + + @staticmethod + def _load_ffi_library(ffi): + return RegisterFfi().get_ffi_lib(ffi) + + def get_logs(self) -> List[str]: + """Retrieve the contents of the FFI log buffer. + + :return: List of log entries, each a line of log output + """ + # Having problems with the buffer output, when running via CLI + # Reverting to log file output instead + result = self.lib.pactffi_fetch_log_buffer(b'NULL') + logs = self.ffi.string(result).decode("utf-8").rstrip().split("\n") + self.lib.pactffi_string_delete(result) + return logs + + # # If using log to file, retrieve like this: + # lines = open(PactFFI.output_file).readlines() + # open(PactFFI.output_file, "w").close() + # return [line.lstrip("\x00") for line in lines] diff --git a/pact/ffi/register_ffi.py b/pact/ffi/register_ffi.py new file mode 100644 index 000000000..23bc63cf9 --- /dev/null +++ b/pact/ffi/register_ffi.py @@ -0,0 +1,68 @@ +"""Registers our FFI library.""" +import platform +import os +import sys +from pact.constants import FFI_LIB_PATH + +class RegisterFfi(object): + """Registers our FFI library.""" + + IS_64 = sys.maxsize > 2 ** 32 + + DIRECTIVES = [ + "#ifndef pact_ffi_h", + "#define pact_ffi_h", + "#include ", + "#include ", + "#include ", + "#include ", + "#endif /* pact_ffi_h */" + ] + + FFI_HEADER_PATH = os.path.join(FFI_LIB_PATH, "pact.h") + + def process_pact_header_file(self, file): + """Process the pact header file.""" + with open(file, "r") as fp: + lines = fp.readlines() + + pactfile = [] + + for line in lines: + if line.strip("\n") not in self.DIRECTIVES: + pactfile.append(line) + + return ''.join(pactfile) + + def load_ffi_library(self, ffi): + """Load the right library.""" + target_platform = platform.platform().lower() + # print(target_platform) + # print(platform.machine()) + + if ("darwin" in target_platform or "macos" in target_platform) and ("aarch64" in platform.machine() or "arm64" in platform.machine()): + libname = os.path.join(FFI_LIB_PATH, "libpact_ffi-osx-aarch64-apple-darwin.dylib") + elif "darwin" in target_platform or "macos" in target_platform: + libname = os.path.join(FFI_LIB_PATH, "libpact_ffi-osx-x86_64.dylib") + elif "linux" in target_platform and self.IS_64 and ("aarch64" in platform.machine() or "arm64" in platform.machine()): + libname = os.path.join(FFI_LIB_PATH, "libpact_ffi-linux-aarch64.so") + elif "linux" in target_platform and self.IS_64: + libname = os.path.join(FFI_LIB_PATH, "libpact_ffi-linux-x86_64.so") + elif 'windows' in target_platform: + libname = os.path.join(FFI_LIB_PATH, "pact_ffi-windows-x86_64.dll") + else: + msg = ('Unfortunately, {} is not a supported platform. Only Linux,' + ' Windows, and OSX are currently supported.').format(target_platform) + raise Exception(msg) + + return ffi.dlopen(libname) + + def load_ffi_headers(self, ffi): + """Load the FFI headers.""" + return ffi.cdef(self.process_pact_header_file(self.FFI_HEADER_PATH)) + + def get_ffi_lib(self, ffi): + """Load the FFI library.""" + self.load_ffi_headers(ffi) + lib = self.load_ffi_library(ffi) + return lib diff --git a/pact/ffi/utils.py b/pact/ffi/utils.py new file mode 100644 index 000000000..4990109c0 --- /dev/null +++ b/pact/ffi/utils.py @@ -0,0 +1,8 @@ +"""Utils for safe handling of c code.""" +def se(s=str): + """Encode the string parameter as a buffer or encode NULL.""" + return b"NULL" if s is None or "" else s.encode("ascii") + +def ne(): + """Encode a null string as a byte.""" + return b"NULL" diff --git a/pact/ffi/verifier.py b/pact/ffi/verifier.py new file mode 100644 index 000000000..c603d3923 --- /dev/null +++ b/pact/ffi/verifier.py @@ -0,0 +1,137 @@ +"""Wrapper to pact reference dynamic libraries using FFI.""" +from enum import Enum, unique +from typing import Dict, NamedTuple, List + +from pact.ffi.pact_ffi import PactFFI +import json + + +@unique +class VerifyStatus(Enum): + """Return codes from a verify request. + + As per: https://docs.rs/pact_ffi/0.0.2/pact_ffi/verifier/fn.pactffi_verify.html + """ + + SUCCESS = 0 # Operation succeeded + VERIFIER_FAILED = 1 # The verification process failed, see output for errors + NULL_POINTER = 2 # A null pointer was received + PANIC = 3 # The method panicked + INVALID_ARGS = 4 # Invalid arguments were provided to the verification process + + +class VerifyResult(NamedTuple): + """Wrap up the return code, and log output.""" + + return_code: VerifyStatus + logs: List[str] + + +class Argument: + """Hold the attributes of a single argument which can be used by the Verifier.""" + + long: str # For example: "token" + short: str = None # For example "t" + help: str # Help description, for example: "Bearer token to use when fetching pacts from URLS" + default_value: str = None # The value which will be passed if none are provided, such as "http" for schema + possible_values: List[str] = None # If only specific values can be used, such as ["http", "https"] for schema + multiple: bool # If the argument can be provided multiple times, for example with file + env: str = None # ENV which will be used in the absence of a provided argument, for example PACT_BROKER_TOKEN + + def __init__( + self, + long: str, + help: str, + multiple: bool, + short: str = None, + default_value: str = None, + possible_values: List[str] = None, + env: str = None, + ): + """Create a new Argument object.""" + self.long = long + self.short = short + self.help = help + self.default_value = default_value + self.possible_values = possible_values + self.multiple = multiple + self.env = env + + +class Arguments: + """Hold the various options and flags which can be used by the Verifier.""" + + options: List[Argument] = [] + flags: List[Argument] = [] + + def __init__(self, options: List[Argument], flags: List[Argument]): + """Create a new Arguments object.""" + self.options = [Argument(**option) for option in options] + self.flags = [Argument(**flags) for flags in flags] + + +class Verifier(PactFFI): + """A Pact Verifier Wrapper. + + This interfaces with the Rust FFI crate pact_ffi, specifically the + `verifier`_ module. + + .. _verifier: + https://docs.rs/pact_ffi/0.0.2/pact_ffi/verifier/index.html + """ + + def __new__(cls): + """Create a new instance of the Verifier.""" + return super(Verifier, cls).__new__(cls) + + def verify(self, args=None) -> VerifyResult: + """Call verify method.""" + # The FFI library specifically defines "usage" of no args, so we will + # replicate that here. In reality we will always want args. + if args: + c_args = self.ffi.new("char[]", bytes(args, "utf-8")) + else: + c_args = self.ffi.NULL + + result = self.lib.pactffi_verify(c_args) + logs = self.get_logs() + return VerifyResult(result, logs) + + def _cli_args_raw(self) -> Dict: + """Call and return the output from the pactffi_verifier_cli_args method. + + :return: The arguments, in raw dict form + """ + result = self.lib.pactffi_verifier_cli_args() + arguments = json.loads(self.ffi.string(result).decode("utf-8")) + # print(arguments) + self.lib.pactffi_free_string(result) + return arguments + + def cli_args(self) -> Arguments: + """Retrieve the Arguments available to the Pact Verifier. + + :return: The arguments, in a Arguments structure + """ + arguments = Arguments(**self._cli_args_raw()) + return arguments + + @staticmethod + def args_dict_to_str(cli_args_dict: Dict) -> str: + """Convert a dict of arguments to the delimited str required to call the FFI function.""" + cli_args = "" + for key, value in cli_args_dict.items(): + # Special case, don't pass through the debug flag for Click + if key == "debug_click": + continue + + key_arg = key.replace("_", "-") # Snake case for python, kebab case for CLI + if value and isinstance(value, bool): + cli_args = f"{cli_args}\n--{key_arg}" + elif value and isinstance(value, str): + cli_args = f"{cli_args}\n--{key_arg}={value}" + elif value and isinstance(value, tuple) or isinstance(value, list): + for multiple_opt in value: + cli_args = f"{cli_args}\n--{key_arg}={multiple_opt}" + cli_args = cli_args.strip() + return cli_args diff --git a/pact/ffi/verifier_args.py b/pact/ffi/verifier_args.py new file mode 100644 index 000000000..d2f6949b2 --- /dev/null +++ b/pact/ffi/verifier_args.py @@ -0,0 +1,100 @@ +"""This module contains the dataclass that represents the arguments available to the pact verifier.""" +import typing +from dataclasses import dataclass, field + + +@dataclass +class VerifierArgs: + """Auto-generated class, containing the arguments available to the pact verifier.""" + + # Log level (defaults to warn) + loglevel: typing.Optional[str] = None + + # URL of the pact broker to fetch pacts from to verify (requires the provider name parameter) + broker_url: typing.Optional[str] = None + + # Provider hostname (defaults to localhost) + hostname: typing.Optional[str] = None + + # Provider port (defaults to protocol default 80/443) + port: typing.Optional[str] = None + + # Provider URI scheme (defaults to http) + scheme: typing.Optional[str] = None + + # Provider name (defaults to provider) + provider_name: typing.Optional[str] = None + + # URL to post state change requests to + state_change_url: typing.Optional[str] = None + + # Only validate interactions whose descriptions match this filter + filter_description: typing.Optional[str] = None + + # Only validate interactions whose provider states match this filter + filter_state: typing.Optional[str] = None + + # Only validate interactions that have no defined provider state + filter_no_state: typing.Optional[str] = None + + # Username to use when fetching pacts from URLS + user: typing.Optional[str] = None + + # Password to use when fetching pacts from URLS + password: typing.Optional[str] = None + + # Bearer token to use when fetching pacts from URLS + token: typing.Optional[str] = None + + # Provider version that is being verified. This is required when publishing results. + provider_version: typing.Optional[str] = None + + # URL of the build to associate with the published verification results. + build_url: typing.Optional[str] = None + + # Provider tags to use when publishing results. Accepts comma-separated values. + provider_tags: typing.Optional[str] = None + + # Base path to add to all requests + base_path: typing.Optional[str] = None + + # Consumer tags to use when fetching pacts from the Broker. Accepts comma-separated values. + consumer_version_tags: typing.Optional[str] = None + + # Consumer version selectors to use when fetching pacts from the Broker. Accepts a JSON string as + # per https://docs.pact.io/pact_broker/advanced_topics/consumer_version_selectors/ + consumer_version_selectors: typing.Optional[str] = None + + # Allow pacts that don't match given consumer selectors (or tags) to be verified, without causing + # the overall task to fail. For more information, see https://pact.io/wip + include_wip_pacts_since: typing.Optional[str] = None + + # Sets the HTTP request timeout in milliseconds for requests to the target API and for state change requests. + request_timeout: typing.Optional[str] = None + + # Pact file to verify (can be repeated) + file: typing.Optional[typing.List[str]] = field(default_factory=list) + + # Directory of pact files to verify (can be repeated) + dir: typing.Optional[typing.List[str]] = field(default_factory=list) + + # URL of pact file to verify (can be repeated) + url: typing.Optional[typing.List[str]] = field(default_factory=list) + + # Consumer name to filter the pacts to be verified (can be repeated) + filter_consumer: typing.Optional[typing.List[str]] = field(default_factory=list) + + # State change request data will be sent as query parameters instead of in the request body + state_change_as_query: typing.Optional[bool] = None + + # State change teardown requests are to be made after each interaction + state_change_teardown: typing.Optional[bool] = None + + # Enables publishing of verification results back to the Pact Broker. Requires the broker-url and provider-version parameters. + publish: typing.Optional[bool] = None + + # Disables validation of SSL certificates + disable_ssl_verification: typing.Optional[bool] = None + + # Enables Pending Pacts + enable_pending: typing.Optional[bool] = None diff --git a/pact/ffi/verify_wrapper.py b/pact/ffi/verify_wrapper.py new file mode 100644 index 000000000..c293abb46 --- /dev/null +++ b/pact/ffi/verify_wrapper.py @@ -0,0 +1,7 @@ +"""A Pact Verifier Wrapper.""" +class VerifyWrapper(object): + """A Pact Verifier Wrapper.""" + + def verify(self): + """Call verify method.""" + pass diff --git a/pact/http_proxy.py b/pact/http_proxy.py index 23c9ebda9..39ddfdd53 100644 --- a/pact/http_proxy.py +++ b/pact/http_proxy.py @@ -1,5 +1,5 @@ """Http Proxy to be used as provider url in verifier.""" -from fastapi import FastAPI, status, Request, HTTPException +from fastapi import FastAPI, Response, status, Request, HTTPException import uvicorn as uvicorn import logging log = logging.getLogger(__name__) @@ -29,11 +29,15 @@ def _match_states(payload): @app.post("/") -async def root(request: Request): +async def root(request: Request, response: Response): """Match states with provided message handlers.""" payload = await request.json() message = _match_states(payload) - return {'contents': message} + # TODO:- Read message metadata from request, parse as json + # and base64 encode - the example below is {"Content-Type": "application/json"} + # https://github.com/pact-foundation/pact-reference/tree/master/rust/pact_verifier_cli#verifying-metadata + response.headers["Pact-Message-Metadata"] = "eyJDb250ZW50LVR5cGUiOiAiYXBwbGljYXRpb24vanNvbiJ9Cg==" + return message @app.get('/ping', status_code=status.HTTP_200_OK) diff --git a/pact/matchers_v3.py b/pact/matchers_v3.py new file mode 100644 index 000000000..dfe5f141f --- /dev/null +++ b/pact/matchers_v3.py @@ -0,0 +1,438 @@ +"""Classes for defining request and response data that is variable for the V3 Pact Spec.""" +# from pact_python_v3 import generate_datetime_string +import datetime + +from enum import Enum + +class V3Matcher(object): + """Base class for defining complex contract expectations.""" + + def generate(self): + """ + Convert this matcher into an intermediate JSON format. + + :rtype: any + """ + raise NotImplementedError + + +class EachLike(V3Matcher): + """Expect the data to be a list of similar objects.""" + + def __init__(self, template, minimum=None, maximum=None, examples=1): + """ + Create a new EachLike. + + :param template: The template value that each item in a list should + look like, this can be other matchers. + :type template: None, list, dict, int, float, str, unicode, Matcher + :param minimum: The minimum number of items expected. + Must be greater than or equal to 0. + :type minimum: int + :param maximum: The maximum number of items expected. + Must be greater than or equal to 1. + :type minimum: int + :param examples: The number of examples values to generate. + Must be greater than or equal to 1. + :type examples: int + """ + self.template = template + self.minimum = minimum + self.maximum = maximum + self.examples = examples + + def generate(self): + """Generate the object.""" + json = { + "pact:matcher:type": "type", + 'value': [self.template for i in range(self.examples)] + } + if self.minimum is not None: + json['min'] = self.minimum + if self.maximum is not None: + json['max'] = self.maximum + + return json + + +class AtLeastOneLike(V3Matcher): + """An array that has to have at least one element and each element must match the given template.""" + + def __init__(self, template, examples=1): + """ + Create a new EachLike. + + :param template: The template value that each item in a list should + look like, this can be other matchers. + :type template: None, list, dict, int, float, str, unicode, Matcher + :param examples: The number of examples values to generate. + Must be greater than or equal to 1. + :type examples: int + """ + self.template = template + self.examples = examples + + def generate(self): + """Generate the object.""" + return { + "pact:matcher:type": "type", + "min": 1, + 'value': [self.template for i in range(self.examples)] + } + + +class Like(V3Matcher): + """Value must be the same type as the example.""" + + def __init__(self, example): + """ + Create a new Like. + + :param example: The template value that each item in a list should + look like, + :type example: None, list, dict, int, float, str, unicode, Matcher + """ + self.example = example + + def generate(self): + """Generate the object.""" + return { + "pact:matcher:type": "type", + 'value': self.example + } + + +class Integer(V3Matcher): + """Value must be an integer (must be a number and have no decimal places).""" + + def __init__(self, *args): + """ + Define a new integer matcher. + + :param example: Example value to use. If not provided a random value will be generated. + :type example: int + """ + if len(args) > 0: + self.example = args[0] + else: + self.example = None + + def generate(self): + """Generate the object.""" + if self.example is not None: + return { + 'pact:matcher:type': 'integer', + 'value': self.example + } + else: + return { + "pact:generator:type": "RandomInt", + "pact:matcher:type": "integer", + "value": 101 + } + +class Regex(V3Matcher): + """ + Expect the response to match a specified regular expression. + + Example: + >>> from pact import Consumer, Provider + >>> pact = Consumer('consumer').has_pact_with(Provider('provider')) + >>> (pact.given('the current user is logged in as `tester`') + ... .upon_receiving('a request for the user profile') + ... .with_request('get', '/profile') + ... .will_respond_with(200, body={ + ... 'name': 'tester', + ... 'theme': Regex('light|dark|legacy', 'dark') + ... })) + + Would expect the response body to be a JSON object, containing the key + `name`, which will contain the value `tester`, and `theme` which must be + one of the values: light, dark, or legacy. When the consumer runs this + contract, the value `dark` will be returned by the mock service. + + """ + + def __init__(self, matcher, generate): + """ + Create a new Regex. + + :param matcher: A regular expression to find. + :type matcher: basestring + :param generate: A value to be returned by the mock service when + generating the response to the consumer. + :type generate: basestring + """ + self.matcher = matcher + self._generate = generate + + def generate(self): + """ + Return the value that should be used in the request/response. + + :return: A dict containing the information about what the contents of + the response should be, and what should match for the requests. + :rtype: dict + """ + return { + "pact:matcher:type": "regex", + "regex": self.matcher, + "value": self._generate + } + +# class DateTime(V3Matcher): +# """String value that must match the provided datetime format string.""" + +# def __init__(self, format, *args): +# """ +# Define a new datetime matcher. + +# :param format: Datetime format string. See [Java SimpleDateFormat](https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html) +# :type format: str +# :param example: Example value to use. If omitted a value using the current system date and time will be generated. +# :type example: str +# """ +# self.format = format +# if len(args) > 0: +# self.example = args[0] +# else: +# self.example = None + +# def generate(self): +# """Generate the object.""" +# if self.example is not None: +# return { +# "pact:generator:type": "DateTime", +# "pact:matcher:type": "timestamp", +# "format": self.format, +# "value": self.example +# } +# else: +# return { +# "pact:generator:type": "DateTime", +# "pact:matcher:type": "timestamp", +# "format": self.format, +# "value": generate_datetime_string(self.format), +# } + +class Format: + """ + Class of regular expressions for common formats. + + Example: + >>> from pact import Consumer, Provider + >>> from pact.matchers import Format + >>> pact = Consumer('consumer').has_pact_with(Provider('provider')) + >>> (pact.given('the current user is logged in as `tester`') + ... .upon_receiving('a request for the user profile') + ... .with_request('get', '/profile') + ... .will_respond_with(200, body={ + ... 'id': Format().identifier, + ... 'lastUpdated': Format().time + ... })) + + Would expect `id` to be any valid int and `lastUpdated` to be a valid time. + When the consumer runs this contract, the value of that will be returned + is the second value passed to Term in the given function, for the time + example it would be datetime.datetime(2000, 2, 1, 12, 30, 0, 0).time() + + """ + + def __init__(self): + """Create a new Formatter.""" + self.identifier = self.integer_or_identifier() + self.integer = self.integer_or_identifier() + self.decimal = self.decimal() + self.ip_address = self.ip_address() + self.hexadecimal = self.hexadecimal() + self.ipv6_address = self.ipv6_address() + self.uuid = self.uuid() + self.timestamp = self.timestamp() + self.date = self.date() + self.time = self.time() + self.iso_datetime = self.iso_8601_datetime() + self.iso_datetime_ms = self.iso_8601_datetime(with_ms=True) + + def integer_or_identifier(self): + """ + Match any integer. + + :return: a Like object with an integer. + :rtype: Like + """ + return Like(1) + + def decimal(self): + """ + Match any decimal. + + :return: a Like object with a decimal. + :rtype: Like + """ + return Like(1.0) + + def ip_address(self): + """ + Match any ip address. + + :return: a Term object with an ip address regex. + :rtype: Term + """ + return Regex(self.Regexes.ip_address.value, '127.0.0.1') + + def hexadecimal(self): + """ + Match any hexadecimal. + + :return: a Term object with a hexdecimal regex. + :rtype: Term + """ + return Regex(self.Regexes.hexadecimal.value, '3F') + + def ipv6_address(self): + """ + Match any ipv6 address. + + :return: a Term object with an ipv6 address regex. + :rtype: Term + """ + return Regex(self.Regexes.ipv6_address.value, '::ffff:192.0.2.128') + + def uuid(self): + """ + Match any uuid. + + :return: a Term object with a uuid regex. + :rtype: Term + """ + return Regex( + self.Regexes.uuid.value, 'fc763eba-0905-41c5-a27f-3934ab26786c' + ) + + def timestamp(self): + """ + Match any timestamp. + + :return: a Term object with a timestamp regex. + :rtype: Term + """ + return Regex( + self.Regexes.timestamp.value, datetime.datetime( + 2000, 2, 1, 12, 30, 0, 0 + ).isoformat() + ) + + def date(self): + """ + Match any date. + + :return: a Term object with a date regex. + :rtype: Term + """ + return Regex( + self.Regexes.date.value, datetime.datetime( + 2000, 2, 1, 12, 30, 0, 0 + ).date().isoformat() + ) + + def time(self): + """ + Match any time. + + :return: a Term object with a time regex. + :rtype: Term + """ + return Regex( + self.Regexes.time_regex.value, datetime.datetime( + 2000, 2, 1, 12, 30, 0, 0 + ).time().isoformat() + ) + + def iso_8601_datetime(self, with_ms=False): + """ + Match a string for a full ISO 8601 Date. + + Does not do any sort of date validation, only checks if the string is + according to the ISO 8601 spec. + + This method differs from :func:`~pact.Format.timestamp`, + :func:`~pact.Format.date` and :func:`~pact.Format.time` implementations + in that it is more stringent and tests the string for exact match to + the ISO 8601 dates format. + + Without `with_ms` will match string containing ISO 8601 formatted dates + as stated bellow: + + * 2016-12-15T20:16:01 + * 2010-05-01T01:14:31.876 + * 2016-05-24T15:54:14.00000Z + * 1994-11-05T08:15:30-05:00 + * 2002-01-31T23:00:00.1234-02:00 + * 1991-02-20T06:35:26.079043+00:00 + + Otherwise, ONLY dates with milliseconds will match the pattern: + + * 2010-05-01T01:14:31.876 + * 2016-05-24T15:54:14.00000Z + * 2002-01-31T23:00:00.1234-02:00 + * 1991-02-20T06:35:26.079043+00:00 + + :param with_ms: Enforcing millisecond precision. + :type with_ms: bool + :return: a Term object with a date regex. + :rtype: Term + """ + date = [1991, 2, 20, 6, 35, 26] + if with_ms: + matcher = self.Regexes.iso_8601_datetime_ms.value + date.append(79043) + else: + matcher = self.Regexes.iso_8601_datetime.value + + return Regex( + matcher, + datetime.datetime(*date, tzinfo=datetime.timezone.utc).isoformat() + ) + + class Regexes(Enum): + """Regex Enum for common formats.""" + + ip_address = r'(\d{1,3}\.)+\d{1,3}' + hexadecimal = r'[0-9a-fA-F]+' + ipv6_address = r'(\A([0-9a-f]{1,4}:){1,1}(:[0-9a-f]{1,4}){1,6}\Z)|' \ + r'(\A([0-9a-f]{1,4}:){1,2}(:[0-9a-f]{1,4}){1,5}\Z)|(\A([0-9a-f]' \ + r'{1,4}:){1,3}(:[0-9a-f]{1,4}){1,4}\Z)|(\A([0-9a-f]{1,4}:)' \ + r'{1,4}(:[0-9a-f]{1,4}){1,3}\Z)|(\A([0-9a-f]{1,4}:){1,5}(:[0-' \ + r'9a-f]{1,4}){1,2}\Z)|(\A([0-9a-f]{1,4}:){1,6}(:[0-9a-f]{1,4})' \ + r'{1,1}\Z)|(\A(([0-9a-f]{1,4}:){1,7}|:):\Z)|(\A:(:[0-9a-f]{1,4})' \ + r'{1,7}\Z)|(\A((([0-9a-f]{1,4}:){6})(25[0-5]|2[0-4]\d|[0-1]' \ + r'?\d?\d)(\.(25[0-5]|2[0-4]\d|[0-1]?\d?\d)){3})\Z)|(\A(([0-9a-f]' \ + r'{1,4}:){5}[0-9a-f]{1,4}:(25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25' \ + r'[0-5]|2[0-4]\d|[0-1]?\d?\d)){3})\Z)|(\A([0-9a-f]{1,4}:){5}:[' \ + r'0-9a-f]{1,4}:(25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]|2[0-4' \ + r']\d|[0-1]?\d?\d)){3}\Z)|(\A([0-9a-f]{1,4}:){1,1}(:[0-9a-f]' \ + r'{1,4}){1,4}:(25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]|2[0-4]' \ + r'\d|[0-1]?\d?\d)){3}\Z)|(\A([0-9a-f]{1,4}:){1,2}(:[0-9a-f]{1,4}' \ + r'){1,3}:(25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]|2[0-4]\d|[0' \ + r'-1]?\d?\d)){3}\Z)|(\A([0-9a-f]{1,4}:){1,3}(:[0-9a-f]{1,4}){1,' \ + r'2}:(25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]|2[0-4]\d|[0-1]' \ + r'?\d?\d)){3}\Z)|(\A([0-9a-f]{1,4}:){1,4}(:[0-9a-f]{1,4}){1,1}:' \ + r'(25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]|2[0-4]\d|[0-1]?\d?' \ + r'\d)){3}\Z)|(\A(([0-9a-f]{1,4}:){1,5}|:):(25[0-5]|2[0-4]\d|[0' \ + r'-1]?\d?\d)(\.(25[0-5]|2[0-4]\d|[0-1]?\d?\d)){3}\Z)|(\A:(:[' \ + r'0-9a-f]{1,4}){1,5}:(25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]' \ + r'|2[0-4]\d|[0-1]?\d?\d)){3}\Z)' + uuid = r'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' + timestamp = r'^([\+-]?\d{4}(?!\d{2}\b))((-?)((0[1-9]|1[0-2])(\3(' \ + r'[12]\d|0[1-9]|3[01]))?|W([0-4]\d|5[0-2])(-?[1-7])?|(00[1-' \ + r'9]|0[1-9]\d|[12]\d{2}|3([0-5]\d|6[1-6])))([T\s]((([01]\d|2' \ + r'[0-3])((:?)[0-5]\d)?|24\:?00)([\.,]\d+(?!:))?)?(\17[0-5]\d' \ + r'([\.,]\d+)?)?([zZ]|([\+-])([01]\d|2[0-3]):?([0-5]\d)?)?)?)?$' + date = r'^([\+-]?\d{4}(?!\d{2}\b))((-?)((0[1-9]|1[0-2])(\3([12]\d|' \ + r'0[1-9]|3[01]))?|W([0-4]\d|5[0-2])(-?[1-7])?|(00[1-9]|0[1-9]\d|' \ + r'[12]\d{2}|3([0-5]\d|6[1-6])))?)' + time_regex = r'^(T\d\d:\d\d(:\d\d)?(\.\d+)?(([+-]\d\d:\d\d)|Z)?)?$' + iso_8601_datetime = r'^\d{4}-[01]\d-[0-3]\d\x54[0-2]\d:[0-6]\d:' \ + r'[0-6]\d(?:\.\d+)?(?:(?:[+-]\d\d:\d\d)|\x5A)?$' + iso_8601_datetime_ms = r'^\d{4}-[01]\d-[0-3]\d\x54[0-2]\d:[0-6]\d:' \ + r'[0-6]\d\.\d+(?:(?:[+-]\d\d:\d\d)|\x5A)?$' diff --git a/pact/message_provider.py b/pact/message_provider.py index 4774fc84a..a5ecbf46d 100644 --- a/pact/message_provider.py +++ b/pact/message_provider.py @@ -6,7 +6,8 @@ from requests.adapters import HTTPAdapter from requests.packages.urllib3 import Retry from multiprocessing import Process -from .verifier import Verifier +from pact.ffi.verifier import VerifyResult +from pact.verifier_v3 import VerifierV3 from .http_proxy import run_proxy import logging @@ -34,8 +35,9 @@ def __init__( consumer, pact_dir=os.getcwd(), version="3.0.0", - proxy_host='localhost', - proxy_port='1234' + proxy_host='127.0.0.1', + proxy_port='1234', + **kwargs ): """Create a Message Provider instance.""" self.message_providers = message_providers @@ -107,13 +109,15 @@ def _stop_proxy(self): if isinstance(self._process, Process): self._wait_for_server_stop() - def verify(self): + def verify(self, **kwargs): """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}' + verifier = VerifierV3(provider=self.provider, + provider_base_url=self._proxy_url(), + ) + return_code, logs = verifier.verify_pacts(sources=[pact_files], + **kwargs) + return VerifyResult(return_code, logs) def verify_with_broker(self, enable_pending=False, include_wip_pacts_since=None, **kwargs): """Use Broker to verify. @@ -122,17 +126,20 @@ def verify_with_broker(self, enable_pending=False, include_wip_pacts_since=None, broker_username ([String]): broker username broker_password ([String]): broker password broker_url ([String]): url of broker - enable_pending ([Boolean]) - include_wip_pacts_since ([String]) - publish_version ([String]) + enable_pending ([Boolean]): enable pending pacts + include_wip_pacts_since ([String]): include wip pacts since + publish_version ([String]): publish version + pacts ([String]): pacts to verify """ - verifier = Verifier(provider=self.provider, - provider_base_url=self._proxy_url()) - - return_code, _ = verifier.verify_with_broker(enable_pending, include_wip_pacts_since, **kwargs) - - assert (return_code == 0), f'Expected returned_code = 0, actual = {return_code}' + verifier = VerifierV3(provider=self.provider, + provider_base_url=self._proxy_url(), + ) + return_code, logs = verifier.verify_pacts(enable_pending=enable_pending, + include_wip_pacts_since=include_wip_pacts_since, + **kwargs) + + return VerifyResult(return_code, logs) def __enter__(self): """ diff --git a/pact/pact.py b/pact/pact.py index 28126bfc6..f6ed25952 100644 --- a/pact/pact.py +++ b/pact/pact.py @@ -276,7 +276,7 @@ def verify(self): """ self._interactions = [] resp = requests.get( - self.uri + "/interactions/verification", headers=self.HEADERS, verify=False + self.uri + "/interactions/verification", headers=self.HEADERS, verify=False, allow_redirects=True ) assert resp.status_code == 200, resp.text resp = requests.post(self.uri + "/pact", headers=self.HEADERS, verify=False) diff --git a/pact/pact_exception.py b/pact/pact_exception.py new file mode 100644 index 000000000..7d7b9e5b1 --- /dev/null +++ b/pact/pact_exception.py @@ -0,0 +1,21 @@ +"""Custom Pact Exception.""" + +class PactException(Exception): + """PactException when input isn't valid. + + Args: + Exception ([type]): [description] + + Raises: + KeyError: [description] + Exception: [description] + + Returns: + [type]: [description] + + """ + + def __init__(self, *args, **kwargs): + """Create wrapper.""" + super().__init__(*args, **kwargs) + self.message = args[0] diff --git a/pact/pact_v3.py b/pact/pact_v3.py new file mode 100644 index 000000000..0bf3317f9 --- /dev/null +++ b/pact/pact_v3.py @@ -0,0 +1,161 @@ +"""V3 API for creating a contract and configuring the mock service.""" +import os +import os.path +from pact.ffi.native_mock_server import MockServer, MockServerResult +from pact.matchers_v3 import V3Matcher +from pact.verifier_v3 import CustomHeader + +class PactV3(object): + """ + Represents a contract between a consumer and provider (supporting Pact V3 specification). + + Provides Python context handlers to configure the Pact mock service to + perform tests on a Python consumer. + """ + + def __init__(self, consumer_name, provider_name, hostname=None, port=None, transport=None, pact_dir=None, log_level=None): + """Create a Pact V3 instance.""" + self.consumer_name = consumer_name + self.provider_name = provider_name + self.log_level = log_level + self.pact_dir = pact_dir or os.path.join(os.getcwd(), 'pacts') + # init(log_level=log_level) + self.pact = MockServer() + self.pact_handle = None + self.interactions = [] + self.hostname = hostname or '127.0.0.1' + self.port = port or 0 + self.transport = transport or 'http' + + def new_http_interaction(self, description): + """Create a new http interaction.""" + self.pact_handle = MockServer().new_pact(self.consumer_name, self.provider_name) + self.interactions = [self.pact.new_interaction(self.pact_handle, description)] + return self + + def given(self, provider_state, params={}): + """Define the provider state for this pact.""" + self.pact.given(self.interactions[0], provider_state) + # self.pact.given(self.interactions[0],provider_state, params) + return self + + def upon_receiving(self, description): + """Define the name of this contract.""" + self.pact.upon_receiving(self.interactions[0], description) + # self.pact.upon_receiving(description) + return self + + def with_request(self, method='GET', path='/', query=None, headers=None, body=None): + """Define the request that the client is expected to perform.""" + self.pact.with_request(self.interactions[0], method, path) + # index = 0 + if headers is not None: + for header in headers: + print(header['name']) + print(header['value']) + # TODO:- deal with multi-value headers and increment the header value appropriately + self.pact.with_response_header(self.interactions[0], header['name'], 0, header['value']) + # index += 1 + if header['name'] in ['Content-Type', 'content-type']: + content_type = header['value'] + + if body is not None: + self.pact.with_request_body(self.interactions[0], content_type, self.__process_body(body)) + # TODO Add query header + # self.pact.with_request(method, path, query, headers, self.__process_body(body)) + return self + + def with_request_with_binary_file(self, method='POST', path='/', query=None, headers=None, file=None): + """Define the request that the client is expected to perform.""" + self.pact.with_request(self.interactions[0], method, path) + # index = 0 + if headers is not None: + for header in headers: + print(header['name']) + print(header['value']) + self.pact.with_request_header(self.interactions[0], header['name'], 0, header['value']) + # index += 1 + if header['name'] in ['Content-Type', 'content-type']: + content_type = header['value'] + + if file is not None: + with open(file, 'rb') as f: + file_content = f.read() # Read whole file in the file_content string + self.pact.with_binary_file(self.interactions[0], "req", content_type, self.__process_body(file_content)) + # TODO Add query header + # self.pact.with_request(method, path, query, headers, self.__process_body(body)) + return self + + def will_respond_with(self, status=200, headers: [CustomHeader] = None, body=None): + """Define the response the server is expected to create.""" + # self.pact.will_respond_with(status, headers, self.__process_body(body)) + + self.pact.response_status(self.interactions[0], status) + index = 0 + if headers is not None: + for header in headers: + print(header['name']) + print(header['value']) + print(index) + # TODO:- deal with multi-value headers and increment the header value appropriately + self.pact.with_response_header(self.interactions[0], header['name'], 0, header['value']) + index += 1 + if header['name'] in ['Content-Type', 'content-type']: + content_type = header['value'] + + if body is not None: + # self.pact.with_response_body(self.interactions[0], content_type, self.__process_body(body)) + self.pact.with_response_body(self.interactions[0], content_type, self.__process_body(body)) + return self + + def __enter__(self): + """ + Enter a Python context. + + Sets up the mock service to expect the client requests. + """ + # self.setup() + return + + def __exit__(self, exc_type, exc_val, exc_tb): + """ + Exit a Python context. + + Calls the mock service to verify that all interactions occurred as + expected, and has it write out the contracts to disk. + """ + if (exc_type, exc_val, exc_tb) != (None, None, None): + return + + return + + def start_service(self) -> int: + """ + Start the external Mock Service. + + :raises RuntimeError: if there is a problem starting the mock service. + """ + self.mock_server_port = self.pact.start_mock_server(self.pact_handle, self.hostname, self.port, self.transport, None) + return self.mock_server_port + + def verify(self) -> MockServerResult: + """ + Have the mock service verify all interactions occurred. + + Calls the mock service to verify that all interactions occurred as + expected, and has it write out the contracts to disk. + + :raises AssertionError: When not all interactions are found. + """ + # self.interactions = [] + return self.pact.verify(self.mock_server_port, self.pact_handle, self.pact_dir) + + def __process_body(self, body): + if isinstance(body, dict): + return {key: self.__process_body(value) for key, value in body.items()} + elif isinstance(body, list): + return [self.__process_body(value) for value in body] + elif isinstance(body, V3Matcher): + return self.__process_body(body.generate()) + else: + return body diff --git a/pact/verifier.py b/pact/verifier.py index 4388e1095..5e78eaa55 100644 --- a/pact/verifier.py +++ b/pact/verifier.py @@ -27,6 +27,10 @@ def __str__(self): """ return 'Verifier for {} with url {}'.format(self.provider, self.provider_base_url) + def version(self): + """Return version info.""" + return VerifyWrapper().version() + def validate_publish(self, **kwargs): """Validate publish has a version.""" if (kwargs.get('publish') is not None) and (kwargs.get('publish_version') is None): @@ -50,16 +54,16 @@ def verify_pacts(self, *pacts, enable_pending=False, include_wip_pacts_since=Non pacts = expand_directories(pacts) options = self.extract_params(**kwargs) - success, logs = VerifyWrapper().call_verify(*pacts, - provider=self.provider, - provider_base_url=self.provider_base_url, - enable_pending=enable_pending, - include_wip_pacts_since=include_wip_pacts_since, - **options) + success, logs = VerifyWrapper().verify(*pacts, + provider=self.provider, + provider_base_url=self.provider_base_url, + enable_pending=enable_pending, + include_wip_pacts_since=include_wip_pacts_since, + **options) return success, logs - def verify_with_broker(self, enable_pending=False, include_wip_pacts_since=None, **kwargs): + def verify_with_broker(self, pacts=None, enable_pending=False, include_wip_pacts_since=None, **kwargs): """Use Broker to verify. Args: @@ -69,6 +73,7 @@ def verify_with_broker(self, enable_pending=False, include_wip_pacts_since=None, enable_pending ([Boolean]) include_wip_pacts_since ([String]) publish_version ([String]) + pacts ([String]) """ broker_username = kwargs.get('broker_username', None) @@ -84,11 +89,12 @@ def verify_with_broker(self, enable_pending=False, include_wip_pacts_since=None, } options.update(self.extract_params(**kwargs)) - success, logs = VerifyWrapper().call_verify(provider=self.provider, - provider_base_url=self.provider_base_url, - enable_pending=enable_pending, - include_wip_pacts_since=include_wip_pacts_since, - **options) + success, logs = VerifyWrapper().verify(pacts=pacts, + provider=self.provider, + provider_base_url=self.provider_base_url, + enable_pending=enable_pending, + include_wip_pacts_since=include_wip_pacts_since, + **options) return success, logs def extract_params(self, **kwargs): diff --git a/pact/verifier_v3.py b/pact/verifier_v3.py new file mode 100644 index 000000000..497cb7b97 --- /dev/null +++ b/pact/verifier_v3.py @@ -0,0 +1,180 @@ +"""Classes and methods to verify Contracts (V3 implementation).""" + +import os +from typing import NamedTuple +from pact.ffi.native_verifier import NativeVerifier +from urllib.parse import urlparse +from pact.ffi.verifier import VerifyResult +from pact.pact_exception import PactException + +from pact.verify_wrapper import is_url + +class CustomHeader(NamedTuple): + """Custom header to send in the Pact Verifier request.""" + + name: str + value: str + +class VerifierV3(object): + """A Pact V3 Verifier.""" + + def __init__(self, provider, provider_base_url, **kwargs): + """Create a new Verifier. + + Args: + provider ([String]): provider name + provider_base_url ([String]): provider url + + """ + self.provider = provider + self.provider_base_url = provider_base_url + self.native_verifier = NativeVerifier() + self.verifier_handle = self.native_verifier.new() + + def __str__(self): + """Return string representation. + + Returns: + [String]: verifier description. + + """ + return 'V3 Verifier for {} with url {}'.format(self.provider, self.provider_base_url) + + def verify_pacts(self, # noqa: max-complexity: 18 + provider_app_version: str or None = None, + provider_branch: str or None = None, + broker_username: str or None = None, + broker_password: str or None = None, + broker_token: str or None = None, + broker_url: str or None = None, + provider_states_setup_url: str or None = None, + state_change_as_query: bool or None = False, + state_change_teardown: bool or None = False, + provider_tags: [str] or None = None, + consumer_version_selectors: [str] or None = None, + consumer_version_tags: [str] or None = None, + request_timeout: int = 300, + filter_description: str or None = None, + filter_state: str or None = None, + filter_no_state: bool or None = False, + build_url: str or None = None, + disable_ssl_verification: bool or None = False, + enable_pending: bool or None = False, + include_wip_pacts_since: str or None = None, + no_pacts_is_error: bool or None = False, + provider_transport: str or None = None, + publish_verification_results: bool or None = False, + sources: [str] or None = None, + consumer_filters: [str] or None = None, + add_custom_header: [CustomHeader] or None = None, + **kwargs): + """Verify the pacts against the provider. + + Args: + broker_username ([String]): broker username + broker_password ([String]): broker password + broker_url ([String]): url of broker + enable_pending ([Boolean]): enable pending pacts + include_wip_pacts_since ([String]): include wip pacts since + publish_version ([String]): publish version + pacts ([String]): pacts to verify + + Returns: + success: True if no failures + + """ + if not sources and not broker_url: + raise PactException('Pact sources or pact_broker_url required') + + # Processing provider base url / scheme and port etc from user provided provider_base_url + parsed_provider_base_url = urlparse(self.provider_base_url) + provider_scheme = 'http' if parsed_provider_base_url.scheme is None else parsed_provider_base_url.scheme + provider_hostname = parsed_provider_base_url.path.split( + "/")[0] if parsed_provider_base_url.scheme is None else parsed_provider_base_url.netloc.split(":")[0] + provider_path = parsed_provider_base_url.path.split("/")[1] if parsed_provider_base_url.scheme is None else parsed_provider_base_url.path + try: + provider_port = int(parsed_provider_base_url.path.split(":")[1]) + except IndexError: + try: + provider_port = int(parsed_provider_base_url.netloc.split(":")[1]) + except IndexError: + provider_port = 8000 + + self.native_verifier.set_provider_info(self.verifier_handle, self.provider, provider_scheme, provider_hostname, provider_port, provider_path) + + if filter_state is not None or filter_description is not None: + self.native_verifier.set_filter_info( + self.verifier_handle, + filter_description if filter_description is not None else '', + filter_state if filter_state is not None else '', + filter_no_state, + ) + + if publish_verification_results is True: + self.native_verifier.set_publish_options( + self.verifier_handle, + provider_app_version, + build_url, + provider_tags, + provider_branch + ) + + self.native_verifier.set_no_pacts_is_error(self.verifier_handle, no_pacts_is_error) + + if consumer_filters is not None: + self.native_verifier.set_consumer_filters(self.verifier_handle, consumer_filters) + + # Processing pact sources + + if broker_url is not None and sources is None and (consumer_version_selectors is None + and consumer_version_tags is None + and enable_pending is False + and include_wip_pacts_since is None): + # This will fetch all the pact files from the broker that match the provider name. + # NOTE - Ordering is important here, you need to have called pactffi_set_provider_info + # to the provider name to be set + print('Fetch via broker source matching provider name') + self.native_verifier.broker_source( + self.verifier_handle, + broker_url, + broker_username, + broker_password, + broker_token, + ) + elif sources is None: + print('Fetch pacts dynamically from broker') + self.native_verifier.broker_source_with_selectors( + self.verifier_handle, + broker_url, + broker_username, + broker_password, + broker_token, + enable_pending, + include_wip_pacts_since, + provider_tags, + provider_branch, + consumer_version_selectors, + consumer_version_tags, + ) + # Add a file, directory or url source (and include broker creds if provided) + if sources is not None: + for source in sources: + if os.path.isdir(source): + self.native_verifier.add_directory_source(self.verifier_handle, source) + elif is_url(source): + self.native_verifier.url_source(self.verifier_handle, source, broker_username, broker_password, broker_token) + else: + self.native_verifier.add_file_source(self.verifier_handle, source) + + self.native_verifier.set_verification_options(self.verifier_handle, disable_ssl_verification, request_timeout) + + if provider_states_setup_url is not None: + self.native_verifier.set_provider_state(self.verifier_handle, provider_states_setup_url, state_change_teardown, state_change_as_query) + + if add_custom_header is not None: + for header in add_custom_header: + self.native_verifier.add_custom_header(self.verifier_handle, header['name'], header['value']) + + result = self.native_verifier.execute(self.verifier_handle) + logs = self.native_verifier.logs(self.verifier_handle) + return VerifyResult(result, logs) diff --git a/pact/verify_wrapper.py b/pact/verify_wrapper.py index 0789b6aef..a2f721742 100644 --- a/pact/verify_wrapper.py +++ b/pact/verify_wrapper.py @@ -1,6 +1,7 @@ """Wrapper to verify previously created pacts.""" from pact.constants import VERIFIER_PATH +from pact.pact_exception import PactException import sys import os import platform @@ -8,8 +9,25 @@ import subprocess from os.path import isdir, join, isfile from os import listdir +from urllib.parse import urlparse +def is_url(path): + """Determine if a string is a valid url. + + Can be provided a URL or local path. URLs always result in a True. + + :param path: The path to check. + :type path: str + :return: True if url otherwise False. + :rtype: bool + """ + try: + result = urlparse(path) + return all([result.scheme, result.netloc]) + except ValueError: + return False + def capture_logs(process, verbose): """Capture logs from ruby process.""" result = '' @@ -102,26 +120,6 @@ def rerun_command(): env['PACT_INTERACTION_RERUN_COMMAND'] = command return env -class PactException(Exception): - """PactException when input isn't valid. - - Args: - Exception ([type]): [description] - - Raises: - KeyError: [description] - Exception: [description] - - Returns: - [type]: [description] - - """ - - def __init__(self, *args, **kwargs): - """Create wrapper.""" - super().__init__(*args, **kwargs) - self.message = args[0] - class VerifyWrapper(object): """A Pact Verifier Wrapper.""" @@ -132,19 +130,20 @@ def _broker_present(self, **kwargs): def _validate_input(self, pacts, **kwargs): if len(pacts) == 0 and not self._broker_present(**kwargs): - raise PactException('Pact urls or Pact broker required') + raise PactException('Pact sources or pact_broker_url required') - def call_verify( + def verify( # noqa: max-complexity: 15 self, *pacts, provider_base_url, provider, enable_pending=False, include_wip_pacts_since=None, **kwargs ): """Call verify method.""" verbose = kwargs.get('verbose', False) - - self._validate_input(pacts, **kwargs) + # if pacts: + # self._validate_input(pacts, **kwargs) provider_app_version = kwargs.get('provider_app_version') provider_version_branch = kwargs.get('provider_version_branch') + publish_verification_results = kwargs.get('publish_verification_results', False) options = { '--provider-base-url': provider_base_url, '--provider': provider, @@ -158,42 +157,53 @@ def call_verify( } command = [VERIFIER_PATH] - all_pact_urls = expand_directories(list(pacts)) + local_file = False + all_pact_urls = False + if pacts: + all_pact_urls = expand_directories(list(pacts)) + for pact_url in all_pact_urls: + if not is_url(pact_url): + local_file = True + + command.extend(all_pact_urls) - command.extend(all_pact_urls) command.extend(['{}={}'.format(k, v) for k, v in options.items() if v]) + if not all_pact_urls and not kwargs.get('broker_url', None): + raise PactException('Pact sources or pact_broker_url required') + + if publish_verification_results is True and local_file: + raise PactException('Cannot publish verification results for local files') + if (provider_app_version): - command.extend(["--provider-app-version", - provider_app_version]) + command.extend(["--provider-app-version", provider_app_version]) - if (kwargs.get('publish_verification_results', False) is True): + if publish_verification_results is True and not local_file: command.extend(['--publish-verification-results']) - if (kwargs.get('verbose', False) is True): + if verbose: command.extend(['--verbose']) - if enable_pending: - command.append('--enable-pending') - - else: - command.append('--no-enable-pending') - - if include_wip_pacts_since: - command.extend(['--include-wip-pacts-since={}'.format(include_wip_pacts_since)]) - if provider_version_branch: command.extend(["--provider-version-branch={}".format(provider_version_branch)]) headers = kwargs.get('custom_provider_headers', []) for header in headers: command.extend(['{}={}'.format('--custom-provider-header', header)]) - for tag in kwargs.get('consumer_tags', []): - command.extend(["--consumer-version-tag={}".format(tag)]) - for tag in kwargs.get('consumer_selectors', []): - command.extend(["--consumer-version-selector={}".format(tag)]) - for tag in kwargs.get('provider_tags', []): - command.extend(["--provider-version-tag={}".format(tag)]) + + if not all_pact_urls: + if enable_pending: + command.append('--enable-pending') + else: + command.append('--no-enable-pending') + if include_wip_pacts_since: + command.extend(['--include-wip-pacts-since={}'.format(include_wip_pacts_since)]) + for tag in kwargs.get('provider_tags', []): + command.extend(["--provider-version-tag={}".format(tag)]) + for selector in kwargs.get('consumer_selectors', []): + command.extend(["--consumer-version-selector={}".format(selector)]) + for tag in kwargs.get('consumer_tags', []): + command.extend(["--consumer-version-tag={}".format(tag)]) env = rerun_command() result = subprocess.Popen(command, bufsize=1, env=env, stdout=subprocess.PIPE, @@ -205,12 +215,6 @@ def call_verify( return result.returncode, logs - def publish_results(self, provider_app_version, command): - """Publish results to broker.""" - if not provider_app_version: - # todo implement - raise Exception('todo') - - command.extend(["--provider-app-version", - provider_app_version, - "--publish-verification-results"]) + def version(self): + """Publish version info.""" + return '0.0.0' diff --git a/pacts/TodoApp-TodoServiceV3.json b/pacts/TodoApp-TodoServiceV3.json new file mode 100644 index 000000000..eb7f7a241 --- /dev/null +++ b/pacts/TodoApp-TodoServiceV3.json @@ -0,0 +1,223 @@ +{ + "consumer": { + "name": "TodoApp" + }, + "interactions": [ + { + "description": "a request for projects", + "providerStates": [ + { + "name": "i have a list of projects" + } + ], + "request": { + "method": "GET", + "path": "/projects" + }, + "response": { + "body": [ + { + "id": 1, + "name": "Project 1", + "tasks": [ + { + "done": true, + "id": 101, + "name": "Do the laundry" + }, + { + "done": true, + "id": 101, + "name": "Do the laundry" + }, + { + "done": true, + "id": 101, + "name": "Do the laundry" + }, + { + "done": true, + "id": 101, + "name": "Do the laundry" + } + ] + } + ], + "generators": { + "body": { + "$[*].tasks[*].id": { + "max": 10, + "min": 0, + "type": "RandomInt" + } + } + }, + "headers": { + "Accept": "application/json", + "Content-Type": "application/json" + }, + "matchingRules": { + "body": { + "$": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$[*].id": { + "combine": "AND", + "matchers": [ + { + "match": "integer" + } + ] + }, + "$[*].name": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$[*].tasks": { + "combine": "AND", + "matchers": [ + { + "match": "type", + "min": 1 + } + ] + }, + "$[*].tasks[*].done": { + "combine": "AND", + "matchers": [ + { + "match": "type" + }, + { + "match": "type" + }, + { + "match": "type" + }, + { + "match": "type" + } + ] + }, + "$[*].tasks[*].id": { + "combine": "AND", + "matchers": [ + { + "match": "integer" + }, + { + "match": "integer" + }, + { + "match": "integer" + }, + { + "match": "integer" + } + ] + }, + "$[*].tasks[*].name": { + "combine": "AND", + "matchers": [ + { + "match": "type" + }, + { + "match": "type" + }, + { + "match": "type" + }, + { + "match": "type" + } + ] + } + }, + "header": {} + }, + "status": 200 + } + }, + { + "description": "a request for projects in XML", + "providerStates": [ + { + "name": "i have a list of projects" + } + ], + "request": { + "headers": { + "Accept": "application/xml" + }, + "method": "GET", + "path": "/projects" + }, + "response": { + "body": "\n \n \n 1\n \n \n 1\n Do the laundry\n true\n \n \n 2\n Do the dishes\n false\n \n \n 3\n Do the backyard\n false\n \n \n 4\n Do nothing\n false\n \n \n \n ", + "headers": { + "Content-Type": "application/xml" + }, + "status": 200 + } + }, + { + "description": "a request to store an image against the project", + "providerStates": [ + { + "name": "i have a project" + } + ], + "request": { + "body": "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AAAAAAAAAAAEAAAAAAAAAAEAAAAAAAAABQAAAAAAAAAFAAAAAAAAAAEAAAAAAAAABgAAAAAAAABpAAAAAAAAALAJUBIBgDprkGJmIwEAAAAAEJsjAQAAACMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAQAAAAAAAAABAAAAAAAAAAIAAAAAAAAAbQAAAAAAAACwCVASAYA6a0BpCCUBAAAAAEqIJAEAAAAKAAAAAAAAAA0AEQAAAAAAAAAAAAAAAAAFAAAAAAAAAAEAAAAAAAAABgAAAAAAAAAGAAAAAAAAAAEAAAAAAAAABwAAAAAAAABjAAAAAAAAALAJUBIBgDprkGJmIwEAAAAAEJsjAQAAACMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAQAAAAAAAAABAAAAAAAAAAIAAAAAAAAAbQAAAAAAAACwCVASAYA6a0BpCCUBAAAAAEqIJAEAAAAKAAAAAAAAAA0AEQAAAAAAAAAAAAAAAAAGAAAAAAAAAAEAAAAAAAAABwAAAAAAAAAHAAAAAAAAAAEAAAAAAAAACAAAAAAAAABhAAAAAAAAALAJUBIBgDprkGJmIwEAAAAAEJsjAQAAACMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAQAAAAAAAAABAAAAAAAAAAIAAAAAAAAAbQAAAAAAAACwCVASAYA6a0BpCCUBAAAAAEqIJAEAAAAKAAAAAAAAAA0AEQAAAAAAAAAAAAAAAAAHAAAAAAAAAAEAAAAAAAAACAAAAAAAAAAIAAAAAAAAAAEAAAAAAAAACQAAAAAAAAB0AAAAAAAAALAJUBIBgDprkGJmIwEAAAAAEJsjAQAAACMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAQAAAAAAAAABAAAAAAAAAAIAAAAAAAAAbQAAAAAAAACwCVASAYA6a0BpCCUBAAAAAEqIJAEAAAAKAAAAAAAAAA0AEQAAAAAAAAAAAAAAAAAIAAAAAAAAAAEAAAAAAAAACQAAAAAAAAAJAAAAAAAAAAEAAAAAAAAACgAAAAAAAABpAAAAAAAAALAJUBIBgDprkGJmIwEAAAAAEJsjAQAAACMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAQAAAAAAAAABAAAAAAAAAAIAAAAAAAAAbQAAAAAAAACwCVASAYA6a0BpCCUBAAAAAEqIJAEAAAAKAAAAAAAAAA0AEQAAAAAAAAAAAAAAAAAJAAAAAAAAAAEAAAAAAAAACgAAAAAAAAAKAAAAAAAAAAEAAAAAAAAACwAAAAAAAABvAAAAAAAAALAJUBIBgDprkGJmIwEAAAAAEJsjAQAAACMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAQAAAAAAAAABAAAAAAAAAAIAAAAAAAAAbQAAAAAAAACwCVASAYA6a0BpCCUBAAAAAEqIJAEAAAAKAAAAAAAAAA0AEQAAAAAAAAAAAAAAAAAKAAAAAAAAAAEAAAAAAAAACwAAAAAAAAALAAAAAAAAAAEAAAAAAAAADAAAAAAAAABuAAAAAAAAALAJUBIBgDprkGJmIwEAAAAAEJsjAQAAACMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAQAAAAAAAAABAAAAAAAAAAIAAAAAAAAAbQAAAAAAAACwCVASAYA6a0BpCCUBAAAAAEqIJAEAAAAKAAAAAAAAAA0AEQAAAAAAAAAAAAAAAAALAAAAAAAAAAEAAAAAAAAADAAAAAAAAAAMAAAAAAAAAAEAAAAAAAAADQAAAAAAAAAvAAAAAAAAALAJUBIBgDprkGJmIwEAAAAAEJsjAQAAACMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAQAAAAAAAAABAAAAAAAAAAIAAAAAAAAAbQAAAAAAAACwCVASAYA6a0BpCCUBAAAAAEqIJAEAAAAKAAAAAAAAAA0AEQAAAAAAAAAAAAAAAAAMAAAAAAAAAAEAAAAAAAAADQAAAAAAAAAOAAAAAAAAAAEAAAAAAAAADwAAAAAAAACgrmEjAQAAAA0AAAAAAAAA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", + "headers": { + "content-type": "application/octet-stream" + }, + "matchingRules": { + "body": { + "$": { + "combine": "AND", + "matchers": [ + { + "match": "contentType", + "value": "application/octet-stream" + } + ] + } + }, + "header": {} + }, + "method": "POST", + "path": "/projects/1001/images" + }, + "response": { + "status": 201 + } + } + ], + "metadata": { + "pact-python": { + "version": "2.0.1b0" + }, + "pactRust": { + "ffi": "0.4.8", + "mockserver": "1.2.3", + "models": "1.1.11" + }, + "pactSpecification": { + "version": "3.0.0" + } + }, + "provider": { + "name": "TodoServiceV3" + } +} \ No newline at end of file diff --git a/pacts/http-consumer-1-http-provider.json b/pacts/http-consumer-1-http-provider.json new file mode 100644 index 000000000..b1f28864a --- /dev/null +++ b/pacts/http-consumer-1-http-provider.json @@ -0,0 +1,154 @@ +{ + "consumer": { + "name": "http-consumer-1" + }, + "interactions": [ + { + "description": "A POST request to create book", + "providerStates": [ + { + "name": "No book fixtures required" + } + ], + "request": { + "body": { + "author": "Margaret Atwood", + "description": "Brilliantly conceived and executed, this powerful evocation of twenty-first century America gives full rein to Margaret Atwood's devastating irony, wit and astute perception.", + "isbn": "0099740915", + "publicationDate": "1985-07-31T00:00:00+00:00", + "title": "The Handmaid's Tale" + }, + "headers": { + "Content-Type": "application/json" + }, + "matchingRules": { + "body": { + "$.author": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.description": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.isbn": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.publicationDate": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "\\d{4}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d([+-][0-2]\\d:[0-5]\\d|Z)" + } + ] + }, + "$.title": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + }, + "header": {} + }, + "method": "POST", + "path": "/api/books" + }, + "response": { + "body": { + "@context": "/api/contexts/Book", + "@id": "/api/books/0114b2a8-3347-49d8-ad99-0e792c5a30e6", + "@type": "Book", + "author": "Melisa Kassulke", + "description": "Quaerat odit quia nisi accusantium natus voluptatem. Explicabo corporis eligendi ut ut sapiente ut qui quidem. Optio amet velit aut delectus. Sed alias asperiores perspiciatis deserunt omnis. Mollitia unde id in.", + "publicationDate": "1999-02-13T00:00:00+07:00", + "reviews": [], + "title": "Voluptas et tempora repellat corporis excepturi." + }, + "headers": { + "Content-Type": "application/ld+json; charset=utf-8" + }, + "matchingRules": { + "body": { + "$.author": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.description": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.publicationDate": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "^\\d{4}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d([+-][0-2]\\d:[0-5]\\d|Z)$" + } + ] + }, + "$.title": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$['@id']": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "^\\/api\\/books\\/[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$" + } + ] + } + }, + "header": {} + }, + "status": 200 + } + } + ], + "metadata": { + "pact-python": { + "version": "2.0.1b0" + }, + "pactRust": { + "ffi": "0.4.7", + "mockserver": "1.2.3", + "models": "1.1.9" + }, + "pactSpecification": { + "version": "3.0.0" + } + }, + "provider": { + "name": "http-provider" + } +} \ No newline at end of file diff --git a/requirements_dev.txt b/requirements_dev.txt index e179ddbee..ed7e38f94 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -24,3 +24,9 @@ markupsafe==2.0.1; python_version < '3.7' markupsafe==2.1.2; python_version >= '3.7' httpx==0.22.0; python_version < '3.7' httpx==0.23.3; python_version >= '3.7' +cffi>=1.14.6 +pytest-httpserver==1.0.1 +# grpcio==1.48.2; python_version < '3.7' +# grpcio==1.56.2; python_version >= '3.7' +# grpcio-tools==1.48.2; python_version < '3.7' +# grpcio-tools==1.56.2; python_version >= '3.7' \ No newline at end of file diff --git a/script/generate_verifier_args.py b/script/generate_verifier_args.py new file mode 100755 index 000000000..191422938 --- /dev/null +++ b/script/generate_verifier_args.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python +""" +Generate Verifier args class + +This script generates the python source for a class to be used for the Pact +verifier. It works by parsing the JSON produced by the Rust Pact FFI library +function "pactffi_verifier_cli_args". + +Each argument available to the Rust Verifier is added as a line, along with the +description provided from Rust. This covers the single option argument, bool +arguments, and lists of options. +""" +import json +from pathlib import Path +from typing import List +from typing import Optional + +from pact.ffi.verifier import Verifier + + +def generate_verifier_args_json(pact_ffi_args_json: str) -> None: + """Call the Rust Pact FFI library to identify the args, and write to a file""" + arguments = Verifier()._cli_args_raw() + + with open(pact_ffi_args_json, "w") as f: + f.writelines(json.dumps(arguments, indent=2)) + + +def generate_verifier_args_source(pact_ffi_args_json: str) -> None: + """Take the generated JSON file, and construct a quick and dirty class""" + cli_json_path = Path.cwd().joinpath(pact_ffi_args_json) + with open(cli_json_path) as json_file: + data = json.load(json_file) + + list_options = [ + (attribute["long"].replace("-", "_"), Optional[List[str]], attribute["help"], "field(default_factory=list)") + for attribute in data.get("options") + if attribute["multiple"] + ] + str_options = [ + (attribute["long"].replace("-", "_"), Optional[str], attribute["help"], None) + for attribute in data.get("options") + if not attribute["multiple"] + ] + bool_options = [ + (attribute["long"].replace("-", "_"), Optional[bool], attribute["help"], None) + for attribute in data.get("flags") + ] + + nl = "\n" + lines = ( + f"import typing{nl}" + f"from dataclasses import dataclass, field{nl}" + f"{nl}" + f"{nl}" + f"@dataclass{nl}" + f"class VerifierArgs:{nl}" + f' """Auto-generated class, containing the arguments available to the pact verifier."""{nl}' + f"{nl}" + f'{nl.join([f" # {option[2]}{nl} {option[0]}: {option[1]} = {option[3]}{nl}" for option in str_options + list_options + bool_options])}' + "" + ) + print(lines) + + with open("pact/ffi/verifier_args.py", "w") as f: + f.writelines(lines) + + +if __name__ == "__main__": + pact_ffi_args_json = "examples/ffi/pact_ffi_verifier_args.json" + + generate_verifier_args_json(pact_ffi_args_json) + generate_verifier_args_source(pact_ffi_args_json) diff --git a/script/install_plugins.sh b/script/install_plugins.sh new file mode 100755 index 000000000..34b93d733 --- /dev/null +++ b/script/install_plugins.sh @@ -0,0 +1,59 @@ +#!/bin/bash -e +# +# Usage: +# $ curl -fsSL https://raw.githubusercontent.com/pact-foundation/pact-plugins/master/install-cli.sh | bash +# or +# $ wget -q https://raw.githubusercontent.com/pact-foundation/pact-plugins/master/install-cli.sh -O- | bash +# +set -e # Needed for Windows bash, which doesn't read the shebang + +function detect_osarch() { + case $(uname -sm) in + 'Linux x86_64') + os='linux' + arch='x86_64' + ;; + 'Linux aarch64') + os='linux' + arch='aarch64' + ;; + 'Darwin x86' | 'Darwin x86_64') + os='osx' + arch='x86_64' + ;; + 'Darwin arm64') + os='osx' + arch='aarch64' + ;; + CYGWIN*|MINGW32*|MSYS*|MINGW*) + os="windows" + arch='x86_64' + ext='.exe' + ;; + *) + echo "Sorry, you'll need to install the plugin CLI manually." + exit 1 + ;; + esac +} + + +PLUGIN_CLI_VERSION="0.1.0" +PROTOBUF_PLUGIN_VERSION="0.3.4" +detect_osarch + +if [ ! -f ~/.pact/bin/pact-plugin-cli ]; then + echo "--- 🐿 Installing plugins CLI version '${VERSION}' (from tag ${TAG})" + mkdir -p ~/.pact/bin + DOWNLOAD_LOCATION=https://github.com/pact-foundation/pact-plugins/releases/download/pact-plugin-cli-v${PLUGIN_CLI_VERSION}/pact-plugin-cli-${os}-${arch}${ext}.gz + echo " Downloading from: ${DOWNLOAD_LOCATION}" + curl -L -o ~/.pact/bin/pact-plugin-cli-${os}-${arch}.gz "${DOWNLOAD_LOCATION}" + echo " Downloaded $(file ~/.pact/bin/pact-plugin-cli-${os}-${arch}.gz)" + gunzip -N -f ~/.pact/bin/pact-plugin-cli-${os}-${arch}.gz + chmod +x ~/.pact/bin/pact-plugin-cli +fi + +if [ ! -d ~/.pact/plugins/protobuf-${PROTOBUF_PLUGIN_VERSION} ]; then + echo "--- 🐿 Installing protobuf plugin" + ~/.pact/bin/pact-plugin-cli install https://github.com/pactflow/pact-protobuf-plugin/releases/tag/v-${PROTOBUF_PLUGIN_VERSION} +fi \ No newline at end of file diff --git a/script/release_prep.sh b/script/release_prep.sh index b6bf29ca2..91cddbd1b 100755 --- a/script/release_prep.sh +++ b/script/release_prep.sh @@ -2,7 +2,7 @@ VERSION=$1 -if [[ $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]*$ ]]; then +if [[ $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+[A-z01]*$ ]]; then echo "Updating version $VERSION." else echo "Invalid version number $VERSION" diff --git a/setup.py b/setup.py index 2269af5f5..f5bcc4a0c 100644 --- a/setup.py +++ b/setup.py @@ -1,35 +1,53 @@ """pact-python PyPI Package.""" +import gzip import os import platform import shutil import sys import tarfile - +from distutils.command.sdist import sdist as sdist_orig +from typing import NamedTuple from zipfile import ZipFile - from setuptools import setup from setuptools.command.develop import develop from setuptools.command.install import install -from distutils.command.sdist import sdist as sdist_orig - IS_64 = sys.maxsize > 2 ** 32 PACT_STANDALONE_VERSION = '2.0.3' -PACT_STANDALONE_SUFFIXES = ['osx-x86_64.tar.gz', - 'osx-arm64.tar.gz', - 'linux-x86_64.tar.gz', - 'linux-arm64.tar.gz', - 'windows-x86_64.zip', - 'windows-x86.zip', - ] +PACT_STANDALONE_SUFFIXES = [ + 'osx-x86_64.tar.gz', + 'osx-arm64.tar.gz', + 'linux-x86_64.tar.gz', + 'linux-arm64.tar.gz', + 'windows-x86_64.zip', + 'windows-x86.zip', +] +PACT_FFI_VERSION = "0.4.7" +PACT_FFI_FILENAMES = [ + "libpact_ffi-linux-aarch64.so.gz", + "libpact_ffi-linux-x86_64.so.gz", + "libpact_ffi-osx-aarch64-apple-darwin.dylib.gz", + "libpact_ffi-osx-x86_64.dylib.gz", + "pact_ffi-windows-x86_64.dll.gz", +] +PACT_RUBY_FILENAME = "pact-{version}-{suffix}" here = os.path.abspath(os.path.dirname(__file__)) + +class Binary(NamedTuple): + filename: str # For example: "pact-1.2.3-linux-x86_64.tar.gz" + version: str # For example: "1.2.3" + suffix: str # For example: "linux-x86_64.tar.gz" + single_file: bool # True for Pact Rust FFI where we have one library file + + about = {} with open(os.path.join(here, "pact", "__version__.py")) as f: exec(f.read(), about) + class sdist(sdist_orig): """Subclass sdist to download all standalone ruby applications into ./pact/bin.""" @@ -41,15 +59,20 @@ def run(self): shutil.rmtree(package_bin_path, ignore_errors=True) os.mkdir(package_bin_path) + # Ruby binary for suffix in PACT_STANDALONE_SUFFIXES: - filename = ('pact-{version}-{suffix}').format(version=PACT_STANDALONE_VERSION, suffix=suffix) - download_ruby_app_binary(package_bin_path, filename, suffix) + filename = PACT_RUBY_FILENAME.format(version=PACT_STANDALONE_VERSION, suffix=suffix) + download_binary(package_bin_path, filename, get_ruby_uri(suffix=suffix)) + + # Rust FFI library + for filename in PACT_FFI_FILENAMES: + download_binary(package_bin_path, filename, get_rust_uri(filename=filename)) + download_binary(package_bin_path, 'pact.h', get_rust_uri(filename='pact.h')) super().run() class PactPythonDevelopCommand(develop): - """ - Custom develop mode installer for pact-python. + """Custom develop mode installer for pact-python. When the package is installed using `python setup.py develop` or `pip install -e` it will download and unpack the appropriate Pact @@ -59,26 +82,29 @@ class PactPythonDevelopCommand(develop): def run(self): """Install ruby command.""" develop.run(self) - package_bin_path = os.path.join(os.path.dirname(__file__), 'pact', 'bin') + package_bin_path = os.path.join(os.path.dirname(__file__), "pact", "bin") if not os.path.exists(package_bin_path): os.mkdir(package_bin_path) - install_ruby_app(package_bin_path, download_bin_path=None) + # Ruby + install_binary(package_bin_path, download_bin_path=None, binary=ruby_app_binary()) + + # Rust + install_binary(package_bin_path, download_bin_path=None, binary=rust_lib_binary()) class PactPythonInstallCommand(install): - """ - Custom installer for pact-python. + """Custom installer for pact-python. Installs the Python package and unpacks the platform appropriate version of the Ruby mock service and provider verifier. User Options: - --bin-path An absolute folder path containing predownloaded pact binaries + --bin-path An absolute folder path containing pre-downloaded pact binaries that should be used instead of fetching from the internet. """ - user_options = install.user_options + [('bin-path=', None, None)] + user_options = install.user_options + [("bin-path=", None, None)] def initialize_options(self): """Load our preconfigured options.""" @@ -92,44 +118,86 @@ def finalize_options(self): def run(self): """Install python binary.""" install.run(self) - package_bin_path = os.path.join(self.install_lib, 'pact', 'bin') + package_bin_path = os.path.join(self.install_lib, "pact", "bin") if not os.path.exists(package_bin_path): os.mkdir(package_bin_path) - install_ruby_app(package_bin_path, self.bin_path) + # Ruby + install_binary(package_bin_path, self.bin_path, binary=ruby_app_binary()) -def install_ruby_app(package_bin_path: str, download_bin_path=None): - """ - Installs the ruby standalone application for this OS. + # Rust + install_binary(package_bin_path, self.bin_path, binary=rust_lib_binary()) + + +def get_ruby_uri(suffix) -> str: + """Determine the full URI to download the Ruby binary from.""" + uri = ( + "https://github.com/pact-foundation/pact-ruby-standalone/releases" + "/download/v{version}/pact-{version}-{suffix}" + ) + return uri.format(version=PACT_STANDALONE_VERSION, suffix=suffix) + + +def get_rust_uri(filename) -> str: + """Determine the full URI to download the Rust binary from.""" + uri = ( + "https://github.com/pact-foundation/pact-reference/releases" + "/download/libpact_ffi-v{version}/{filename}" + ) + return uri.format(version=PACT_FFI_VERSION, filename=filename) + + +def install_binary(package_bin_path, download_bin_path, binary: Binary): + """Installs the ruby standalone application for this OS. :param package_bin_path: The path where we want our pact binaries unarchived. :param download_bin_path: An optional path containing pre-downloaded pact binaries. + :param binary: Details of the zipped binary files required """ - binary = ruby_app_binary() - - # The compressed Pact .tar.gz, zip etc file is expected to be in download_bin_path (if provided). - # Otherwise we will look in package_bin_path. - source_dir = download_bin_path if download_bin_path else package_bin_path - pact_unextracted_path = os.path.join(source_dir, binary['filename']) - - if os.path.isfile(pact_unextracted_path): - # Already downloaded, so just need to extract - extract_ruby_app_binary(source_dir, package_bin_path, binary['filename']) + print("-> install_binary({package_bin_path}, {download_bin_path}, {binary})".format( + package_bin_path=package_bin_path, + download_bin_path=download_bin_path, + binary=binary + )) + + if download_bin_path is not None: + # If a download_bin_path has been provided, but does not contain what we + # expect, do not continue + path = os.path.join(download_bin_path, binary.filename) + if not os.path.isfile(path): + raise RuntimeError("Could not find {} binary.".format(path)) + else: + if binary.single_file: + extract_gz(download_bin_path, package_bin_path, binary.filename) + else: + extract_ruby_app_binary(download_bin_path, package_bin_path, binary.filename) else: - if download_bin_path: - # An alternative source was provided, but did not contain the .tar.gz - raise RuntimeError('Could not find {} binary.'.format(pact_unextracted_path)) + # Otherwise, download to the destination package_bin_path, skipping to + # just extract if we have it already + path = os.path.join(package_bin_path, binary.filename) + if not os.path.isfile(path): + # Ruby binary + if binary.suffix in PACT_STANDALONE_SUFFIXES: + download_binary(package_bin_path, binary.filename, uri=get_ruby_uri(binary.suffix)) + + # Rust FFI library + if binary.filename in PACT_FFI_FILENAMES: + print(binary.filename) + download_binary(package_bin_path, binary.filename, get_rust_uri(filename=binary.filename)) + download_binary(package_bin_path, 'pact.h', get_rust_uri(filename='pact.h')) + + if binary.single_file: + extract_gz(package_bin_path, package_bin_path, binary.filename) else: - # Clean start, download an extract - download_ruby_app_binary(package_bin_path, binary['filename'], binary['suffix']) - extract_ruby_app_binary(package_bin_path, package_bin_path, binary['filename']) + extract_ruby_app_binary(package_bin_path, package_bin_path, binary.filename) + print("<- install_binary") -def ruby_app_binary(): - """ - Determine the ruby app binary required for this OS. - :return A dictionary of type {'filename': string, 'version': string, 'suffix': string } +def ruby_app_binary() -> Binary: + """Determines the ruby app binary required for this OS. + + :return Details of the binary file required """ target_platform = platform.platform().lower() @@ -147,91 +215,118 @@ def ruby_app_binary(): elif 'windows' in target_platform: suffix = 'windows-x86.zip' else: - msg = ('Unfortunately, {} is not a supported platform. Only Linux,' - ' Windows, and OSX are currently supported.').format( - platform.platform()) + msg = ( + "Unfortunately, {} is not a supported platform. Only Linux," " Windows, and OSX are currently supported." + ).format(platform.platform()) raise Exception(msg) - binary = binary.format(version=PACT_STANDALONE_VERSION, suffix=suffix) - return {'filename': binary, 'version': PACT_STANDALONE_VERSION, 'suffix': suffix} + binary = PACT_RUBY_FILENAME.format(version=PACT_STANDALONE_VERSION, suffix=suffix) + return Binary(filename=binary, version=PACT_STANDALONE_VERSION, suffix=suffix, single_file=False) + -def download_ruby_app_binary(path_to_download_to, filename, suffix): +def rust_lib_binary() -> Binary: + """Determines the Rust FFI library binary required for this OS. + + :return Details of the binary file required """ - Download `binary` into `path_to_download_to`. + target_platform = platform.platform().lower() + # print(target_platform) + # print(platform.machine()) + + if ("darwin" in target_platform or "macos" in target_platform) and ("aarch64" in platform.machine() or "arm64" in platform.machine()): + binary = "libpact_ffi-osx-aarch64-apple-darwin.dylib.gz" + elif "darwin" in target_platform or "macos" in target_platform: + binary = "libpact_ffi-osx-x86_64.dylib.gz" + elif "linux" in target_platform and IS_64 and ("aarch64" in platform.machine() or "arm64" in platform.machine()): + binary = "libpact_ffi-linux-aarch64.so.gz" + elif "linux" in target_platform and IS_64: + binary = "libpact_ffi-linux-x86_64.so.gz" + elif "windows" in target_platform: + binary = "pact_ffi-windows-x86_64.dll.gz" + else: + msg = ( + "Unfortunately, {} is not a supported platform. Only Linux x86_64," + " Windows, and OSX are currently supported." + ).format(target_platform) + raise Exception(msg) + + return Binary(filename=binary, version=PACT_FFI_VERSION, suffix=None, single_file=True) + +def download_binary(path_to_download_to, filename, uri): + """Downloads `filename` into `path_to_download_to`. :param path_to_download_to: The path where binaries should be downloaded. :param filename: The filename that should be installed. - :param suffix: The suffix of the standalone app to install. + :param uri: The URI to download the file from. """ - uri = ('https://github.com/pact-foundation/pact-ruby-standalone/releases' - '/download/v{version}/pact-{version}-{suffix}') + print("-> download_binary({path_to_download_to}, {filename}, {uri})".format(path_to_download_to=path_to_download_to, filename=filename, uri=uri)) - if sys.version_info.major == 2: - from urllib import urlopen - else: - from urllib.request import urlopen + from urllib3 import Retry, PoolManager path = os.path.join(path_to_download_to, filename) - resp = urlopen(uri.format(version=PACT_STANDALONE_VERSION, suffix=suffix)) - with open(path, 'wb') as f: - if resp.code == 200: - f.write(resp.read()) + retries = Retry(connect=5, read=2, redirect=5, backoff_factor=0.1) + http = PoolManager(retries=retries) + resp = http.request('GET', uri) + with open(path, "wb") as f: + if resp.status == 200: + f.write(resp.data) else: - raise RuntimeError( - 'Received HTTP {} when downloading {}'.format( - resp.code, resp.url)) + raise RuntimeError("Received HTTP {} when downloading {}".format(resp.code, resp.url)) -def extract_ruby_app_binary(source, destination, binary): - """ - Extract the ruby app binary from `source` into `destination`. + print("<- download_binary") + + +def extract_ruby_app_binary(source: str, destination: str, binary: str): + """Extracts the ruby app binary from `source` into `destination`. :param source: The location of the binary to unarchive. :param destination: The location to unarchive to. :param binary: The binary that needs to be unarchived. """ + print("-> extract_ruby_app_binary({source}, {destination}, {binary})".format(source=source, destination=destination, binary=binary)) + path = os.path.join(source, binary) - if 'windows' in platform.platform().lower(): + if "windows" in platform.platform().lower(): with ZipFile(path) as f: f.extractall(destination) else: with tarfile.open(path) as f: - def is_within_directory(directory, target): - - abs_directory = os.path.abspath(directory) - abs_target = os.path.abspath(target) - - prefix = os.path.commonprefix([abs_directory, abs_target]) + f.extractall(destination) + os.remove(path) + print("<- extract_ruby_app_binary") - return prefix == abs_directory - def safe_extract(tar, path=".", members=None, *, numeric_owner=False): +def extract_gz(source: str, destination: str, binary: str): + print("-> extract_gz({source}, {destination}, {binary})".format(source=source, destination=destination, binary=binary)) - for member in tar.getmembers(): - member_path = os.path.join(path, member.name) - if not is_within_directory(path, member_path): - raise Exception("Attempted Path Traversal in Tar File") + path = os.path.join(source, binary) + dest = os.path.splitext(os.path.join(destination, binary))[0] - tar.extractall(path, members, numeric_owner=numeric_owner) + with gzip.open(path, "rb") as f_in, open(dest, "wb") as f_out: + shutil.copyfileobj(f_in, f_out) + os.remove(path) - safe_extract(f, destination) + print("<- extract_gz") def read(filename): """Read file contents.""" path = os.path.realpath(os.path.join(os.path.dirname(__file__), filename)) - with open(path, 'rb') as f: - return f.read().decode('utf-8') + with open(path, "rb") as f: + return f.read().decode("utf-8") dependencies = [ + 'cffi==1.15.1', 'psutil>=5.9.4', 'six>=1.16.0', 'fastapi>=0.67.0', 'urllib3>=1.26.12', ] -if sys.version_info < (3, 7): +if sys.version_info.major < 4 and sys.version_info.minor < 7: dependencies += [ + 'cffi==1.15.1', 'click<=8.0.4', 'httpx==0.22.0', 'requests==2.27.1', @@ -239,6 +334,7 @@ def read(filename): ] else: dependencies += [ + 'cffi==1.15.1', 'click>=8.1.3', 'httpx==0.23.3', 'requests>=2.28.0', @@ -265,30 +361,27 @@ def read(filename): 'License :: OSI Approved :: MIT License', ] -if __name__ == '__main__': +if __name__ == "__main__": setup( - cmdclass={ - 'develop': PactPythonDevelopCommand, - 'install': PactPythonInstallCommand, - 'sdist': sdist}, - name='pact-python', - version=about['__version__'], - description=( - 'Tools for creating and verifying consumer driven ' - 'contracts using the Pact framework.'), - long_description=read('README.md'), - long_description_content_type='text/markdown', - author='Matthew Balvanz', - author_email='matthew.balvanz@workiva.com', - url='https://github.com/pact-foundation/pact-python', - entry_points=''' + cmdclass={"develop": PactPythonDevelopCommand, "install": PactPythonInstallCommand, "sdist": sdist}, + name="pact-python", + version=about["__version__"], + description=("Tools for creating and verifying consumer driven " "contracts using the Pact framework."), + long_description=read("README.md"), + long_description_content_type="text/markdown", + author="Matthew Balvanz", + author_email="matthew.balvanz@workiva.com", + url="https://github.com/pact-foundation/pact-python", + entry_points=""" [console_scripts] pact-verifier=pact.cli.verify:main - ''', + pact-verifier-ffi=pact.ffi.cli.verify:main + """, classifiers=CLASSIFIERS, python_requires='>=3.6,<4', install_requires=dependencies, - packages=['pact', 'pact.cli'], - package_data={'pact': ['bin/*']}, - package_dir={'pact': 'pact'}, - license='MIT License') + packages=["pact", "pact.cli"], + package_data={"pact": ["bin/*"]}, + package_dir={"pact": "pact"}, + license="MIT License", + ) diff --git a/tests/cli/test_verify.py b/tests/cli/test_verify.py index 3c242a941..bca49fb68 100644 --- a/tests/cli/test_verify.py +++ b/tests/cli/test_verify.py @@ -69,7 +69,7 @@ def test_provider_base_url_is_required(self): self.assertEqual(result.exit_code, 2) self.assertIn('--provider-base-url', result.output) - @patch("pact.verify_wrapper.VerifyWrapper.call_verify") + @patch("pact.verify_wrapper.VerifyWrapper.verify") def test_pact_urls_or_broker_are_required(self, mock_wrapper): result = self.runner.invoke( verify.main, ['--provider-base-url=http://localhost']) @@ -78,7 +78,7 @@ def test_pact_urls_or_broker_are_required(self, mock_wrapper): self.assertIn('at least one', result.output) mock_wrapper.assert_not_called() - @patch("pact.verify_wrapper.VerifyWrapper.call_verify") + @patch("pact.verify_wrapper.VerifyWrapper.verify") def test_broker_url_but_no_provider_required(self, mock_wrapper): result = self.runner.invoke( verify.main, ['--provider-base-url=http://localhost', @@ -87,17 +87,17 @@ def test_broker_url_but_no_provider_required(self, mock_wrapper): mock_wrapper.assert_not_called() self.assertEqual(result.exit_code, 1) - @patch("pact.verify_wrapper.VerifyWrapper.call_verify") + @patch("pact.verify_wrapper.VerifyWrapper.verify") @patch("pact.verify_wrapper.isfile", return_value=True) def test_wrapper_error_code_returned(self, mock_isfile, mock_wrapper): mock_wrapper.return_value = 8, None # rnd number to indicate retval returned result = self.runner.invoke(verify.main, self.all_url_opts) - self.assertFalse(mock_wrapper.call_verify.called) + self.assertFalse(mock_wrapper.verify.called) self.assertEqual(result.exit_code, 8) - @patch("pact.verify_wrapper.VerifyWrapper.call_verify") + @patch("pact.verify_wrapper.VerifyWrapper.verify") @patch("pact.verify_wrapper.isfile", return_value=True) def test_successful_verification(self, mock_isfile, mock_wrapper): mock_wrapper.return_value = 0, None # rnd number to indicate retval returned @@ -115,7 +115,7 @@ def test_successful_verification(self, mock_isfile, mock_wrapper): publish_verification_results=False, include_wip_pacts_since=None) - @patch("pact.verify_wrapper.VerifyWrapper.call_verify") + @patch("pact.verify_wrapper.VerifyWrapper.verify") @patch("pact.verify_wrapper.isfile", return_value=True) def test_broker_url_and_provider_required(self, mock_isfile, mock_wrapper): mock_wrapper.return_value = 0, None @@ -125,7 +125,7 @@ def test_broker_url_and_provider_required(self, mock_isfile, mock_wrapper): mock_wrapper.assert_called() self.assertEqual(result.exit_code, 0) - @patch("pact.verify_wrapper.VerifyWrapper.call_verify") + @patch("pact.verify_wrapper.VerifyWrapper.verify") @patch("pact.verify_wrapper.isfile", return_value=True) def test_pact_url_param_supported(self, mock_isfile, mock_wrapper): mock_wrapper.return_value = 0, None @@ -146,7 +146,7 @@ def test_pact_url_param_supported(self, mock_isfile, mock_wrapper): include_wip_pacts_since=None) self.assertEqual(result.exit_code, 0) - @patch("pact.verify_wrapper.VerifyWrapper.call_verify") + @patch("pact.verify_wrapper.VerifyWrapper.verify") @patch("pact.verify_wrapper.isfile", return_value=True) def test_pact_urls_param_supported(self, mock_isfile, mock_wrapper): mock_wrapper.return_value = 0, None @@ -169,7 +169,7 @@ def test_pact_urls_param_supported(self, mock_isfile, mock_wrapper): include_wip_pacts_since=None) self.assertEqual(result.exit_code, 0) - @patch("pact.verify_wrapper.VerifyWrapper.call_verify") + @patch("pact.verify_wrapper.VerifyWrapper.verify") @patch("pact.verify_wrapper.isfile", return_value=False) def test_local_pact_urls_must_exist(self, mock_isfile, mock_wrapper): mock_wrapper.return_value = 0, None @@ -177,9 +177,9 @@ def test_local_pact_urls_must_exist(self, mock_isfile, mock_wrapper): result = self.runner.invoke(verify.main, self.all_url_opts) self.assertEqual(result.exit_code, 1) self.assertIn('./pacts/consumer-provider.json', result.output) - mock_wrapper.call_verify.assert_not_called + mock_wrapper.verify.assert_not_called - @patch("pact.verify_wrapper.VerifyWrapper.call_verify") + @patch("pact.verify_wrapper.VerifyWrapper.verify") @patch("pact.verify_wrapper.isfile", return_value=True) def test_failed_verification(self, mock_isfile, mock_wrapper): mock_wrapper.return_value = 3, None @@ -198,7 +198,7 @@ def test_failed_verification(self, mock_isfile, mock_wrapper): @patch.dict(os.environ, {'PACT_BROKER_PASSWORD': 'pwd', 'PACT_BROKER_USERNAME': 'broker_user', 'PACT_BROKER_BASE_URL': 'http://broker/'}) - @patch("pact.verify_wrapper.VerifyWrapper.call_verify") + @patch("pact.verify_wrapper.VerifyWrapper.verify") @patch("pact.verify_wrapper.isfile", return_value=True) def test_broker_creds_from_env_var(self, mock_isfile, mock_wrapper): mock_wrapper.return_value = 0, None @@ -219,7 +219,7 @@ def test_broker_creds_from_env_var(self, mock_isfile, mock_wrapper): publish_verification_results=False, include_wip_pacts_since=None) - @patch("pact.verify_wrapper.VerifyWrapper.call_verify") + @patch("pact.verify_wrapper.VerifyWrapper.verify") @patch("pact.verify_wrapper.isfile", return_value=True) def test_all_url_options(self, mock_isfile, mock_wrapper): mock_wrapper.return_value = 0, None @@ -270,7 +270,7 @@ def test_all_url_options(self, mock_isfile, mock_wrapper): publish_verification_results=False, include_wip_pacts_since=None) - @patch("pact.verify_wrapper.VerifyWrapper.call_verify") + @patch("pact.verify_wrapper.VerifyWrapper.verify") def test_all_broker_options(self, mock_wrapper): mock_wrapper.return_value = 0, None result = self.runner.invoke(verify.main, [ diff --git a/tests/ffi/cli/test_verify.py b/tests/ffi/cli/test_verify.py new file mode 100644 index 000000000..5ef87ee61 --- /dev/null +++ b/tests/ffi/cli/test_verify.py @@ -0,0 +1,81 @@ +from pact.ffi.cli.verify import main +from pact.ffi.verifier import Verifier, VerifyStatus + + +def test_cli_args(): + """Make sure we have at least some arguments and they all have the required + long version and help.""" + args = Verifier().cli_args() + + assert len(args.options) > 0 + assert len(args.flags) > 0 + assert all([arg.help is not None for arg in args.options]) + assert all([arg.long is not None for arg in args.options]) + assert all([arg.help is not None for arg in args.flags]) + assert all([arg.long is not None for arg in args.flags]) + + +def test_cli_args_cautious(cli_options, cli_flags): + """ + If desired, we can keep track of the list of arguments supported by the FFI + CLI, and then at least be alerted if there is a change (this test will fail). + + We don't really *need* to test against this, but it might be nice to know to + avoid any surprises. + """ + args = Verifier().cli_args() + assert len(args.options) == len(cli_options) + assert all([arg.long in cli_options for arg in args.options]) + + assert len(args.flags) == len(cli_flags) + assert all([arg.long in cli_flags for arg in args.flags]) + + +def test_cli_help(runner): + """Click should return the usage information.""" + result = runner.invoke(main, ["--help"]) + assert result.exit_code == 0 + assert result.output.startswith("Usage: pact-verifier-ffi [OPTIONS]") + + +def test_cli_no_args(runner): + """If no args are provided, but Click passes the default, we still want help.""" + result = runner.invoke(main, []) + assert result.exit_code == 0 + assert result.output.startswith("Usage: pact-verifier-ffi [OPTIONS]") + + +def test_cli_verify_success(runner, httpserver, pact_consumer_one_pact_provider_one_path): + """ + Use the FFI library to verify a simple pact, using a mock httpserver. + In this case the response is as expected, so the verify succeeds. + """ + body = {"answer": 42} # 42 will be returned as an int, as expected + endpoint = "/test-provider-one" + httpserver.expect_request(endpoint).respond_with_json(body) + + args = [ + f"--port={httpserver.port}", + f"--file={pact_consumer_one_pact_provider_one_path}", + ] + result = runner.invoke(main, args) + + assert VerifyStatus(result.exit_code) == VerifyStatus.SUCCESS + + +def test_cli_verify_failure(runner, httpserver, pact_consumer_one_pact_provider_one_path): + """ + Use the FFI library to verify a simple pact, using a mock httpserver. + In this case the response is NOT as expected (str not int), so the verify fails. + """ + body = {"answer": "42"} # 42 will be returned as a str, which will fail + endpoint = "/test-provider-one" + httpserver.expect_request(endpoint).respond_with_json(body) + + args = [ + f"--port={httpserver.port}", + f"--file={pact_consumer_one_pact_provider_one_path}", + ] + result = runner.invoke(main, args) + + assert VerifyStatus(result.exit_code) == VerifyStatus.VERIFIER_FAILED diff --git a/tests/ffi/conftest.py b/tests/ffi/conftest.py new file mode 100644 index 000000000..6f95b4e84 --- /dev/null +++ b/tests/ffi/conftest.py @@ -0,0 +1,64 @@ +from pathlib import Path + +import pytest +from click.testing import CliRunner + + +@pytest.fixture +def runner(): + return CliRunner() + + +# CLI option arguments supported, correct as of Pact FFI 0.0.2. +@pytest.fixture +def cli_options(): + return [ + "loglevel", + "file", + "dir", + "url", + "broker-url", + "hostname", + "port", + "scheme", + "provider-name", + "state-change-url", + "filter-description", + "filter-state", + "filter-no-state", + "filter-consumer", + "user", + "password", + "token", + "provider-version", + "build-url", + "provider-tags", + "provider-branch", + "base-path", + "consumer-version-tags", + "consumer-version-selectors", + "include-wip-pacts-since", + "request-timeout", + "header" + ] + + +# CLI flag arguments supported, correct as of Pact FFI 0.0.2. +@pytest.fixture +def cli_flags(): + return ["state-change-as-query", "state-change-teardown", "publish", "disable-ssl-verification", "enable-pending"] + + +@pytest.fixture +def pacts_dir(): + """Find the correct pacts dir, depending on where the tests are run from""" + relative = "../examples/pacts/" if Path.cwd().name == "tests" else "examples/pacts" + return Path.cwd().joinpath(relative) + + +@pytest.fixture +def pact_consumer_one_pact_provider_one_path(pacts_dir): + """Provide the full path to a JSON pact for tests""" + pact = pacts_dir.joinpath("pact-consumer-one-pact-provider-one.json") + assert pact.is_file() + return str(pact) diff --git a/tests/ffi/test_ffi_http_consumer.py b/tests/ffi/test_ffi_http_consumer.py new file mode 100644 index 000000000..fb834154f --- /dev/null +++ b/tests/ffi/test_ffi_http_consumer.py @@ -0,0 +1,113 @@ +import json +import requests + +from pact.ffi.native_mock_server import MockServer, MockServerStatus + +m = MockServer() +PACT_FILE_DIR = './examples/pacts' + +def test_ffi_http_consumer(): + request_interaction_body = { + "isbn": { + "pact:matcher:type": "type", + "value": "0099740915" + }, + "title": { + "pact:matcher:type": "type", + "value": "The Handmaid\'s Tale" + }, + "description": { + "pact:matcher:type": "type", + "value": "Brilliantly conceived and executed, this powerful evocation of twenty-first" + " century America gives full rein to Margaret Atwood\'s devastating irony, wit and astute perception." + }, + "author": { + "pact:matcher:type": "type", + "value": "Margaret Atwood" + }, + "publicationDate": { + "pact:matcher:type": "regex", + "regex": "^\\d{4}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d([+-][0-2]\\d:[0-5]\\d|Z)$", + "value": "1985-07-31T00:00:00+00:00" + } + } + + response_interaction_body = { + "@context": "/api/contexts/Book", + "@id": { + "pact:matcher:type": "regex", + "regex": "^\\/api\\/books\\/[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$", + "value": "/api/books/0114b2a8-3347-49d8-ad99-0e792c5a30e6" + }, + "@type": "Book", + "title": { + "pact:matcher:type": "type", + "value": "Voluptas et tempora repellat corporis excepturi." + }, + "description": { + "pact:matcher:type": "type", + "value": "Quaerat odit quia nisi accusantium natus voluptatem. Explicabo corporis eligendi " + "ut ut sapiente ut qui quidem. Optio amet velit aut delectus. Sed alias asperiores " + "perspiciatis deserunt omnis. Mollitia unde id in." + }, + "author": { + "pact:matcher:type": "type", + "value": "Melisa Kassulke" + }, + "publicationDate": { + "pact:matcher:type": "regex", + "regex": "^\\d{4}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d([+-][0-2]\\d:[0-5]\\d|Z)$", + "value": "1999-02-13T00:00:00+07:00" + }, + "reviews": [ + ] + } + # Setup pact for testing + pact = m.new_pact('http-consumer-1', 'http-provider') + interaction = m.new_interaction(pact, 'A POST request to create book') + # setup interaction request + m.given(interaction, 'No book fixtures required') + m.upon_receiving(interaction, 'A POST request to create book') + m.with_request(interaction, 'POST', '/api/books') + m.with_request_header(interaction, 'Content-Type', 0, 'application/json') + m.with_request_body(interaction, 'application/json', request_interaction_body) + # setup interaction response + m.response_status(interaction, 200) + m.with_response_header(interaction, 'Content-Type', 0, 'application/ld+json; charset=utf-8') + m.with_response_body(interaction, 'application/json', response_interaction_body) + # Start mock server + mock_server_port = m.start_mock_server(pact, '0.0.0.0', 0, 'http', None) + + # Make our client call + body = { + "isbn": '0099740915', + "title": "The Handmaid's Tale", + "description": "Brilliantly conceived and executed, this powerful evocation of twenty-first " + "century America gives full rein to Margaret Atwood\'s devastating irony, wit and astute perception.", + "author": 'Margaret Atwood', + "publicationDate": '1985-07-31T00:00:00+00:00' + } + expected_response = { + "@context": "/api/contexts/Book", + "@id": "/api/books/0114b2a8-3347-49d8-ad99-0e792c5a30e6", + "@type": "Book", + "author": "Melisa Kassulke", + "description": "Quaerat odit quia nisi accusantium natus voluptatem. Explicabo corporis eligendi ut ut sapiente ut qui quidem. " + "Optio amet velit aut delectus. Sed alias asperiores perspiciatis deserunt omnis. Mollitia unde id in.", + "publicationDate": "1999-02-13T00:00:00+07:00", + "reviews": [], + "title": "Voluptas et tempora repellat corporis excepturi."} + try: + response = requests.post(f"http://127.0.0.1:{mock_server_port}/api/books", data=json.dumps(body), + headers={'Content-Type': 'application/json'}) + print(f"{json.loads(response.text)}") + print(f"{expected_response}") + print(f"Client response - matched: {json.loads(response.text) == expected_response}") + response.raise_for_status() + except requests.HTTPError as http_err: + print(f'Client request - HTTP error occurred: {http_err}') # Python 3.6 + except Exception as err: + print(f'Client request - Other error occurred: {err}') # Python 3.6 + + result = m.verify(mock_server_port, pact, PACT_FILE_DIR) + assert MockServerStatus(result.return_code) == MockServerStatus.SUCCESS diff --git a/tests/ffi/test_ffi_message_consumer.py b/tests/ffi/test_ffi_message_consumer.py new file mode 100644 index 000000000..1538b36d9 --- /dev/null +++ b/tests/ffi/test_ffi_message_consumer.py @@ -0,0 +1,56 @@ +import json +import requests + +from pact.ffi.native_mock_server import MockServer, MockServerStatus + +m = MockServer() +PACT_FILE_DIR = './examples/pacts' + +def test_ffi_message_consumer(): + # Setup http pact for testing + pact = m.new_pact('http-consumer-2', 'http-provider') + interaction = m.new_interaction(pact, 'A PUT request to generate book cover') + # setup interaction request + m.given(interaction, 'A book with id fb5a885f-f7e8-4a50-950f-c1a64a94d500 is required') + m.upon_receiving(interaction, 'A PUT request to generate book cover') + m.with_request(interaction, 'PUT', '/api/books/fb5a885f-f7e8-4a50-950f-c1a64a94d500/generate-cover') + m.with_request_header(interaction, 'Content-Type', 0, 'application/json') + m.with_request_body(interaction, 'application/json', []) + # setup interaction response + m.response_status(interaction, 204) + # Setup message pact for testing + contents = { + "uuid": { + "pact:matcher:type": 'regex', + "regex": '^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$', + "value": 'fb5a885f-f7e8-4a50-950f-c1a64a94d500' + } + } + message_pact = m.new_pact('message-consumer-2', 'message-provider') + message = m.new_message(message_pact, 'Book (id fb5a885f-f7e8-4a50-950f-c1a64a94d500) created message') + m.message_given(message, 'A book with id fb5a885f-f7e8-4a50-950f-c1a64a94d500 is required') + m.message_expects_to_receive(message, 'Book (id fb5a885f-f7e8-4a50-950f-c1a64a94d500) created message') + m.message_with_contents(message, 'application/json', contents) + + # Start mock server + mock_server_port = m.start_mock_server(pact, '0.0.0.0', 0, 'http', None) + reified = m.message_reify(message) + uuid = json.loads(reified)['contents']['uuid'] + + # Make our client call + body = [] + try: + response = requests.put(f"http://127.0.0.1:{mock_server_port}/api/books/{uuid}/generate-cover", data=json.dumps(body), + headers={'Content-Type': 'application/json'}) + print(f"Client response - matched: {response.text}") + print(f"Client response - matched: {response.status_code}") + print(f"Client response - matched: {response.status_code == '204'}") + response.raise_for_status() + except requests.HTTPError as http_err: + print(f'Client request - HTTP error occurred: {http_err}') # Python 3.6 + except Exception as err: + print(f'Client request - Other error occurred: {err}') # Python 3.6 + + # verify and write pact if success + result = m.verify(mock_server_port, pact, PACT_FILE_DIR, message_pact) + assert MockServerStatus(result.return_code) == MockServerStatus.SUCCESS diff --git a/tests/ffi/test_v3_http_consumer.py b/tests/ffi/test_v3_http_consumer.py new file mode 100644 index 000000000..f4fe26d38 --- /dev/null +++ b/tests/ffi/test_v3_http_consumer.py @@ -0,0 +1,92 @@ +import json +import requests + +from pact import PactV3 +from pact.matchers_v3 import Like, Regex +from pact.ffi.native_mock_server import MockServerStatus + +def test_ffi_http_consumer(): + request_interaction_body = { + "isbn": Like("0099740915"), + "title": Like("The Handmaid\'s Tale"), + "description": Like("Brilliantly conceived and executed, this powerful evocation of twenty-first" + " century America gives full rein to Margaret Atwood\'s devastating irony, wit and astute perception."), + "author": Like("Margaret Atwood"), + "publicationDate": + Regex("\\d{4}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d([+-][0-2]\\d:[0-5]\\d|Z)", "1985-07-31T00:00:00+00:00"), + } + + response_interaction_body = { + "@context": "/api/contexts/Book", + "@id": + Regex("^\\/api\\/books\\/[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$", "/api/books/0114b2a8-3347-49d8-ad99-0e792c5a30e6"), + "@type": "Book", + "title": Like("Voluptas et tempora repellat corporis excepturi."), + "description": Like("Quaerat odit quia nisi accusantium natus voluptatem. Explicabo corporis eligendi " + "ut ut sapiente ut qui quidem. Optio amet velit aut delectus. Sed alias asperiores " + "perspiciatis deserunt omnis. Mollitia unde id in."), + "author": + Like("Melisa Kassulke"), + "publicationDate": Regex( + "^\\d{4}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d([+-][0-2]\\d:[0-5]\\d|Z)$", + "1999-02-13T00:00:00+07:00" + ), + "reviews": [ + ] + } + + # Setup pact for testing + provider = PactV3('http-consumer-1', 'http-provider') + (provider + .new_http_interaction('A POST request to create book') + .given('No book fixtures required') + .upon_receiving('A POST request to create book') + .with_request( + 'POST', + '/api/books', + headers=[{"name": "Content-Type", "value": 'application/json'}], + body=request_interaction_body + ) + .will_respond_with( + 200, + headers=[{"name": "Content-Type", "value": 'application/ld+json; charset=utf-8'}], + body=response_interaction_body, + ) + ) + + # Start mock server (the port is also available via provider.mock_server_port) + # mock_server_port = provider.start_service() + mock_server_port = provider.start_service() + + # Make our client call + body = { + "isbn": '0099740915', + "title": "The Handmaid's Tale", + "description": "Brilliantly conceived and executed, this powerful evocation of twenty-first " + "century America gives full rein to Margaret Atwood\'s devastating irony, wit and astute perception.", + "author": 'Margaret Atwood', + "publicationDate": '1985-07-31T00:00:00+00:00' + } + expected_response = { + "@context": "/api/contexts/Book", + "@id": "/api/books/0114b2a8-3347-49d8-ad99-0e792c5a30e6", + "@type": "Book", + "author": "Melisa Kassulke", + "description": "Quaerat odit quia nisi accusantium natus voluptatem. Explicabo corporis eligendi ut ut sapiente ut qui quidem. " + "Optio amet velit aut delectus. Sed alias asperiores perspiciatis deserunt omnis. Mollitia unde id in.", + "publicationDate": "1999-02-13T00:00:00+07:00", + "reviews": [], + "title": "Voluptas et tempora repellat corporis excepturi."} + try: + response = requests.post(f"http://127.0.0.1:{mock_server_port}/api/books", data=json.dumps(body), + headers={'Content-Type': 'application/json'}) + print(f"{expected_response}") + print(f"Client response - matched: {json.loads(response.text) == expected_response}") + response.raise_for_status() + except requests.HTTPError as http_err: + print(f'Client request - HTTP error occurred: {http_err}') # Python 3.6 + except Exception as err: + print(f'Client request - Other error occurred: {err}') # Python 3.6 + + result = provider.verify() + assert MockServerStatus(result.return_code) == MockServerStatus.SUCCESS diff --git a/tests/ffi/test_v3_verifier.py b/tests/ffi/test_v3_verifier.py new file mode 100644 index 000000000..527ee13c7 --- /dev/null +++ b/tests/ffi/test_v3_verifier.py @@ -0,0 +1,251 @@ +import pytest +from pact import VerifierV3 +import platform +from pact.ffi.verifier import VerifyStatus +from pact.pact_exception import PactException + + +def test_pact_urls_or_broker_required(): + verifier = VerifierV3(provider="provider", provider_base_url="http://localhost") + with pytest.raises(PactException) as e: + verifier.verify_pacts() + + assert "Pact sources or pact_broker_url required" in str(e) + +def test_pact_file_does_not_exist(): + verifier = VerifierV3(provider="test_provider", + provider_base_url="http://localhost", + ) + result = verifier.verify_pacts( + sources=["consumer-provider.json"], + ) + assert VerifyStatus(result.return_code) == VerifyStatus.VERIFIER_FAILED + target_platform = platform.platform().lower() + + if 'windows' in target_platform: + assert ( + "Failed to load pact 'consumer-provider.json' - The system cannot find the file specified." + in "\n".join(result.logs) + ) + else: + assert ( + "Failed to load pact 'consumer-provider.json' - No such file or directory" + in "\n".join(result.logs) + ) + +def test_pact_url_does_not_exist(): + + verifier = VerifierV3(provider="test_provider", + provider_base_url="http://localhost", + ) + result = verifier.verify_pacts( + sources=["http://broker.com/pacts/consumer-provider.json"], + ) + assert VerifyStatus(result.return_code) == VerifyStatus.VERIFIER_FAILED + assert ( + "Failed to load pact 'http://broker.com/pacts/consumer-provider.json' - Request failed with status - 404 Not Found" + in "\n".join(result.logs) + ) + +def test_broker_url_does_not_exist(): + + verifier = VerifierV3(provider="Example API", + provider_base_url="http://localhost", + ) + result = verifier.verify_pacts( + broker_url="http://broker.com/", + ) + assert VerifyStatus(result.return_code) == VerifyStatus.VERIFIER_FAILED + assert ( + "Failed to load pact - \x1b[31mCould not load pacts from pact broker 'http://broker.com/'" + in "\n".join(result.logs) + ) + +def test_authed_broker_without_credentials(): + verifier = VerifierV3(provider="Example API", + provider_base_url="http://localhost", + ) + result = verifier.verify_pacts( + broker_url="https://test.pactflow.io", + + ) + assert VerifyStatus(result.return_code) == VerifyStatus.VERIFIER_FAILED + assert ( + "Failed to load pact - \x1b[31mCould not load pacts from pact broker 'https://test.pactflow.io'" + in "\n".join(result.logs) + ) + + +def test_local_http_v2_pact_with_filter_state_and_consumer_filters(httpserver): + body = {"name": "testing matchers - string"} + endpoint = "/alligators/Mary" + httpserver.expect_request(endpoint).respond_with_json( + body, content_type="application/json;charset=utf-8" + ) + + verifier = VerifierV3(provider='Example API', + provider_base_url="http://127.0.0.1:{}".format(httpserver.port), + ) + + result = verifier.verify_pacts( + sources=['./examples/pacts'], + broker_username='foo', + filter_state="there is an alligator named Mary", + no_pacts_is_error=True, + add_custom_header=[ + {'name': 'custom_header', + 'value': 'test'} + ], + consumer_filters=['Example App'] + ) + + assert VerifyStatus(result.return_code) == VerifyStatus.SUCCESS + +def test_broker_http_v2_pact_with_filter_state(httpserver): + body = {"name": "Mary"} + endpoint = "/alligators/Mary" + httpserver.expect_request(endpoint).respond_with_json( + body, content_type="application/json;charset=utf-8" + ) + + verifier = VerifierV3(provider='Example API', + provider_base_url="http://127.0.0.1:{}".format(httpserver.port), + ) + + result = verifier.verify_pacts( + # broker_url="http://0.0.0.0", + # broker_username="pactbroker", + # broker_password="pactbroker", + no_pacts_is_error=True, + broker_url="https://test.pactflow.io", + broker_username="dXfltyFMgNOFZAxr8io9wJ37iUpY42M", + broker_password="O5AIZWxelWbLvqMd8PkAVycBJh2Psyg1", + provider="Example API", + provider_base_url="http://127.0.0.1:{}".format(httpserver.port), + filter_state="there is an alligator named Mary", + ) + assert VerifyStatus(result.return_code) == VerifyStatus.SUCCESS + +def test_pact_via_url_http_v2_pact_with_filter_state(httpserver): + body = {"name": "Mary"} + endpoint = "/alligators/Mary" + httpserver.expect_request(endpoint).respond_with_json( + body, content_type="application/json;charset=utf-8" + ) + + verifier = VerifierV3(provider='Example API', + provider_base_url="http://127.0.0.1:{}".format(httpserver.port), + ) + + result = verifier.verify_pacts( + sources=[ + 'https://test.pactflow.io/pacts/provider/Example%20API/consumer/Example%20App/latest' + ], + broker_username="dXfltyFMgNOFZAxr8io9wJ37iUpY42M", + broker_password="O5AIZWxelWbLvqMd8PkAVycBJh2Psyg1", + provider_base_url="http://127.0.0.1:{}".format(httpserver.port), + filter_state="there is an alligator named Mary", + ) + assert VerifyStatus(result.return_code) == VerifyStatus.SUCCESS + +def test_local_http_v3_pact(httpserver): + body = { + "@context": "/api/contexts/Book", + "@id": "/api/books/0114b2a8-3347-49d8-ad99-0e792c5a31e6", + "@type": "Book", + "author": "testing matchers - using regex for id and pub date", + "description": "testing matchers - string", + "publicationDate": "2023-02-13T00:00:00+07:00", + "reviews": [], + "title": "testing matchers - string" + } + endpoint = "/api/books" + httpserver.expect_request(endpoint).respond_with_json( + body, content_type="application/ld+json;charset=utf-8" + ) + + verifier = VerifierV3(provider='http-provider', + provider_base_url="http://127.0.0.1:{}".format(httpserver.port), + ) + result = verifier.verify_pacts( + sources=["./examples/pacts/v3-http.json"], + ) + assert VerifyStatus(result.return_code) == VerifyStatus.SUCCESS + +def test_broker_consumer_version_selectors_http_v2_pact(httpserver): + body = {"name": "Mary"} + endpoint = "/alligators/Mary" + httpserver.expect_request(endpoint).respond_with_json( + body, content_type="application/json;charset=utf-8" + ) + + verifier = VerifierV3(provider='Example API', + provider_base_url="http://127.0.0.1:{}".format(httpserver.port), + ) + + result = verifier.verify_pacts( + # broker_url="http://0.0.0.0", + # broker_username="pactbroker", + # broker_password="pactbroker", + no_pacts_is_error=True, + broker_url="https://test.pactflow.io", + broker_username="dXfltyFMgNOFZAxr8io9wJ37iUpY42M", + broker_password="O5AIZWxelWbLvqMd8PkAVycBJh2Psyg1", + provider="Example API", + provider_base_url="http://127.0.0.1:{}".format(httpserver.port), + filter_state="there is an alligator named Mary", + provider_branch='main', + consumer_version_selectors=[ + {"mainBranch": True}, # (recommended) - Returns the pacts for consumers configured mainBranch property + {"deployedOrReleased": True}, # (recommended) - Returns the pacts for all versions of the consumer that are currently deployed or + # released and currently supported in any environment. + {"matchingBranch": True}, + {"deployed": "test"}, # Normally, this would not be needed, Any versions currently deployed to the specified environment. + {"deployed": "production"}, # Normally, this would not be needed, Any versions currently deployed to the specified environment. + # Normally, this would not be needed, Any versions currently deployed or released and supported in the specified environment. + {"environment": "test"}, + # Normally, this would not be needed, Any versions currently deployed or released and supported in the specified environment. + {"environment": "production"}, + ] + ) + assert VerifyStatus(result.return_code) == VerifyStatus.SUCCESS + +def test_broker_publish_http_v2_pact(httpserver): + body = {"name": "Mary"} + endpoint = "/alligators/Mary" + httpserver.expect_request(endpoint).respond_with_json( + body, content_type="application/json;charset=utf-8" + ) + + verifier = VerifierV3(provider='Example API', + provider_base_url="http://127.0.0.1:{}".format(httpserver.port), + ) + + result = verifier.verify_pacts( + # broker_url="http://0.0.0.0", + # broker_username="pactbroker", + # broker_password="pactbroker", + no_pacts_is_error=True, + broker_url="https://test.pactflow.io", + broker_username="dXfltyFMgNOFZAxr8io9wJ37iUpY42M", + broker_password="O5AIZWxelWbLvqMd8PkAVycBJh2Psyg1", + provider="Example API", + provider_base_url="http://127.0.0.1:{}".format(httpserver.port), + filter_state="there is an alligator named Mary", + provider_branch='main', + consumer_version_selectors=[ + {"mainBranch": True}, # (recommended) - Returns the pacts for consumers configured mainBranch property + {"deployedOrReleased": True}, # (recommended) - Returns the pacts for all versions of the consumer that are currently deployed or + # released and currently supported in any environment. + {"matchingBranch": True}, + {"deployed": "test"}, # Normally, this would not be needed, Any versions currently deployed to the specified environment. + {"deployed": "production"}, # Normally, this would not be needed, Any versions currently deployed to the specified environment. + # Normally, this would not be needed, Any versions currently deployed or released and supported in the specified environment. + {"environment": "test"}, + # Normally, this would not be needed, Any versions currently deployed or released and supported in the specified environment. + {"environment": "production"}, + ], + publish_verification_results=True, + provider_app_version='1.0.0' # TODO:- defaults to NULL, should error if not set and publish_verification_results=True + ) + assert VerifyStatus(result.return_code) == VerifyStatus.SUCCESS diff --git a/tests/ffi/test_verifier.py b/tests/ffi/test_verifier.py new file mode 100644 index 000000000..16a64fcbd --- /dev/null +++ b/tests/ffi/test_verifier.py @@ -0,0 +1,109 @@ + +from pact.ffi.verifier import Verifier, VerifyStatus + +def test_version(): + result = Verifier().version() + assert result == "0.4.7" + + +# def test_verify_no_args(): +# result = Verifier().verify(args=None) +# assert VerifyStatus(result.return_code) == VerifyStatus.NULL_POINTER + + +def test_verify_help(): + result = Verifier().verify(args="--help") + assert VerifyStatus(result.return_code) == VerifyStatus.INVALID_ARGS + # assert "kind: HelpDisplayed" in "\n".join(result.logs) + + +def test_verify_version(): + result = Verifier().verify(args="--version") + assert VerifyStatus(result.return_code) == VerifyStatus.INVALID_ARGS + # assert "kind: VersionDisplayed" in "\n".join(result.logs) + + +def test_verify_invalid_args(): + """Verify we get an expected return code and log content to invalid args. + + Example output, with TRACE (default) logs: + [TRACE][mio::poll] registering event source with poller: token=Token(0), interests=READABLE | WRITABLE + [ERROR][pact_ffi::verifier::verifier] error verifying Pact: "error: Found argument 'Your argument + is invalid' which wasn't expected, or isn't valid in this context\n\nUSAGE:\n pact_verifier_cli + [FLAGS] [OPTIONS] --broker-url ... --dir ... --file ... --provider-name + --url ...\n\nFor more information try --help" Error { message: "error: Found argument 'Your argument is + invalid' which wasn't expected, or isn't valid in this context\n\nUSAGE:\n pact_verifier_cli [FLAGS] [OPTIONS] + --broker-url ... --dir ... --file ... --provider-name --url ...\n\n + For more information try --help", kind: UnknownArgument, info: Some(["Your argument is invalid"]) } + """ + result = Verifier().verify(args="Your argument is invalid") + assert VerifyStatus(result.return_code) == VerifyStatus.INVALID_ARGS + # assert "kind: UnknownArgument" in "\n".join(result.logs) + # assert len(result.logs) == 1 # 1 for only the ERROR log, otherwise will be 2 + + +def test_verify_success(httpserver, pact_consumer_one_pact_provider_one_path): + """ + Use the FFI library to verify a simple pact, using a mock httpserver. + In this case the response is as expected, so the verify succeeds. + """ + body = {"answer": 42} # 42 will be returned as an int, as expected + endpoint = "/test-provider-one" + httpserver.expect_request(endpoint).respond_with_json(body) + + args_list = [ + f"--port={httpserver.port}", + f"--file={pact_consumer_one_pact_provider_one_path}", + ] + args = "\n".join(args_list) + result = Verifier().verify(args=args) + assert VerifyStatus(result.return_code) == VerifyStatus.SUCCESS + + +def test_verify_failure(httpserver, pact_consumer_one_pact_provider_one_path): + """ + Use the FFI library to verify a simple pact, using a mock httpserver. + In this case the response is NOT as expected (str not int), so the verify fails. + """ + body = {"answer": "42"} # 42 will be returned as a str, which will fail + endpoint = "/test-provider-one" + httpserver.expect_request(endpoint).respond_with_json(body) + + args_list = [ + f"--port={httpserver.port}", + f"--file={pact_consumer_one_pact_provider_one_path}", + ] + args = "\n".join(args_list) + result = Verifier().verify(args=args) + assert VerifyStatus(result.return_code) == VerifyStatus.VERIFIER_FAILED + + +""" +Original verifier tests. Moving as they are implemented via FFI instead. + +TODO: + def test_verifier_with_provider_and_files(self, mock_path_exists, mock_wrapper): + def test_verifier_with_provider_and_files_passes_consumer_selctors(self, mock_path_exists, mock_wrapper): + def test_validate_on_publish_results(self): + def test_publish_on_success(self, mock_path_exists, mock_wrapper): + def test_raises_error_on_missing_pact_files(self, mock_path_exists): + def test_expand_directories_called_for_pacts(self, mock_path_exists, mock_expand_dir, mock_wrapper): + def test_passes_enable_pending_flag_value(self, mock_wrapper): + def test_passes_include_wip_pacts_since_value(self, mock_path_exists, mock_wrapper): + def test_verifier_with_broker(self, mock_wrapper): + def test_verifier_and_pubish_with_broker(self, mock_wrapper): + def test_verifier_with_broker_passes_consumer_selctors(self, mock_wrapper): + def test_publish_on_success(self, mock_path_exists, mock_wrapper): + def test_passes_enable_pending_flag_value(self, mock_wrapper): + def test_passes_include_wip_pacts_since_value(self, mock_path_exists, mock_wrapper): + +Done: + def test_version(self): + +Issues: +Skipped test_verifier.py and cli/test_ffi_verifier.py, there is an issue with the loggers whereby +test_ffi_verifier.py uses pactffi_log_to_buffer and pactffi_verifier_logs with a verifier handle +the others, either write to a pactffi_log_to_file, or call pactffi_log_to_buffer but call pactffi_fetch_log_buffer +This function can take a log specifier but its not clear how to set that. +if the tests are run individually they are fine... +""" diff --git a/tests/test_http_proxy.py b/tests/test_http_proxy.py index 36bac25a8..f21c1f1a5 100644 --- a/tests/test_http_proxy.py +++ b/tests/test_http_proxy.py @@ -63,7 +63,7 @@ def test_home_should_return_expected_response(self): json=payload ) - self.assertEqual(res.json(), {'contents': message}) + self.assertEqual(res.json(), message) def test_home_raise_runtime_error_if_no_matched(self): data = { diff --git a/tests/test_message_provider.py b/tests/test_message_provider.py index 8b71c3bdd..78a1d6e97 100644 --- a/tests/test_message_provider.py +++ b/tests/test_message_provider.py @@ -48,26 +48,31 @@ def test_init(self): 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_host, '127.0.0.1') self.assertEqual(self.provider.proxy_port, '1234') - @patch('pact.Verifier.verify_pacts', return_value=(0, 'logs')) + @patch('pact.verifier_v3.VerifierV3.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) + mock_verify_pacts.assert_called_with(sources=[f'{self.provider.pact_dir}/{self.provider._pact_file()}'], + ) - @patch('pact.Verifier.verify_with_broker', return_value=(0, 'logs')) + @patch('pact.verifier_v3.VerifierV3.verify_pacts', return_value=(0, 'logs')) def test_verify_with_broker(self, mock_verify_pacts): self.provider.verify_with_broker(**self.options) assert mock_verify_pacts.call_count == 1 - mock_verify_pacts.assert_called_with(False, None, broker_username="test", - broker_password="test", - broker_url="http://localhost", - publish_version='3', - publish_verification_results=False) + mock_verify_pacts.assert_called_with( + enable_pending=False, + include_wip_pacts_since=None, + broker_username="test", + broker_password="test", + broker_url="http://localhost", + publish_version='3', + publish_verification_results=False + ) class MessageProviderContextManagerTestCase(MessageProviderTestCase): diff --git a/tests/test_pact.py b/tests/test_pact.py index 76e1e7059..8a23e92e4 100644 --- a/tests/test_pact.py +++ b/tests/test_pact.py @@ -488,11 +488,13 @@ def setUp(self): .upon_receiving('a specific request to the server') .with_request('GET', '/path') .will_respond_with(200, body='success')) + self.get_verification_call = call( 'get', 'http://localhost:1234/interactions/verification', headers={'X-Pact-Mock-Service': 'true'}, verify=False, - params=None) + params=None, + allow_redirects=True) self.post_publish_pacts_call = call( 'post', 'http://localhost:1234/pact', @@ -534,7 +536,6 @@ def test_error_writing_pacts_to_file(self): self.mock_requests.assert_has_calls([ self.get_verification_call, self.post_publish_pacts_call]) - class PactContextManagerTestCase(PactTestCase): def setUp(self): super(PactContextManagerTestCase, self).setUp() diff --git a/tests/test_verifier.py b/tests/test_verifier.py index 7b0a46f05..54d37dca2 100644 --- a/tests/test_verifier.py +++ b/tests/test_verifier.py @@ -24,9 +24,12 @@ def setUp(self): provider_base_url="http://localhost:8888") self.mock_wrapper = patch.object( - VerifyWrapper, 'call_verify').start() + VerifyWrapper, 'verify').start() - @patch("pact.verify_wrapper.VerifyWrapper.call_verify") + def test_version(self): + self.assertEqual(self.verifier.version(), "0.0.0") + + @patch("pact.verify_wrapper.VerifyWrapper.verify") @patch('pact.verifier.path_exists', return_value=True) def test_verifier_with_provider_and_files(self, mock_path_exists, mock_wrapper): mock_wrapper.return_value = (True, 'some logs') @@ -46,7 +49,7 @@ def test_verifier_with_provider_and_files(self, mock_path_exists, mock_wrapper): enable_pending=False, include_wip_pacts_since=None) - @patch("pact.verify_wrapper.VerifyWrapper.call_verify") + @patch("pact.verify_wrapper.VerifyWrapper.verify") @patch('pact.verifier.path_exists', return_value=True) def test_verifier_with_provider_and_files_passes_consumer_selctors(self, mock_path_exists, mock_wrapper): mock_wrapper.return_value = (True, 'some logs') @@ -75,10 +78,11 @@ def test_verifier_with_provider_and_files_passes_consumer_selctors(self, mock_pa consumer_selectors=['{"tag": "main", "latest": true}', '{"tag": "test", "latest": false}']) - def test_validate_on_publish_results(self): + @patch('pact.verifier.path_exists', return_value=True) + def test_validate_on_publish_results_without_version(self, mock_path_exists): self.assertRaises(Exception, self.verifier.verify_pacts, 'path/to/pact1', publish=True) - @patch("pact.verify_wrapper.VerifyWrapper.call_verify") + @patch("pact.verify_wrapper.VerifyWrapper.verify") @patch('pact.verifier.path_exists', return_value=True) def test_publish_on_success(self, mock_path_exists, mock_wrapper): mock_wrapper.return_value = (True, 'some logs') @@ -103,7 +107,7 @@ def test_raises_error_on_missing_pact_files(self, mock_path_exists): mock_path_exists.assert_called_with('path/to/pact2') - @patch("pact.verify_wrapper.VerifyWrapper.call_verify", return_value=(0, None)) + @patch("pact.verify_wrapper.VerifyWrapper.verify", return_value=(0, None)) @patch('pact.verifier.expand_directories', return_value=['./pacts/pact1', './pacts/pact2']) @patch('pact.verifier.path_exists', return_value=True) def test_expand_directories_called_for_pacts(self, mock_path_exists, mock_expand_dir, mock_wrapper): @@ -112,7 +116,7 @@ def test_expand_directories_called_for_pacts(self, mock_path_exists, mock_expand mock_expand_dir.assert_called_once() - @patch('pact.verify_wrapper.VerifyWrapper.call_verify', return_value=(0, None)) + @patch('pact.verify_wrapper.VerifyWrapper.verify', return_value=(0, None)) def test_passes_enable_pending_flag_value(self, mock_wrapper): for value in (True, False): with self.subTest(value=value): @@ -123,7 +127,7 @@ def test_passes_enable_pending_flag_value(self, mock_wrapper): mock_wrapper.call_args.kwargs, ) - @patch('pact.verify_wrapper.VerifyWrapper.call_verify', return_value=(0, None)) + @patch('pact.verify_wrapper.VerifyWrapper.verify', return_value=(0, None)) @patch('pact.verifier.path_exists', return_value=True) def test_passes_include_wip_pacts_since_value(self, mock_path_exists, mock_wrapper): self.verifier.verify_pacts('any.json', include_wip_pacts_since='2018-01-01') @@ -142,7 +146,7 @@ def setUp(self): provider_base_url="http://localhost:8888") self.mock_wrapper = patch.object( - VerifyWrapper, 'call_verify').start() + VerifyWrapper, 'verify').start() self.broker_username = 'broker_username' self.broker_password = 'broker_password' self.broker_url = 'http://broker' @@ -154,7 +158,7 @@ def setUp(self): 'broker_token': 'token' } - @patch("pact.verify_wrapper.VerifyWrapper.call_verify") + @patch("pact.verify_wrapper.VerifyWrapper.verify") def test_verifier_with_broker(self, mock_wrapper): mock_wrapper.return_value = (True, 'some value') @@ -163,6 +167,7 @@ def test_verifier_with_broker(self, mock_wrapper): self.assertTrue(output) assertVerifyCalled(mock_wrapper, + pacts=None, provider='test_provider', provider_base_url='http://localhost:8888', broker_password=self.broker_password, @@ -174,7 +179,7 @@ def test_verifier_with_broker(self, mock_wrapper): enable_pending=False, include_wip_pacts_since=None) - @patch("pact.verify_wrapper.VerifyWrapper.call_verify") + @patch("pact.verify_wrapper.VerifyWrapper.verify") def test_verifier_and_pubish_with_broker(self, mock_wrapper): mock_wrapper.return_value = (True, 'some value') @@ -184,6 +189,7 @@ def test_verifier_and_pubish_with_broker(self, mock_wrapper): self.assertTrue(output) assertVerifyCalled(mock_wrapper, + pacts=None, provider='test_provider', provider_base_url='http://localhost:8888', broker_password=self.broker_password, @@ -197,7 +203,7 @@ def test_verifier_and_pubish_with_broker(self, mock_wrapper): provider_app_version='1.0.0', ) - @patch("pact.verify_wrapper.VerifyWrapper.call_verify") + @patch("pact.verify_wrapper.VerifyWrapper.verify") def test_verifier_with_broker_passes_consumer_selctors(self, mock_wrapper): mock_wrapper.return_value = (True, 'some value') @@ -213,6 +219,7 @@ def test_verifier_with_broker_passes_consumer_selctors(self, mock_wrapper): self.assertTrue(output) assertVerifyCalled(mock_wrapper, + pacts=None, provider='test_provider', provider_base_url='http://localhost:8888', broker_password=self.broker_password, @@ -226,7 +233,7 @@ def test_verifier_with_broker_passes_consumer_selctors(self, mock_wrapper): consumer_selectors=['{"tag": "main", "latest": true}', '{"tag": "test", "latest": false}']) - @patch("pact.verify_wrapper.VerifyWrapper.call_verify") + @patch("pact.verify_wrapper.VerifyWrapper.verify") @patch('pact.verifier.path_exists', return_value=True) def test_publish_on_success(self, mock_path_exists, mock_wrapper): mock_wrapper.return_value = (True, 'some logs') @@ -234,6 +241,7 @@ def test_publish_on_success(self, mock_path_exists, mock_wrapper): self.verifier.verify_with_broker(publish_version='1.0.0', **self.default_opts) assertVerifyCalled(mock_wrapper, + pacts=None, provider='test_provider', provider_base_url='http://localhost:8888', broker_password=self.broker_password, @@ -246,7 +254,7 @@ def test_publish_on_success(self, mock_path_exists, mock_wrapper): enable_pending=False, include_wip_pacts_since=None) - @patch('pact.verify_wrapper.VerifyWrapper.call_verify', return_value=(0, None)) + @patch('pact.verify_wrapper.VerifyWrapper.verify', return_value=(0, None)) def test_passes_enable_pending_flag_value(self, mock_wrapper): for value in (True, False): with self.subTest(value=value): @@ -257,7 +265,7 @@ def test_passes_enable_pending_flag_value(self, mock_wrapper): mock_wrapper.call_args.kwargs, ) - @patch('pact.verify_wrapper.VerifyWrapper.call_verify', return_value=(0, None)) + @patch('pact.verify_wrapper.VerifyWrapper.verify', return_value=(0, None)) @patch('pact.verifier.path_exists', return_value=True) def test_passes_include_wip_pacts_since_value(self, mock_path_exists, mock_wrapper): self.verifier.verify_with_broker(include_wip_pacts_since='2018-01-01') diff --git a/tests/test_verify_wrapper.py b/tests/test_verify_wrapper.py index 8ec63eaf7..ed5ead8a2 100644 --- a/tests/test_verify_wrapper.py +++ b/tests/test_verify_wrapper.py @@ -9,7 +9,6 @@ from subprocess import PIPE, Popen - class VerifyWrapperTestCase(TestCase): """ use traceback.print_exception(*result.exc_info) to debug """ @@ -38,9 +37,9 @@ def setUp(self): verify_wrapper, 'rerun_command', autospec=True).start() self.default_call = [ - './pacts/consumer-provider.json', - './pacts/consumer-provider2.json', - '--no-enable-pending', + # './pacts/consumer-provider.json', + # './pacts/consumer-provider2.json', + # '--no-enable-pending', '--provider=test_provider', '--provider-base-url=http://localhost'] @@ -63,6 +62,7 @@ def assertProcess(self, *expected): process_call = self.mock_Popen.mock_calls[0] actual = process_call[1][0] + print(actual) self.assertEqual(actual[0], VERIFIER_PATH) self.assertEqual(len(actual), len(expected) + 1) self.assertEqual(set(actual[1:]), set(expected)) @@ -72,19 +72,23 @@ def assertProcess(self, *expected): self.mock_rerun_command.return_value) self.assertTrue(self.mock_Popen.called) + def test_version(self): + wrapper = VerifyWrapper() + self.assertEqual(wrapper.version(), "0.0.0") + def test_pact_urls_or_broker_required(self): self.mock_Popen.return_value.returncode = 2 wrapper = VerifyWrapper() with self.assertRaises(PactException) as context: - wrapper.call_verify(provider='provider', provider_base_url='http://localhost') + wrapper.verify(provider='provider', provider_base_url='http://localhost') - self.assertTrue('Pact urls or Pact broker required' in context.exception.message) + self.assertTrue('Pact sources or pact_broker_url required' in context.exception.message) def test_broker_without_authentication_can_be_used(self): self.mock_Popen.return_value.returncode = 0 wrapper = VerifyWrapper() - wrapper.call_verify( + wrapper.verify( provider='provider', provider_base_url='http://localhost', broker_url='http://broker.example.com' ) self.assertProcess(*[ @@ -94,14 +98,29 @@ def test_broker_without_authentication_can_be_used(self): '--provider=provider', ]) + def test_pact_files_provided(self): + self.mock_Popen.return_value.returncode = 0 + wrapper = VerifyWrapper() + + result, output = wrapper.verify('./pacts/consumer-provider.json', + './pacts/consumer-provider2.json', + provider='test_provider', + provider_base_url='http://localhost') + self.default_call.insert(0, './pacts/consumer-provider.json') + self.default_call.insert(1, './pacts/consumer-provider2.json') + self.assertProcess(*self.default_call) + self.assertEqual(result, 0) + def test_pact_urls_provided(self): self.mock_Popen.return_value.returncode = 0 wrapper = VerifyWrapper() - result, output = wrapper.call_verify('./pacts/consumer-provider.json', - './pacts/consumer-provider2.json', - provider='test_provider', - provider_base_url='http://localhost') + result, output = wrapper.verify('http://broker.com/pacts/consumer-provider.json', + 'http://broker.com/pacts/consumer-provider2.json', + provider='test_provider', + provider_base_url='http://localhost') + self.default_call.insert(0, 'http://broker.com/pacts/consumer-provider.json') + self.default_call.insert(1, 'http://broker.com/pacts/consumer-provider2.json') self.assertProcess(*self.default_call) self.assertEqual(result, 0) @@ -111,19 +130,19 @@ def test_all_url_options(self, mock_isfile): self.mock_Popen.return_value.returncode = 0 wrapper = VerifyWrapper() - result, output = wrapper.call_verify('./pacts/consumer-provider5.json', - './pacts/consumer-provider3.json', - provider_base_url='http://localhost', - provider_states_setup_url='http://localhost/provider-states/set', - provider='provider', - provider_app_version='1.2.3', - custom_provider_headers=['Authorization: Basic cGFj', 'CustomHeader: somevalue'], - log_dir='tmp/logs/pact.test.log', - log_level='INFO', - timeout=60, - verbose=True, - enable_pending=True, - include_wip_pacts_since='2018-01-01') + result, output = wrapper.verify('./pacts/consumer-provider5.json', + './pacts/consumer-provider3.json', + provider_base_url='http://localhost', + provider_states_setup_url='http://localhost/provider-states/set', + provider='provider', + provider_app_version='1.2.3', + custom_provider_headers=['Authorization: Basic cGFj', 'CustomHeader: somevalue'], + log_dir='tmp/logs/pact.test.log', + log_level='INFO', + timeout=60, + verbose=True, + enable_pending=True, + include_wip_pacts_since='2018-01-01') self.assertEqual(result, 0) self.mock_Popen.return_value.wait.assert_called_once_with() @@ -140,23 +159,23 @@ def test_all_url_options(self, mock_isfile): '--log-dir=tmp/logs/pact.test.log', '--log-level=INFO', '--verbose', - '--enable-pending', - '--include-wip-pacts-since=2018-01-01', + # '--enable-pending', + # '--include-wip-pacts-since=2018-01-01', ) def test_uses_broker_if_no_pacts_and_provider_required(self): self.mock_Popen.return_value.returncode = 0 wrapper = VerifyWrapper() - result, output = wrapper.call_verify(provider='test_provider', - provider_base_url='http://localhost', - broker_username='username', - broker_password='pwd', - broker_token='token', - broker_url='http://broker', - consumer_tags=['prod', 'dev'], - provider_tags=['dev', 'qa'], - provider_version_branch='provider-branch') + result, output = wrapper.verify(provider='test_provider', + provider_base_url='http://localhost', + broker_username='username', + broker_password='pwd', + broker_token='token', + broker_url='http://broker', + consumer_tags=['prod', 'dev'], + provider_tags=['dev', 'qa'], + provider_version_branch='provider-branch') self.assertProcess(*self.broker_call) self.assertEqual(result, 0) @@ -169,10 +188,10 @@ def test_rerun_command_called(self, mock_rerun_cmd, mock_expand_dirs, mock_sanit self.mock_Popen.return_value.returncode = 0 wrapper = VerifyWrapper() - result, output = wrapper.call_verify('path/to/pact1', - 'path/to/pact2', - provider_base_url='http://localhost', - provider='provider') + result, output = wrapper.verify('path/to/pact1', + 'path/to/pact2', + provider_base_url='http://localhost', + provider='provider') mock_rerun_cmd.assert_called_once() @@ -184,28 +203,44 @@ def test_sanitize_called(self, mock_rerun_cmd, mock_expand_dirs, mock_sanitize_l self.mock_Popen.return_value.returncode = 0 wrapper = VerifyWrapper() - result, output = wrapper.call_verify('path/to/pact1', - 'path/to/pact2', - provider_base_url='http://localhost', - provider='provider') + result, output = wrapper.verify('path/to/pact1', + 'path/to/pact2', + provider_base_url='http://localhost', + provider='provider') mock_sanitize_logs.assert_called_with(self.mock_Popen.return_value, False) @patch('pact.verify_wrapper.path_exists', return_value=True) @patch('pact.verify_wrapper.sanitize_logs') - def test_publishing_with_version(self, mock_sanitize_logs, mock_path_exists): + def test_publishing_with_local_file(self, mock_sanitize_logs, mock_path_exists): self.mock_Popen.return_value.returncode = 0 wrapper = VerifyWrapper() - result, output = wrapper.call_verify('./pacts/consumer-provider.json', - './pacts/consumer-provider2.json', - provider='test_provider', - provider_base_url='http://localhost', - provider_app_version='1.2.3', - publish_verification_results=True) + with self.assertRaises(PactException) as context: + wrapper.verify('./pacts/consumer-provider.json', + './pacts/consumer-provider2.json', + provider='test_provider', + provider_base_url='http://localhost', + provider_app_version='1.2.3', + publish_verification_results=True) - self.default_call.extend(['--provider-app-version', '1.2.3', '--publish-verification-results']) + self.assertTrue('Cannot publish verification results for local files' in context.exception.message) + @patch('pact.verify_wrapper.sanitize_logs') + def test_publishing_with_version(self, mock_sanitize_logs): + self.mock_Popen.return_value.returncode = 0 + wrapper = VerifyWrapper() + + result, output = wrapper.verify('http://broker.com/pacts/consumer-provider.json', + 'http://broker.com/pacts/consumer-provider2.json', + provider='test_provider', + provider_base_url='http://localhost', + provider_app_version='1.2.3', + publish_verification_results=True) + + self.default_call.insert(0, 'http://broker.com/pacts/consumer-provider.json') + self.default_call.insert(1, 'http://broker.com/pacts/consumer-provider2.json') + self.default_call.extend(['--provider-app-version', '1.2.3', '--publish-verification-results']) self.assertProcess(*self.default_call) self.assertEqual(result, 0) @@ -217,10 +252,10 @@ def test_expand_dirs_called(self, mock_rerun_cmd, mock_expand_dirs, mock_sanitiz self.mock_Popen.return_value.returncode = 0 wrapper = VerifyWrapper() - result, output = wrapper.call_verify('path/to/pact1', - 'path/to/pact2', - provider_base_url='http://localhost', - provider='provider') + result, output = wrapper.verify('path/to/pact1', + 'path/to/pact2', + provider_base_url='http://localhost', + provider='provider') mock_expand_dirs.assert_called_with(['path/to/pact1', 'path/to/pact2']) diff --git a/tox.ini b/tox.ini index 2cfa5fdeb..c3638b31e 100644 --- a/tox.ini +++ b/tox.ini @@ -1,8 +1,13 @@ [tox] envlist=py{36,37,38,39,310,311}-{test,install} + [testenv] +setenv = + PACT_DO_NOT_TRACK = true +; passenv = * +usedevelop=True deps= test: -rrequirements_dev.txt commands= - test: pytest --cov pact tests - install: python -c "import pact" + test: pytest --cov pact tests -rx + install: python -c "import pact" \ No newline at end of file