From 7043dc6ec892992043f628c6c4fc80374ab2962f Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Thu, 14 Sep 2023 17:00:14 +1000 Subject: [PATCH 1/9] feat(example): simplify docker-compose For the purposes of showcasing an example, the previous `docker-compose` was rather excessive running the Pact Broker behind a Nginx proxy with a PostgreSQL backend. This simplifies the containers to just use the core Pact broker image and uses `sqlite` for the database. This commit also drops SSL/TLS from the example as it does not meaningfully contribute to the example. Signed-off-by: JP-Ellis --- examples/broker/docker-compose.yml | 61 ------------------------ examples/broker/ssl/nginx-selfsigned.crt | 21 -------- examples/broker/ssl/nginx-selfsigned.key | 27 ----------- examples/broker/ssl/nginx.conf | 23 --------- examples/docker-compose.yml | 42 ++++++++++++++++ 5 files changed, 42 insertions(+), 132 deletions(-) delete mode 100644 examples/broker/docker-compose.yml delete mode 100644 examples/broker/ssl/nginx-selfsigned.crt delete mode 100644 examples/broker/ssl/nginx-selfsigned.key delete mode 100644 examples/broker/ssl/nginx.conf create mode 100644 examples/docker-compose.yml diff --git a/examples/broker/docker-compose.yml b/examples/broker/docker-compose.yml deleted file mode 100644 index 11585e298..000000000 --- a/examples/broker/docker-compose.yml +++ /dev/null @@ -1,61 +0,0 @@ -version: '3.9' - -services: - # A PostgreSQL database for the Broker to store Pacts and verification results - postgres: - image: postgres - healthcheck: - test: psql postgres --command "select 1" -U postgres - ports: - - "5432:5432" - environment: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: password - POSTGRES_DB: postgres - - # The Pact Broker - broker_app: - # Alternatively the DiUS Pact Broker can be used: - # image: dius/pact-broker - # - # As well as changing the image, the destination port will need to be changed - # from 9292 below, and in the nginx.conf proxy_pass section - image: pactfoundation/pact-broker:latest-multi - ports: - - "80:9292" - depends_on: - - postgres - links: - - postgres - environment: - PACT_BROKER_DATABASE_USERNAME: postgres - PACT_BROKER_DATABASE_PASSWORD: password - PACT_BROKER_DATABASE_HOST: postgres - PACT_BROKER_DATABASE_NAME: postgres - PACT_BROKER_BASIC_AUTH_USERNAME: pactbroker - PACT_BROKER_BASIC_AUTH_PASSWORD: pactbroker - PACT_BROKER_DATABASE_CONNECT_MAX_RETRIES: "5" - # The Pact Broker provides a healthcheck endpoint which we will use to wait - # for it to become available before starting up - healthcheck: - test: [ "CMD", "wget", "-q", "--tries=1", "--spider", "http://pactbroker:pactbroker@localhost:9292/diagnostic/status/heartbeat" ] - interval: 1s - timeout: 2s - retries: 5 - - # An NGINX reverse proxy in front of the Broker on port 8443, to be able to - # terminate with SSL - nginx: - image: nginx:alpine - links: - - broker_app:broker - volumes: - - ./ssl/nginx.conf:/etc/nginx/conf.d/default.conf:ro - - ./ssl:/etc/nginx/ssl - ports: - - "8443:443" - restart: always - depends_on: - broker_app: - condition: service_healthy - \ No newline at end of file diff --git a/examples/broker/ssl/nginx-selfsigned.crt b/examples/broker/ssl/nginx-selfsigned.crt deleted file mode 100644 index 144287f9c..000000000 --- a/examples/broker/ssl/nginx-selfsigned.crt +++ /dev/null @@ -1,21 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIDiDCCAnACCQCWW6LywpPSwjANBgkqhkiG9w0BAQsFADCBhTELMAkGA1UEBhMC -QVUxEzARBgNVBAgMClNvbWUgU3RhdGUxDzANBgNVBAcMBlN5ZG5leTENMAsGA1UE -CgwEUGFjdDEPMA0GA1UECwwGUHl0aG9uMRIwEAYDVQQDDAlsb2NhbGhvc3QxHDAa -BgkqhkiG9w0BCQEWDXNvbWVAbWFpbC5jb20wHhcNMjAwNjEwMTUzOTM2WhcNMjMw -MzMxMTUzOTM2WjCBhTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUgU3RhdGUx -DzANBgNVBAcMBlN5ZG5leTENMAsGA1UECgwEUGFjdDEPMA0GA1UECwwGUHl0aG9u -MRIwEAYDVQQDDAlsb2NhbGhvc3QxHDAaBgkqhkiG9w0BCQEWDXNvbWVAbWFpbC5j -b20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDm/BMUkuVaYwLjnoq/ -u4fFKoBGSPl3CxvSUWhzlsaM5i+UlS7ZLwXxAxw+Vba9cztSyYHNs2BCxCHUWBFe -B818cXzQXbV0gunMz9oDxr8aQmwpRkIdxxBvmaqLbk6sjj5cTqRK39/BNtZEkZmA -QAOggnfB7Bx/OQmh4aidT6DytjA8ur3FofAVUVXHfQohm/kJOhqcdXL5pBQqD2bh -Ua6KPbZTsfOmFLggZmhqPZSjS+leqFagpissW/aHSyk/3c+vhXOhEbCUeCXaz7up -/DNF/0OHF4+r2UaeonxMxC/X6NEhNYHyNPypbdC3/59Zoa2Spu2BLy8ZoChe1dRk -hZqtAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAHpq3JmhAm5t/orY4ONFxPq1iF89 -3nKsKckfcOpDF/zjS2+6I30LVByuU88BKdTt7tsRojoWXEI01YGqYWTEwerfESr9 -M16xek5h5e7XJqp9jzyX6kswel/rWB8rF93biW0v00/KKRwwIr5IDvKb4XvugzW4 -FEG+1nhXCyjrkmKV/bbCfdkBHgavaj5TPv1LoXOX7VDRjwqoM7RP/z6JJsZkxDx3 -TkXtC8Lw4LF+tpWY8nQu3/HCqwxL7Vgy4M/IvoXRePdSI6goH8ri0zFuK9pvAREK -IjY271t+lapu8sDqUEf9tW/98YhxpBInQYBL2bEEtMYTRXRm06fSn7o3IlM= ------END CERTIFICATE----- diff --git a/examples/broker/ssl/nginx-selfsigned.key b/examples/broker/ssl/nginx-selfsigned.key deleted file mode 100644 index e8a6b5423..000000000 --- a/examples/broker/ssl/nginx-selfsigned.key +++ /dev/null @@ -1,27 +0,0 @@ ------BEGIN RSA PRIVATE KEY----- -MIIEpQIBAAKCAQEA5vwTFJLlWmMC456Kv7uHxSqARkj5dwsb0lFoc5bGjOYvlJUu -2S8F8QMcPlW2vXM7UsmBzbNgQsQh1FgRXgfNfHF80F21dILpzM/aA8a/GkJsKUZC -HccQb5mqi25OrI4+XE6kSt/fwTbWRJGZgEADoIJ3wewcfzkJoeGonU+g8rYwPLq9 -xaHwFVFVx30KIZv5CToanHVy+aQUKg9m4VGuij22U7HzphS4IGZoaj2Uo0vpXqhW -oKYrLFv2h0spP93Pr4VzoRGwlHgl2s+7qfwzRf9DhxePq9lGnqJ8TMQv1+jRITWB -8jT8qW3Qt/+fWaGtkqbtgS8vGaAoXtXUZIWarQIDAQABAoIBAQC3r5woz0yO3ZAN -nSWvpZ0pwUuzGRMxhOcCEPUkfrG0mNUbrqtL0WZDLHsIYzdoXzu88TxFbbFORxSz -/bkJ8uCJZuKf/PVxCy6MTnqMaD/OzSWgiRvI/GXoqeYC7ZypApE67NsgI/qXd1lb -vAG7CK0ZtscvsulSjvRHBOIG/6z5dUAKnLJjr7uKydMHSIKNafKAEA6HGDCvIu4d -J9EQzLfmpjLTkeB1DNZrv1mtNjf/kG/M/UX5a1RtOJTGvHQn/oZSUKng3DVUNBtq -dEO6Pi5n88xWuxH6YAWqqDjCfqyey1Jc1rQxfnx6vRPL7+IaXRugAKFMFm8Xbp9/ -/9eEDCyNAoGBAPZEjYH9u2856KYUTyky8gD1TOE9gf4x4zFjK6SzBT8v1y1RdSwQ -tf7ozj94OV/b9bAE3k/z2a09xYty5VBXs6MCluQTS67KgRaO9sSFtRmnupyBNk2z -r3QEYuVDmJ6Dk/3ovItXqFaW8IbOZMf6Acu5aEDx4UKmb2tzGGJ7DxF/AoGBAPAc -57p1yRWIG+hJMdkudXhBz+L3t2NbESWom33hi1mDMIKp3dwJmhA4kq+Uyqfl32uF -Iy3z+3xr2V1BdGg1RnicfcyjHaQ4/89YB+nkOHB8muV2R57tYahOgWn6rXXxTOBs -X2Vjd7ByAEFimrVfDH33inrYuIiI/cku4Xyj71HTAoGBAJeyrsBuPfFL6KW1SPYF -7dDtSchNjS+6J0sa3Z18sTS1EYVW8iiMuq8lVTb/pcgIxJUCyrbRbTssG+3EfsE4 -5Oz7AVvJDwvCrjXpJtTz0BTXnzoc1giTMPb0ZL75HqA2SQlVPh9PheCg5dUEekw9 -ErIdqbynwqy9vVCg+1pel2+dAoGAR1C+fsIHFG8VottCg/fpies6HHZosIjWwfGf -JTc9FTwCx3w+WeE8Mf8rihzOSCndPukPNtHVavH5YFpVgbH5GU+ZiZMU9ba8O9Aw -oYZYQQixVN/Zi9mDfOK8S0baCELAC5QEjW+KmAx0CPeJbb8qTaudJLmDrYHKpttW -u5dROGMCgYEAlgTZNiEeBAPQZD30CSvFUlZVCOOyu5crP9hCPA9um5FsvD9minSz -yJqeMj7zapZsatAzYwHrGG6nHnTKWEBNaimR7kjTpKdKzXQaA9XeVLmeFAZ3Exad -JDKTPI+asF+097sHUcVuloMOZXbD1uAZnvLWIwfsaHxs41AkF+0lmM4= ------END RSA PRIVATE KEY----- diff --git a/examples/broker/ssl/nginx.conf b/examples/broker/ssl/nginx.conf deleted file mode 100644 index e3c6bb36e..000000000 --- a/examples/broker/ssl/nginx.conf +++ /dev/null @@ -1,23 +0,0 @@ -server { - listen 443 ssl default_server; - server_name localhost; - ssl_certificate /etc/nginx/ssl/nginx-selfsigned.crt; - ssl_certificate_key /etc/nginx/ssl/nginx-selfsigned.key; - ssl_protocols TLSv1 TLSv1.1 TLSv1.2; - ssl_prefer_server_ciphers on; - ssl_ecdh_curve secp384r1; - ssl_session_cache shared:SSL:10m; - ssl_stapling on; - ssl_stapling_verify on; - - location / { - # To use with the Dius Pact Broker: - # proxy_pass http://broker:80; - proxy_pass http://broker:9292; - proxy_set_header Host $host; - proxy_set_header X-Forwarded-Scheme "https"; - proxy_set_header X-Forwarded-Port "443"; - proxy_set_header X-Forwarded-Ssl "on"; - proxy_set_header X-Real-IP $remote_addr; - } -} diff --git a/examples/docker-compose.yml b/examples/docker-compose.yml new file mode 100644 index 000000000..c845b1ee1 --- /dev/null +++ b/examples/docker-compose.yml @@ -0,0 +1,42 @@ +version: "3.9" + +services: + postgres: + image: postgres + ports: + - "5432:5432" + healthcheck: + test: psql postgres -U postgres --command 'SELECT 1' + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + + broker: + image: pactfoundation/pact-broker:latest-multi + depends_on: + - postgres + ports: + - "9292:9292" + environment: + # Basic auth credentials for the Broker + PACT_BROKER_ALLOW_PUBLIC_READ: "true" + PACT_BROKER_BASIC_AUTH_USERNAME: pactbroker + PACT_BROKER_BASIC_AUTH_PASSWORD: pactbroker + # Database + PACT_BROKER_DATABASE_URL: "postgres://postgres:postgres@postgres/postgres" + # PACT_BROKER_DATABASE_URL: sqlite:////tmp/pact_broker.sqlite # Pending pact-foundation/pact-broker-docker#148 + + healthcheck: + test: + [ + "CMD", + "curl", + "--silent", + "--show-error", + "--fail", + "http://pactbroker:pactbroker@localhost:9292/diagnostic/status/heartbeat", + ] + interval: 1s + timeout: 2s + retries: 5 From 3a33d6320e88a58bf3d8c04b001c20721bbfec7a Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Fri, 15 Sep 2023 16:30:20 +1000 Subject: [PATCH 2/9] chore(example): migrate consumer example Migrate the consumer example to the new example structure. This should help reduce the redundancy between the examples and make it easier for users to test them out and see how they work. Ultimately, the idea will be that the consumer tests run first, and then the provider tests run against the generated pact file. Signed-off-by: JP-Ellis --- .cirrus.yml | 2 - .github/workflows/test.yml | 5 +- examples/.ruff.toml | 11 ++ examples/common/sharedfixtures.py | 94 ------------- examples/conftest.py | 68 +++++++++ examples/consumer/conftest.py | 8 -- examples/consumer/requirements.txt | 6 - examples/consumer/run_pytest.sh | 4 - examples/consumer/src/consumer.py | 40 ------ .../tests/consumer/test_user_consumer.py | 129 ----------------- examples/pacts/.gitignore | 2 + .../pacts/userserviceclient-userservice.json | 65 --------- examples/src/__init__.py | 12 ++ examples/src/consumer.py | 103 ++++++++++++++ examples/tests/test_00_consumer.py | 130 ++++++++++++++++++ pyproject.toml | 3 +- 16 files changed, 330 insertions(+), 352 deletions(-) create mode 100644 examples/.ruff.toml delete mode 100644 examples/common/sharedfixtures.py create mode 100644 examples/conftest.py delete mode 100644 examples/consumer/conftest.py delete mode 100644 examples/consumer/requirements.txt delete mode 100755 examples/consumer/run_pytest.sh delete mode 100644 examples/consumer/src/consumer.py delete mode 100644 examples/consumer/tests/consumer/test_user_consumer.py create mode 100644 examples/pacts/.gitignore delete mode 100644 examples/pacts/userserviceclient-userservice.json create mode 100644 examples/src/__init__.py create mode 100644 examples/src/consumer.py create mode 100644 examples/tests/test_00_consumer.py diff --git a/.cirrus.yml b/.cirrus.yml index b8c4d9331..9c08833dc 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -5,8 +5,6 @@ TEST_TEMPLATE: &TEST_TEMPLATE - python --version # TODO: Fix lints before enabling - echo hatch run lint - # TODO: Implement the examples to work in hatch - - echo hatch run example - hatch run test linux_arm64_task: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6808db77a..ea1ae8d5c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -52,10 +52,9 @@ jobs: if: matrix.python-version == env.STABLE_PYTHON_VERSION && runner.os == 'Linux' run: echo hatch run lint - - # TODO: Implement the examples to work in hatch - name: Examples + - name: Examples if: matrix.python-version == env.STABLE_PYTHON_VERSION && runner.os == 'Linux' - run: echo hatch run example + run: hatch run example --color=yes --capture=no - name: Run tests and track code coverage run: hatch run test diff --git a/examples/.ruff.toml b/examples/.ruff.toml new file mode 100644 index 000000000..03292c3c2 --- /dev/null +++ b/examples/.ruff.toml @@ -0,0 +1,11 @@ +extend = "../pyproject.toml" + +ignore = [ + "S101", # Forbid assert statements + "D103", # Require docstring in public function +] + +[per-file-ignores] +"tests/**.py" = [ + "INP001", # Forbid implicit namespaces +] diff --git a/examples/common/sharedfixtures.py b/examples/common/sharedfixtures.py deleted file mode 100644 index 4cdece923..000000000 --- a/examples/common/sharedfixtures.py +++ /dev/null @@ -1,94 +0,0 @@ -import platform -import pathlib - -import docker -import pytest -from testcontainers.compose import DockerCompose - - -# This fixture is to simulate a managed Pact Broker or PactFlow account. -# For almost all purposes outside this example, you will want to use a real -# broker. See https://github.com/pact-foundation/pact_broker for further details. -@pytest.fixture(scope="session", autouse=True) -def broker(request): - version = request.config.getoption("--publish-pact") - publish = True if version else False - - # If the results are not going to be published to the broker, there is - # nothing further to do anyway - if not publish: - yield - return - - run_broker = request.config.getoption("--run-broker") - - if run_broker: - # Start up the broker using docker-compose - print("Starting broker") - with DockerCompose("../broker", compose_file_name=["docker-compose.yml"], pull=True) as compose: - stdout, stderr = compose.get_logs() - if stderr: - print("Errors\\n:{}".format(stderr)) - print("{}".format(stdout)) - print("Started broker") - - yield - print("Stopping broker") - print("Broker stopped") - else: - # Assuming there is a broker available already, docker-compose has been - # used manually as the --run-broker option has not been provided - yield - return - - -@pytest.fixture(scope="session", autouse=True) -def publish_existing_pact(broker): - """Publish the contents of the pacts folder to the Pact Broker. - - In normal usage, a Consumer would publish Pacts to the Pact Broker after - running tests - this fixture would NOT be needed. - . - Because the broker is being used standalone here, it will not contain the - required Pacts, so we must first spin up the pact-cli and publish them. - - In the Pact Broker logs, this corresponds to the following entry: - PactBroker::Pacts::Service -- Creating new pact publication with params \ - {:consumer_name=>"UserServiceClient", :provider_name=>"UserService", \ - :revision_number=>nil, :consumer_version_number=>"1", :pact_version_sha=>nil, \ - :consumer_name_in_pact=>"UserServiceClient", :provider_name_in_pact=>"UserService"} - """ - 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", - } - - target_platform = platform.platform().lower() - - if 'macos' in target_platform or 'windows' in target_platform: - 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", - ) - print("Finished publishing") - - -def pytest_addoption(parser): - parser.addoption( - "--publish-pact", type=str, action="store", help="Upload generated pact file to pact broker with version" - ) - - parser.addoption("--run-broker", type=bool, action="store", help="Whether to run broker in this test or not.") - parser.addoption("--provider-url", type=str, action="store", help="The url to our provider.") diff --git a/examples/conftest.py b/examples/conftest.py new file mode 100644 index 000000000..3c0a5994a --- /dev/null +++ b/examples/conftest.py @@ -0,0 +1,68 @@ +""" +Shared PyTest configuration. + +In order to run the examples, we need to run the Pact broker. In order to avoid +having to run the Pact broker manually, or repeating the same code in each +example, we define a PyTest fixture to run the Pact broker. + +We also define a `pact_dir` fixture to define the directory where the generated +Pact files will be stored. You are encouraged to have a look at these files +after the examples have been run. +""" +from __future__ import annotations + +from pathlib import Path +from typing import Any, Generator + +import pytest +from testcontainers.compose import DockerCompose +from yarl import URL + +EXAMPLE_DIR = Path(__file__).parent.resolve() + + +def pytest_addoption(parser: pytest.Parser) -> None: + """Define additional command lines to customise the examples.""" + parser.addoption( + "--broker-url", + help=( + "The URL of the broker to use. If this option has been given, the container" + " will _not_ be started." + ), + type=str, + ) + + +@pytest.fixture(scope="session") +def broker(request: pytest.FixtureRequest) -> Generator[URL, Any, None]: + """ + Fixture to run the Pact broker. + + This inspects whether the `--broker-url` option has been given. If it has, + it is assumed that the broker is already running and simply returns the + given URL. + + Otherwise, the Pact broker is started in a container. The URL of the + containerised broker is then returned. + """ + broker_url: str | None = request.config.getoption("--broker-url") + + # If we have been given a broker URL, there's nothing more to do here and we + # can return early. + if broker_url: + yield URL(broker_url) + return + + with DockerCompose( + EXAMPLE_DIR, + compose_file_name=["docker-compose.yml"], + pull=True, + ) as _: + yield URL("http://pactbroker:pactbroker@localhost:9292") + return + + +@pytest.fixture(scope="session") +def pact_dir() -> Path: + """Fixture for the Pact directory.""" + return EXAMPLE_DIR / "pacts" diff --git a/examples/consumer/conftest.py b/examples/consumer/conftest.py deleted file mode 100644 index 90ac63b9a..000000000 --- a/examples/consumer/conftest.py +++ /dev/null @@ -1,8 +0,0 @@ -import sys - -# Load in the fixtures from common/sharedfixtures.py -sys.path.append("../common") - -pytest_plugins = [ - "sharedfixtures", -] diff --git a/examples/consumer/requirements.txt b/examples/consumer/requirements.txt deleted file mode 100644 index c93ec0f76..000000000 --- a/examples/consumer/requirements.txt +++ /dev/null @@ -1,6 +0,0 @@ -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' diff --git a/examples/consumer/run_pytest.sh b/examples/consumer/run_pytest.sh deleted file mode 100755 index f997e1583..000000000 --- a/examples/consumer/run_pytest.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/bash -set -o pipefail - -pytest tests --run-broker True --publish-pact 1 diff --git a/examples/consumer/src/consumer.py b/examples/consumer/src/consumer.py deleted file mode 100644 index 6d6ed3268..000000000 --- a/examples/consumer/src/consumer.py +++ /dev/null @@ -1,40 +0,0 @@ -from typing import Optional - -import requests -from datetime import datetime - - -class User(object): - """Define the basic User data we expect to receive from the User Provider.""" - - def __init__(self, name: str, created_on: str): - self.name = name - self.created_on = created_on - - -class UserConsumer(object): - """Demonstrate some basic functionality of how the User Consumer will interact - with the User Provider, in this case a simple get_user.""" - - def __init__(self, base_uri: str): - """Initialise the Consumer, in this case we only need to know the URI. - - :param base_uri: The full URI, including port of the Provider to connect to - """ - self.base_uri = base_uri - - def get_user(self, user_name: str) -> Optional[User]: - """Fetch a user object by user_name from the server. - - :param user_name: User name to search for - :return: User details if found, None if not found - """ - uri = self.base_uri + "/users/" + user_name - response = requests.get(uri) - if response.status_code == 404: - return None - - name = response.json()["name"] - created_on = datetime.strptime(response.json()["created_on"], "%Y-%m-%dT%H:%M:%S") - - return User(name, created_on) diff --git a/examples/consumer/tests/consumer/test_user_consumer.py b/examples/consumer/tests/consumer/test_user_consumer.py deleted file mode 100644 index 72ebaf589..000000000 --- a/examples/consumer/tests/consumer/test_user_consumer.py +++ /dev/null @@ -1,129 +0,0 @@ -"""pact test for user service client""" - -import atexit -import logging -import os - -import pytest - -from pact import Consumer, Like, Provider, Term, Format -from src.consumer import UserConsumer - -log = logging.getLogger(__name__) -logging.basicConfig(level=logging.INFO) - -# 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" - -# Define where to run the mock server, for the consumer to connect to. These -# are the defaults so may be omitted -PACT_MOCK_HOST = "localhost" -PACT_MOCK_PORT = 1234 - -# Where to output the JSON Pact files created by any tests -PACT_DIR = os.path.dirname(os.path.realpath(__file__)) - - -@pytest.fixture -def consumer() -> UserConsumer: - return UserConsumer("http://{host}:{port}".format(host=PACT_MOCK_HOST, port=PACT_MOCK_PORT)) - - -@pytest.fixture(scope="session") -def pact(request): - """Setup a Pact Consumer, which provides the Provider mock service. This - will generate and optionally publish Pacts to the Pact Broker""" - - # 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() - - # Make sure the Pact mocked provider is stopped when we finish, otherwise - # port 1234 may become blocked - atexit.register(pact.stop_service) - - yield pact - - # This will stop the Pact mock server, and if publish is True, submit Pacts - # to the Pact Broker - 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 - - -def test_get_user_non_admin(pact, 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"), - "ip_address": Format().ip_address, - "admin": False, - } - - # Define the expected behaviour of the Provider. This determines how the - # Pact mock provider will behave. In this case, we expect a body which is - # "Like" the structure defined above. This means the mock provider will - # 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") - .upon_receiving("a request for UserA") - .with_request("get", "/users/UserA") - .will_respond_with(200, body=Like(expected)) - ) - - with pact: - # Perform the actual request - user = consumer.get_user("UserA") - - # In this case the mock Provider will have returned a valid response - assert user.name == "UserA" - - # Make sure that all interactions defined occurred - pact.verify() - - -def test_get_non_existing_user(pact, 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") - .upon_receiving("a request for UserA") - .with_request("get", "/users/UserA") - .will_respond_with(404) - ) - - with pact: - # Perform the actual request - user = consumer.get_user("UserA") - - # In this case, the mock Provider will have returned a 404 so the - # consumer will have returned None - assert user is None - - # Make sure that all interactions defined occurred - pact.verify() diff --git a/examples/pacts/.gitignore b/examples/pacts/.gitignore new file mode 100644 index 000000000..d6b7ef32c --- /dev/null +++ b/examples/pacts/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/examples/pacts/userserviceclient-userservice.json b/examples/pacts/userserviceclient-userservice.json deleted file mode 100644 index d3260f688..000000000 --- a/examples/pacts/userserviceclient-userservice.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "consumer": { - "name": "UserServiceClient" - }, - "provider": { - "name": "UserService" - }, - "interactions": [ - { - "description": "a request for UserA", - "providerState": "UserA exists and is not an administrator", - "request": { - "method": "get", - "path": "/users/UserA" - }, - "response": { - "status": 200, - "headers": { - }, - "body": { - "name": "UserA", - "id": "fc763eba-0905-41c5-a27f-3934ab26786c", - "created_on": "2016-12-15T20:16:01", - "ip_address": "127.0.0.1", - "admin": false - }, - "matchingRules": { - "$.body": { - "match": "type" - }, - "$.body.id": { - "match": "regex", - "regex": "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" - }, - "$.body.created_on": { - "match": "regex", - "regex": "\\d+-\\d+-\\d+T\\d+:\\d+:\\d+" - }, - "$.body.ip_address": { - "match": "regex", - "regex": "(\\d{1,3}\\.)+\\d{1,3}" - } - } - } - }, - { - "description": "a request for UserA", - "providerState": "UserA does not exist", - "request": { - "method": "get", - "path": "/users/UserA" - }, - "response": { - "status": 404, - "headers": { - } - } - } - ], - "metadata": { - "pactSpecification": { - "version": "2.0.0" - } - } -} diff --git a/examples/src/__init__.py b/examples/src/__init__.py new file mode 100644 index 000000000..87b9d2a43 --- /dev/null +++ b/examples/src/__init__.py @@ -0,0 +1,12 @@ +""" +Example Client Code. + +This module defines a simple consumer and a couple of implementation of simple +providers. The general premise here is that the consumers will be fetching user +information from the providers. + +The development of the consumer and provider sides would typically be done in +separate teams (and likely different languages). Within the Pact framework, the +consumer side is the one which defines the contract and the provider side is the +one which must satisfy the contract. +""" diff --git a/examples/src/consumer.py b/examples/src/consumer.py new file mode 100644 index 000000000..3aab181da --- /dev/null +++ b/examples/src/consumer.py @@ -0,0 +1,103 @@ +""" +Simple Consumer Implementation. + +This modules defines a simple +[consumer](https://docs.pact.io/getting_started/terminology#service-consumer) +with Pact. As Pact is a consumer-driven framework, the consumer defines the +interactions which the provider must then satisfy. + +The consumer is the application which makes requests to another service (the +provider) and receives a response to process. In this example, we have a simple +[`User`](User) class and the consumer fetches a user's information from a HTTP +endpoint. + +Note that the code in this module is agnostic of Pact. The `pact-python` +dependency only appears in the tests. This is because the consumer is not +concerned with Pact, only the tests are. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import Any + +import requests + + +@dataclass() +class User: + """User data class.""" + + id: int # noqa: A003 + name: str + created_on: datetime + + def __post_init__(self) -> None: + """ + Validate the User data. + + This performs the following checks: + + - The name cannot be empty + - The id must be a positive integer + + Raises: + ValueError: If any of the above checks fail. + """ + if not self.name: + msg = "User must have a name" + raise ValueError(msg) + + if self.id < 0: + msg = "User ID must be a positive integer" + raise ValueError(msg) + + def __repr__(self) -> str: + """Return the user's name.""" + return f"User({self.id}:{self.name})" + + +class UserConsumer: + """ + Example consumer. + + This class defines a simple consumer which will interact with a provider + over HTTP to fetch a user's information, and then return an instance of the + `User` class. + """ + + def __init__(self, base_uri: str) -> None: + """ + Initialise the consumer. + + Args: + base_uri: The uri of the provider + """ + self.base_uri = base_uri + + def get_user(self, user_id: int) -> User: + """ + Fetch a user by ID from the server. + + Args: + user_id: The ID of the user to fetch. + + Returns: + The user if found. + + In all other cases, an error dictionary is returned with the key + `error` and the value as the error message. + + Raises: + requests.HTTPError: If the server returns a non-200 response. + """ + uri = f"{self.base_uri}/users/{user_id}" + response = requests.get(uri, timeout=5) + response.raise_for_status() + data: dict[str, Any] = response.json() + return User( + id=data["id"], + name=data["name"], + created_on=datetime.fromisoformat(data["created_on"]), + ) diff --git a/examples/tests/test_00_consumer.py b/examples/tests/test_00_consumer.py new file mode 100644 index 000000000..029550714 --- /dev/null +++ b/examples/tests/test_00_consumer.py @@ -0,0 +1,130 @@ +""" +Test the consumer with Pact. + +This module tests the consumer defined in `src/consumer.py` against a mock +provider. The mock provider is set up by Pact, and is used to ensure that the +consumer is making the expected requests to the provider, and that the provider +is responding with the expected responses. Once these interactions are +validated, the contracts can be published to a Pact Broker. The contracts can +then be used to validate the provider's interactions. +""" + +from __future__ import annotations + +import logging +from http import HTTPStatus +from typing import TYPE_CHECKING, Any, Generator + +import pytest +import requests +from pact import Consumer, Format, Like, Provider +from yarl import URL + +from src.consumer import User, UserConsumer + +if TYPE_CHECKING: + from pathlib import Path + + from pact.pact import Pact + +log = logging.getLogger(__name__) + +MOCK_URL = URL("http://localhost:8080") + + +@pytest.fixture() +def user_consumer() -> UserConsumer: + """ + Returns an instance of the UserConsumer class. + + As we do not want to stand up all of the consumer's dependencies, we direct + the consumer to use Pact's mock provider. This allows us to define what + requests the consumer will make to the provider, and what responses the + provider will return. + """ + return UserConsumer(str(MOCK_URL)) + + +@pytest.fixture(scope="module") +def pact(broker: URL, pact_dir: Path) -> Generator[Pact, Any, None]: + """ + Set up Pact. + + In order to test the consumer in isolation, Pact sets up a mock version of + the provider. This mock provider will expect to receive defined requests + and will respond with defined responses. + + The fixture here simply defines the Consumer and Provide, and sets up the + mock provider. With each test, we define the expected request and response + from the provider as follows: + + ```python + pact.given("UserA exists and is not an admin") \ + .upon_receiving("A request for UserA") \ + .with_request("get", "/users/123") \ + .will_respond_with(200, body=Like(expected)) + ``` + """ + consumer = Consumer("UserConsumer") + pact = consumer.has_pact_with( + Provider("UserProvider"), + pact_dir=pact_dir, + publish_to_broker=True, + # Mock service configuration + host_name=MOCK_URL.host, + port=MOCK_URL.port, + # Broker configuration + broker_base_url=str(broker), + broker_username=broker.user, + broker_password=broker.password, + ) + + pact.start_service() + yield pact + pact.stop_service() + + +def test_get_existing_user(pact: Pact, user_consumer: UserConsumer) -> None: + """ + Test request for an existing user. + + This test defines the expected request and response from the provider. The + provider will be expected to return a response with a status code of 200, + """ + expected: dict[str, Any] = { + "id": Format().integer, + "name": "Verna Hampton", + "created_on": Format().iso_8601_datetime(), + } + + ( + pact.given("user 123 exists") + .upon_receiving("a request for user 123") + .with_request("get", "/users/123") + .will_respond_with(200, body=Like(expected)) + ) + + with pact: + user = user_consumer.get_user(123) + + assert isinstance(user, User) + assert user.name == "Verna Hampton" + + pact.verify() + + +def test_get_unknown_user(pact: Pact, user_consumer: UserConsumer) -> None: + expected = {"error": "User not found"} + + ( + pact.given("user 123 doesn't exist") + .upon_receiving("a request for user 123") + .with_request("get", "/users/123") + .will_respond_with(404, body=Like(expected)) + ) + + with pact: + with pytest.raises(requests.HTTPError) as excinfo: + user_consumer.get_user(123) + assert excinfo.value.response.status_code == HTTPStatus.NOT_FOUND + pact.verify() diff --git a/pyproject.toml b/pyproject.toml index 30e2533b8..bad6b3d48 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,6 +55,7 @@ test = [ "pytest ~= 7.4", "pytest-cov ~= 4.1", "testcontainers ~= 3.7", + "yarl ~= 1.9", ] dev = [ "pact-python[types]", @@ -104,7 +105,7 @@ extra-dependencies = ["hatchling", "packaging", "requests"] [tool.hatch.envs.default.scripts] lint = ["black --check --diff {args:.}", "ruff {args:.}", "mypy {args:.}"] test = "pytest --cov-config=pyproject.toml --cov=pact --cov=tests tests/" -# TODO: Adapt the examples to work in Hatch +example = "PYTHONPATH=examples pytest examples/ {args}" all = ["lint", "tests"] # Test environment for running unit tests. This automatically tests against all From 4da4d14c524accccae40539a8164265d874ac054 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Mon, 18 Sep 2023 14:20:39 +1000 Subject: [PATCH 3/9] chore(example): migrate fastapi provider example This migrates the Pact FastAPI provider from the old standalone examples, and merges it with the new combined examples. The consumer tests are executed first which publishes the contracts with the broker. The provider test is then executed against the broker to verify compliance with the published contracts. This does, at this stage, create an unusual interdependence between tests which typically should be avoided. I plan to fix this at a later stage. Signed-off-by: JP-Ellis --- examples/fastapi_provider/.flake8 | 4 - examples/fastapi_provider/requirements.txt | 9 -- examples/fastapi_provider/run_pytest.sh | 6 - examples/fastapi_provider/src/provider.py | 25 --- examples/fastapi_provider/tests/__init__.py | 0 examples/fastapi_provider/tests/conftest.py | 27 ---- .../fastapi_provider/tests/pact_provider.py | 46 ------ .../tests/provider/__init__.py | 0 .../tests/provider/test_provider.py | 87 ----------- examples/fastapi_provider/verify_pact.sh | 37 ----- examples/src/fastapi.py | 52 +++++++ examples/tests/test_01_provider_fastapi.py | 143 ++++++++++++++++++ 12 files changed, 195 insertions(+), 241 deletions(-) delete mode 100644 examples/fastapi_provider/.flake8 delete mode 100644 examples/fastapi_provider/requirements.txt delete mode 100755 examples/fastapi_provider/run_pytest.sh delete mode 100644 examples/fastapi_provider/src/provider.py delete mode 100644 examples/fastapi_provider/tests/__init__.py delete mode 100644 examples/fastapi_provider/tests/conftest.py delete mode 100644 examples/fastapi_provider/tests/pact_provider.py delete mode 100644 examples/fastapi_provider/tests/provider/__init__.py delete mode 100644 examples/fastapi_provider/tests/provider/test_provider.py delete mode 100755 examples/fastapi_provider/verify_pact.sh create mode 100644 examples/src/fastapi.py create mode 100644 examples/tests/test_01_provider_fastapi.py diff --git a/examples/fastapi_provider/.flake8 b/examples/fastapi_provider/.flake8 deleted file mode 100644 index 5fb64e780..000000000 --- a/examples/fastapi_provider/.flake8 +++ /dev/null @@ -1,4 +0,0 @@ -[flake8] -max-line-length = 160 -exclude = .direnv/* -max-complexity = 10 diff --git a/examples/fastapi_provider/requirements.txt b/examples/fastapi_provider/requirements.txt deleted file mode 100644 index eea0e8724..000000000 --- a/examples/fastapi_provider/requirements.txt +++ /dev/null @@ -1,9 +0,0 @@ -fastapi==0.67.0 -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' -uvicorn==0.16.0; python_version < '3.7' -uvicorn>=0.19.0; python_version >= '3.7' -testcontainers==3.7.0; python_version < '3.7' -testcontainers==3.7.1; python_version >= '3.7' diff --git a/examples/fastapi_provider/run_pytest.sh b/examples/fastapi_provider/run_pytest.sh deleted file mode 100755 index 447e0c4b3..000000000 --- a/examples/fastapi_provider/run_pytest.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/bash -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\ diff --git a/examples/fastapi_provider/src/provider.py b/examples/fastapi_provider/src/provider.py deleted file mode 100644 index 206a87da8..000000000 --- a/examples/fastapi_provider/src/provider.py +++ /dev/null @@ -1,25 +0,0 @@ -import logging - -from fastapi import FastAPI, HTTPException, APIRouter -from fastapi.logger import logger - -fakedb = {} # Use a simple dict to represent a database - -logger.setLevel(logging.DEBUG) -router = APIRouter() -app = FastAPI() - - -@app.get("/users/{name}") -def get_user_by_name(name: str): - """Handle requests to retrieve a single user from the simulated database. - - :param name: Name of the user to "search for" - :return: The user data if found, HTTP 404 if not - """ - user_data = fakedb.get(name) - if not user_data: - logger.error(f"GET user for: '{name}', HTTP 404 not found") - raise HTTPException(status_code=404, detail="User not found") - logger.error(f"GET user for: '{name}', returning: {user_data}") - return user_data diff --git a/examples/fastapi_provider/tests/__init__.py b/examples/fastapi_provider/tests/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/examples/fastapi_provider/tests/conftest.py b/examples/fastapi_provider/tests/conftest.py deleted file mode 100644 index 6e3176f20..000000000 --- a/examples/fastapi_provider/tests/conftest.py +++ /dev/null @@ -1,27 +0,0 @@ -import sys -from multiprocessing import Process - -import pytest - -from .pact_provider import run_server - -# Load in the fixtures from common/sharedfixtures.py -sys.path.append("../common") - -pytest_plugins = [ - "sharedfixtures", -] - - -@pytest.fixture(scope="module") -def server(): - proc = Process(target=run_server, args=(), daemon=True) - proc.start() - yield proc - - # Cleanup after test - if sys.version_info >= (3, 7): - # multiprocessing.kill is new in 3.7 - proc.kill() - else: - proc.terminate() diff --git a/examples/fastapi_provider/tests/pact_provider.py b/examples/fastapi_provider/tests/pact_provider.py deleted file mode 100644 index d778cd433..000000000 --- a/examples/fastapi_provider/tests/pact_provider.py +++ /dev/null @@ -1,46 +0,0 @@ -import uvicorn - -from fastapi import APIRouter -from pydantic import BaseModel - -from src.provider import app, fakedb, router as main_router - -pact_router = APIRouter() - - -class ProviderState(BaseModel): - state: str # noqa: E999 - - -@pact_router.post("/_pact/provider_states") -async def provider_states(provider_state: ProviderState): - mapping = { - "UserA does not exist": setup_no_user_a, - "UserA exists and is not an administrator": setup_user_a_nonadmin, - } - mapping[provider_state.state]() - - return {"result": mapping[provider_state.state]} - - -# Make sure the app includes both routers. This needs to be done after the -# declaration of the provider_states -app.include_router(main_router) -app.include_router(pact_router) - - -def run_server(): - uvicorn.run(app) - - -def setup_no_user_a(): - if "UserA" in fakedb: - del fakedb["UserA"] - - -def setup_user_a_nonadmin(): - id = "00000000-0000-4000-a000-000000000000" - some_date = "2016-12-15T20:16:01" - ip_address = "198.0.0.1" - - fakedb["UserA"] = {"name": "UserA", "id": id, "created_on": some_date, "ip_address": ip_address, "admin": False} diff --git a/examples/fastapi_provider/tests/provider/__init__.py b/examples/fastapi_provider/tests/provider/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/examples/fastapi_provider/tests/provider/test_provider.py b/examples/fastapi_provider/tests/provider/test_provider.py deleted file mode 100644 index bc903b481..000000000 --- a/examples/fastapi_provider/tests/provider/test_provider.py +++ /dev/null @@ -1,87 +0,0 @@ -"""pact test for user service client""" -import logging - -import pytest - -from pact import Verifier - -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" - -# 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 -# running a Docker container with a database of test data configured. -# This is the "real" provider to verify against. -PROVIDER_HOST = "127.0.0.1" -PROVIDER_PORT = 8000 -PROVIDER_URL = f"http://{PROVIDER_HOST}:{PROVIDER_PORT}" - - -def test_success(): - pass - - -@pytest.fixture -def broker_opts(): - return { - "broker_username": PACT_BROKER_USERNAME, - "broker_password": PACT_BROKER_PASSWORD, - "broker_url": PACT_BROKER_URL, - "publish_version": "3", - "publish_verification_results": True, - } - - -def test_user_service_provider_against_broker(server, broker_opts): - verifier = Verifier(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( - **broker_opts, - verbose=True, - provider_states_setup_url=f"{PROVIDER_URL}/_pact/provider_states", - enable_pending=False, - ) - # If publish_verification_results is set to True, the results will be - # published to the Pact Broker. - # In the Pact Broker logs, this corresponds to the following entry: - # PactBroker::Verifications::Service -- Creating verification 200 for \ - # pact_version_sha=c8568cbb30d2e3933b2df4d6e1248b3d37f3be34 -- \ - # {"success"=>true, "providerApplicationVersion"=>"3", "wip"=>false, \ - # "pending"=>"true"} - - # Note: - # If "successful", then the return code here will be 0 - # This can still be 0 and so PASS if a Pact verification FAILS, as long as - # it has not resulted in a REGRESSION of an already verified interaction. - # See https://docs.pact.io/pact_broker/advanced_topics/pending_pacts/ for - # more details. - assert success == 0 - - -def test_user_service_provider_against_pact(server): - verifier = Verifier(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. - output, _ = verifier.verify_pacts( - "../pacts/userserviceclient-userservice.json", - verbose=False, - provider_states_setup_url="{}/_pact/provider_states".format(PROVIDER_URL), - ) - - assert output == 0 diff --git a/examples/fastapi_provider/verify_pact.sh b/examples/fastapi_provider/verify_pact.sh deleted file mode 100755 index 356b9f739..000000000 --- a/examples/fastapi_provider/verify_pact.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/bin/bash -set -o pipefail - -# Run the FastAPI server, using the pact_provider.py as the app to be able to -# inject the provider_states endpoint -uvicorn tests.pact_provider:app & &>/dev/null -FASTAPI_PID=$! - -# Make sure the FastAPI server is stopped when finished to avoid blocking the port -function teardown { - echo "Tearing down FastAPI server ${FASTAPI_PID}" - kill -9 $FASTAPI_PID -} -trap teardown EXIT - -# Wait a little in case FastAPI isn't quite ready -sleep 1 - -VERSION=$1 -if [ -x $VERSION ]; -then - echo "Validating provider locally" - - pact-verifier --provider-base-url=http://localhost:8000 \ - --provider-states-setup-url=http://localhost:8000/_pact/provider_states \ - ../pacts/userserviceclient-userservice.json -else - echo "Validating against Pact Broker" - - pact-verifier --provider-base-url=http://localhost:8000 \ - --provider-app-version $VERSION \ - --pact-url="http://127.0.0.1/pacts/provider/UserService/consumer/UserServiceClient/latest" \ - --pact-broker-username pactbroker \ - --pact-broker-password pactbroker \ - --publish-verification-results \ - --provider-states-setup-url=http://localhost:8000/_pact/provider_states -fi diff --git a/examples/src/fastapi.py b/examples/src/fastapi.py new file mode 100644 index 000000000..2589fcd3d --- /dev/null +++ b/examples/src/fastapi.py @@ -0,0 +1,52 @@ +""" +FastAPI provider example. + +This modules defines a simple +[provider](https://docs.pact.io/getting_started/terminology#service-provider) +with Pact. As Pact is a consumer-driven framework, the consumer defines the +contract which the provider must then satisfy. + +The provider is the application which receives requests from another service +(the consumer) and returns a response. In this example, we have a simple +endpoint which returns a user's information from a (fake) database. + +Note that the code in this module is agnostic of Pact. The `pact-python` +dependency only appears in the tests. This is because the consumer is not +concerned with Pact, only the tests are. +""" + +from __future__ import annotations + +from typing import Any + +from fastapi import FastAPI +from fastapi.responses import JSONResponse + +app = FastAPI() + +""" +As this is a simple example, we'll use a simple dict to represent a database. +This would be replaced with a real database in a real application. + +When testing the provider in a real application, the calls to the database +would be mocked out to avoid the need for a real database. An example of this +can be found in the test suite. +""" +FAKE_DB: dict[int, dict[str, Any]] = {} + + +@app.get("/users/{uid}") +async def get_user_by_id(uid: int) -> dict[str, Any]: + """ + Fetch a user by their ID. + + Args: + uid: The ID of the user to fetch + + Returns: + The user data if found, HTTP 404 if not + """ + user = FAKE_DB.get(uid) + if not user: + return JSONResponse(status_code=404, content={"error": "User not found"}) + return JSONResponse(status_code=200, content=user) diff --git a/examples/tests/test_01_provider_fastapi.py b/examples/tests/test_01_provider_fastapi.py new file mode 100644 index 000000000..ce05964f0 --- /dev/null +++ b/examples/tests/test_01_provider_fastapi.py @@ -0,0 +1,143 @@ +""" +Test the FastAPI provider with Pact. + +This module tests the FastAPI provider defined in `src/fastapi.py` against the +mock consumer. The mock consumer is set up by Pact and will replay the requests +defined by the consumers. Pact will then validate that the provider responds +with the expected responses. + +The provider will be expected to be in a given state in order to respond to +certain requests. For example, when fetching a user's information, the provider +will need to have a user with the given ID in the database. In order to avoid +side effects, the provider's database calls are mocked out using functionalities +from `unittest.mock`. + +In order to set the provider into the correct state, this test module defines an +additional endpoint on the provider, in this case `/_pact/provider_states`. +Calls to this endpoint mock the relevant database calls to set the provider into +the correct state. +""" + +from __future__ import annotations + +from multiprocessing import Process +from typing import Any, Generator +from unittest.mock import MagicMock + +import pytest +import uvicorn +from pact import Verifier +from pydantic import BaseModel +from yarl import URL + +from src.fastapi import app + +PROVIDER_URL = URL("http://localhost:8080") + + +class ProviderState(BaseModel): + """Define the provider state.""" + + consumer: str + state: str + + +@app.post("/_pact/provider_states") +async def mock_pact_provider_states(state: ProviderState) -> dict[str, str | None]: + """ + Define the provider state. + + For Pact to be able to correctly tests compliance with the contract, the + internal state of the provider needs to be set up correctly. Naively, this + would be achieved by setting up the database with the correct data for the + test, but this can be slow and error-prone. Instead this is best achieved by + mocking the relevant calls to the database so as to avoid any side effects. + + For Pact to be able to correctly get the provider into the correct state, + this function is used to define an additional endpoint on the provider. This + endpoint is called by Pact before each test to ensure that the provider is + in the correct state. + """ + mapping = { + "user 123 doesn't exist": mock_user_123_doesnt_exist, + "user 123 exists": mock_user_123_exists, + } + return {"result": mapping[state.state]()} + + +def run_server() -> None: + """ + Run the FastAPI server. + + This function is required to run the FastAPI server in a separate process. A + lambda cannot be used as the target of a `multiprocessing.Process` as it + cannot be pickled. + """ + uvicorn.run(app, host=PROVIDER_URL.host, port=PROVIDER_URL.port) + + +@pytest.fixture(scope="module") +def verifier() -> Generator[Verifier, Any, None]: + """Set up the Pact verifier.""" + proc = Process(target=run_server, daemon=True) + verifier = Verifier( + provider="UserProvider", + provider_base_url=str(PROVIDER_URL), + ) + proc.start() + yield verifier + proc.kill() + + +def mock_user_123_doesnt_exist() -> None: + """Mock the database for the user 123 doesn't exist state.""" + import src.fastapi + + src.fastapi.FAKE_DB = MagicMock() + src.fastapi.FAKE_DB.get.return_value = None + + +def mock_user_123_exists() -> None: + """ + Mock the database for the user 123 exists state. + + You may notice that the return value here differs from the consumer's + expected response. This is because the consumer's expected response is + guided by what the consumer users. + + By using consumer-driven contracts and testing the provider against the + consumer's contract, we can ensure that the provider is only providing what + """ + import src.fastapi + + src.fastapi.FAKE_DB = MagicMock() + src.fastapi.FAKE_DB.get.return_value = { + "id": 123, + "name": "Verna Hampton", + "created_on": "2016-12-15T20:16:01", + "ip_address": "10.1.2.3", + "hobbies": ["hiking", "swimming"], + "admin": False, + } + + +def test_against_broker(broker: URL, verifier: Verifier) -> None: + """ + Test the provider against the broker. + + The broker will be used to retrieve the contract, and the provider will be + tested against the contract. + + As Pact is a consumer-driven, the provider is tested against the contract + defined by the consumer. The consumer defines the expected request to and + response from the provider. + + For an example of the consumer's contract, see the consumer's tests. + """ + code, _ = verifier.verify_with_broker( + broker_url=str(broker), + published_verification_results=True, + provider_states_setup_url=str(PROVIDER_URL / "_pact" / "provider_states"), + ) + + assert code == 0 From e2103f78c9d70bb45e4ae559ed88745669a354ee Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Mon, 18 Sep 2023 15:46:38 +1000 Subject: [PATCH 4/9] chore(example): migrate flask provider example Following the changes to the FastAPI example, this migrates the Flask provider example to the new structure. The example relies on the consumer having published contracts, and the flask provider is verified against those contracts. Signed-off-by: JP-Ellis --- examples/flask_provider/.flake8 | 4 - examples/flask_provider/requirements.txt | 10 -- examples/flask_provider/run_pytest.sh | 20 --- examples/flask_provider/src/provider.py | 25 ---- examples/flask_provider/tests/conftest.py | 8 -- .../flask_provider/tests/pact_provider.py | 48 ------- .../tests/provider/test_provider.py | 83 ----------- examples/flask_provider/verify_pact.sh | 39 ----- examples/src/flask.py | 51 +++++++ examples/tests/test_01_provider_flask.py | 136 ++++++++++++++++++ pyproject.toml | 2 +- 11 files changed, 188 insertions(+), 238 deletions(-) delete mode 100644 examples/flask_provider/.flake8 delete mode 100644 examples/flask_provider/requirements.txt delete mode 100755 examples/flask_provider/run_pytest.sh delete mode 100644 examples/flask_provider/src/provider.py delete mode 100644 examples/flask_provider/tests/conftest.py delete mode 100644 examples/flask_provider/tests/pact_provider.py delete mode 100644 examples/flask_provider/tests/provider/test_provider.py delete mode 100755 examples/flask_provider/verify_pact.sh create mode 100644 examples/src/flask.py create mode 100644 examples/tests/test_01_provider_flask.py diff --git a/examples/flask_provider/.flake8 b/examples/flask_provider/.flake8 deleted file mode 100644 index 5fb64e780..000000000 --- a/examples/flask_provider/.flake8 +++ /dev/null @@ -1,4 +0,0 @@ -[flake8] -max-line-length = 160 -exclude = .direnv/* -max-complexity = 10 diff --git a/examples/flask_provider/requirements.txt b/examples/flask_provider/requirements.txt deleted file mode 100644 index b3f83b690..000000000 --- a/examples/flask_provider/requirements.txt +++ /dev/null @@ -1,10 +0,0 @@ -Flask==2.0.3; python_version < '3.7' -Flask==2.2.5; python_version >= '3.7' -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' -markupsafe==2.0.1; python_version < '3.7' -markupsafe==2.1.2; python_version >= '3.7' diff --git a/examples/flask_provider/run_pytest.sh b/examples/flask_provider/run_pytest.sh deleted file mode 100755 index 37aa783a0..000000000 --- a/examples/flask_provider/run_pytest.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/bin/bash -set -o pipefail - -# Run the Flask server, using the pact_provider.py as the app to be able to -# inject the provider_states endpoint -FLASK_APP=tests/pact_provider.py python -m flask run -p 5001 & -FLASK_PID=$! - -# Make sure the Flask server is stopped when finished to avoid blocking the port -function teardown { - echo "Tearing down Flask server: ${FLASK_PID}" - kill -9 $FLASK_PID -} -trap teardown EXIT - -# Wait a little in case Flask isn't quite ready -sleep 1 - -# Now run the tests -pytest tests --run-broker True --publish-pact 1 diff --git a/examples/flask_provider/src/provider.py b/examples/flask_provider/src/provider.py deleted file mode 100644 index abdb4549f..000000000 --- a/examples/flask_provider/src/provider.py +++ /dev/null @@ -1,25 +0,0 @@ -from flask import Flask, abort, jsonify - -fakedb = {} # Use a simple dict to represent a database - -app = Flask(__name__) - - -@app.route("/users/") -def get_user_by_name(name: str): - """Handle requests to retrieve a single user from the simulated database. - - :param name: Name of the user to "search for" - :return: The user data if found, None (HTTP 404) if not - """ - user_data = fakedb.get(name) - if not user_data: - app.logger.debug(f"GET user for: '{name}', HTTP 404 not found") - abort(404) - response = jsonify(**user_data) - app.logger.debug(f"GET user for: '{name}', returning: {response.data}") - return response - - -if __name__ == "__main__": - app.run(debug=True, port=5001) diff --git a/examples/flask_provider/tests/conftest.py b/examples/flask_provider/tests/conftest.py deleted file mode 100644 index 90ac63b9a..000000000 --- a/examples/flask_provider/tests/conftest.py +++ /dev/null @@ -1,8 +0,0 @@ -import sys - -# Load in the fixtures from common/sharedfixtures.py -sys.path.append("../common") - -pytest_plugins = [ - "sharedfixtures", -] diff --git a/examples/flask_provider/tests/pact_provider.py b/examples/flask_provider/tests/pact_provider.py deleted file mode 100644 index 0cb967486..000000000 --- a/examples/flask_provider/tests/pact_provider.py +++ /dev/null @@ -1,48 +0,0 @@ -"""additional endpoints to facilitate provider_states""" - -from flask import jsonify, request - -from src.provider import app, fakedb - - -@app.route("/_pact/provider_states", methods=["POST"]) -def provider_states(): - """Implement the "functionality" to change the state, to prepare for a test. - - When a Pact interaction is verified, it provides the "given" part of the - description from the Consumer in the X_PACT_PROVIDER_STATES header. - This can then be used to perform some operations on a database for example, - so that the actual request can be performed and respond as expected. - See: https://docs.pact.io/getting_started/provider_states - - This provider_states endpoint is deemed test only, and generally should not - be available once deployed to an environment. It would represent both a - potential data loss risk, as well as a security risk. - - As such, when running the Provider to test against, this is defined as the - FLASK_APP to run, adding this additional route to the app while keeping the - source separate. - """ - mapping = { - "UserA does not exist": setup_no_user_a, - "UserA exists and is not an administrator": setup_user_a_nonadmin, - } - mapping[request.json["state"]]() - return jsonify({"result": request.json["state"]}) - - -def setup_no_user_a(): - if "UserA" in fakedb: - del fakedb["UserA"] - - -def setup_user_a_nonadmin(): - id = "00000000-0000-4000-a000-000000000000" - some_date = "2016-12-15T20:16:01" - ip_address = "198.0.0.1" - - fakedb["UserA"] = {"name": "UserA", "id": id, "created_on": some_date, "ip_address": ip_address, "admin": False} - - -if __name__ == "__main__": - app.run(debug=True, port=5001) diff --git a/examples/flask_provider/tests/provider/test_provider.py b/examples/flask_provider/tests/provider/test_provider.py deleted file mode 100644 index f87257785..000000000 --- a/examples/flask_provider/tests/provider/test_provider.py +++ /dev/null @@ -1,83 +0,0 @@ -"""pact test for user service provider""" - -import logging - -import pytest - -from pact import Verifier - -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" - -# 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_PORT = 5001 -PROVIDER_URL = f"http://{PROVIDER_HOST}:{PROVIDER_PORT}" - - -@pytest.fixture -def broker_opts(): - return { - "broker_username": PACT_BROKER_USERNAME, - "broker_password": PACT_BROKER_PASSWORD, - "broker_url": PACT_BROKER_URL, - "publish_version": "3", - "publish_verification_results": True, - } - - -def test_user_service_provider_against_broker(broker_opts): - verifier = Verifier(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( - **broker_opts, - verbose=True, - provider_states_setup_url=f"{PROVIDER_URL}/_pact/provider_states", - enable_pending=False, - ) - # If publish_verification_results is set to True, the results will be - # published to the Pact Broker. - # In the Pact Broker logs, this corresponds to the following entry: - # PactBroker::Verifications::Service -- Creating verification 200 for \ - # pact_version_sha=c8568cbb30d2e3933b2df4d6e1248b3d37f3be34 -- \ - # {"success"=>true, "providerApplicationVersion"=>"3", "wip"=>false, \ - # "pending"=>"true"} - - # Note: - # If "successful", then the return code here will be 0 - # This can still be 0 and so PASS if a Pact verification FAILS, as long as - # it has not resulted in a REGRESSION of an already verified interaction. - # See https://docs.pact.io/pact_broker/advanced_topics/pending_pacts/ for - # more details. - assert success == 0 - - -def test_user_service_provider_against_pact(): - verifier = Verifier(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. - output, _ = verifier.verify_pacts( - "../pacts/userserviceclient-userservice.json", - verbose=False, - provider_states_setup_url="{}/_pact/provider_states".format(PROVIDER_URL), - ) - - assert output == 0 diff --git a/examples/flask_provider/verify_pact.sh b/examples/flask_provider/verify_pact.sh deleted file mode 100755 index f70629699..000000000 --- a/examples/flask_provider/verify_pact.sh +++ /dev/null @@ -1,39 +0,0 @@ -#!/bin/bash -set -o pipefail - -# Run the Flask server, using the pact_provider.py as the app to be able to -# inject the provider_states endpoint -FLASK_APP=tests/pact_provider.py python -m flask run -p 5001 & -FLASK_PID=$! - -# Make sure the Flask server is stopped when finished to avoid blocking the port -function teardown { - echo "Tearing down Flask server: ${FLASK_PID}" - kill -9 $FLASK_PID -} -trap teardown EXIT - -# Wait a little in case Flask isn't quite ready -sleep 1 - -VERSION=$1 -if [ -z "$VERSION" ]; -then - echo "Validating provider locally" - - pact-verifier \ - --provider-base-url=http://localhost:5001 \ - --provider-states-setup-url=http://localhost:5001/_pact/provider_states \ - ../pacts/userserviceclient-userservice.json -else - echo "Validating against Pact Broker" - - pact-verifier \ - --provider-base-url=http://localhost:5001 \ - --provider-app-version $VERSION \ - --pact-url="http://127.0.0.1/pacts/provider/UserService/consumer/UserServiceClient/latest" \ - --pact-broker-username pactbroker \ - --pact-broker-password pactbroker \ - --publish-verification-results \ - --provider-states-setup-url=http://localhost:5001/_pact/provider_states -fi diff --git a/examples/src/flask.py b/examples/src/flask.py new file mode 100644 index 000000000..fd5095e03 --- /dev/null +++ b/examples/src/flask.py @@ -0,0 +1,51 @@ +""" +Flask provider example. + +This modules defines a simple +[provider](https://docs.pact.io/getting_started/terminology#service-provider) +with Pact. As Pact is a consumer-driven framework, the consumer defines the +contract which the provider must then satisfy. + +The provider is the application which receives requests from another service +(the consumer) and returns a response. In this example, we have a simple +endpoint which returns a user's information from a (fake) database. + +Note that the code in this module is agnostic of Pact. The `pact-python` +dependency only appears in the tests. This is because the consumer is not +concerned with Pact, only the tests are. +""" + +from __future__ import annotations + +from typing import Any + +from flask import Flask + +app = Flask(__name__) + +""" +As this is a simple example, we'll use a simple dict to represent a database. +This would be replaced with a real database in a real application. + +When testing the provider in a real application, the calls to the database +would be mocked out to avoid the need for a real database. An example of this +can be found in the test suite. +""" +FAKE_DB: dict[int, dict[str, Any]] = {} + + +@app.route("/users/") +def get_user_by_id(uid: int) -> dict[str, Any] | tuple[dict[str, Any], int]: + """ + Fetch a user by their ID. + + Args: + uid: The ID of the user to fetch + + Returns: + The user data if found, HTTP 404 if not + """ + user = FAKE_DB.get(uid) + if not user: + return {"error": "User not found"}, 404 + return user diff --git a/examples/tests/test_01_provider_flask.py b/examples/tests/test_01_provider_flask.py new file mode 100644 index 000000000..1aff011e3 --- /dev/null +++ b/examples/tests/test_01_provider_flask.py @@ -0,0 +1,136 @@ +""" +Test the Flask provider with Pact. + +This module tests the Flask provider defined in `src/flask.py` against the mock +consumer. The mock consumer is set up by Pact and will replay the requests +defined by the consumers. Pact will then validate that the provider responds +with the expected responses. + +The provider will be expected to be in a given state in order to respond to +certain requests. For example, when fetching a user's information, the provider +will need to have a user with the given ID in the database. In order to avoid +side effects, the provider's database calls are mocked out using functionalities +from `unittest.mock`. + +In order to set the provider into the correct state, this test module defines an +additional endpoint on the provider, in this case `/_pact/provider_states`. +Calls to this endpoint mock the relevant database calls to set the provider into +the correct state. +""" + + +from __future__ import annotations + +from multiprocessing import Process +from typing import Any, Generator +from unittest.mock import MagicMock + +import pytest +from flask import request +from pact import Verifier +from yarl import URL + +from src.flask import app + +PROVIDER_URL = URL("http://localhost:8080") + + +@app.route("/_pact/provider_states", methods=["POST"]) +async def mock_pact_provider_states() -> dict[str, str | None]: + """ + Define the provider state. + + For Pact to be able to correctly tests compliance with the contract, the + internal state of the provider needs to be set up correctly. Naively, this + would be achieved by setting up the database with the correct data for the + test, but this can be slow and error-prone. Instead this is best achieved by + mocking the relevant calls to the database so as to avoid any side effects. + + For Pact to be able to correctly get the provider into the correct state, + this function is used to define an additional endpoint on the provider. This + endpoint is called by Pact before each test to ensure that the provider is + in the correct state. + """ + mapping = { + "user 123 doesn't exist": mock_user_123_doesnt_exist, + "user 123 exists": mock_user_123_exists, + } + return {"result": mapping[request.json["state"]]()} + + +def run_server() -> None: + """ + Run the Flask server. + + This function is required to run the Flask server in a separate process. A + lambda cannot be used as the target of a `multiprocessing.Process` as it + cannot be pickled. + """ + app.run(host=PROVIDER_URL.host, port=PROVIDER_URL.port) + + +@pytest.fixture(scope="module") +def verifier() -> Generator[Verifier, Any, None]: + """Set up the Pact verifier.""" + proc = Process(target=run_server, daemon=True) + verifier = Verifier( + provider="UserProvider", + provider_base_url=str(PROVIDER_URL), + ) + proc.start() + yield verifier + proc.kill() + + +def mock_user_123_doesnt_exist() -> None: + """Mock the database for the user 123 doesn't exist state.""" + import src.flask + + src.flask.FAKE_DB = MagicMock() + src.flask.FAKE_DB.get.return_value = None + + +def mock_user_123_exists() -> None: + """ + Mock the database for the user 123 exists state. + + You may notice that the return value here differs from the consumer's + expected response. This is because the consumer's expected response is + guided by what the consumer users. + + By using consumer-driven contracts and testing the provider against the + consumer's contract, we can ensure that the provider is only providing what + """ + import src.flask + + src.flask.FAKE_DB = MagicMock() + src.flask.FAKE_DB.get.return_value = { + "id": 123, + "name": "Verna Hampton", + "created_on": "2016-12-15T20:16:01", + "ip_address": "10.1.2.3", + "hobbies": ["hiking", "swimming"], + "admin": False, + } + + +def test_against_broker(broker: URL, verifier: Verifier) -> None: + """ + Test the provider against the broker. + + The broker will be used to retrieve the contract, and the provider will be + tested against the contract. + + As Pact is a consumer-driven, the provider is tested against the contract + defined by the consumer. The consumer defines the expected request to and + response from the provider. + + For an example of the consumer's contract, see the consumer's tests. + """ + code, _ = verifier.verify_with_broker( + broker_url=str(broker), + published_verification_results=True, + provider_states_setup_url=str(PROVIDER_URL / "_pact" / "provider_states"), + ) + + assert code == 0 diff --git a/pyproject.toml b/pyproject.toml index bad6b3d48..5fb225b05 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,7 @@ pact-verifier = "pact.cli.verify:main" types = ["mypy ~= 1.1", "types-requests ~= 2.31"] test = [ "coverage[toml] ~= 7.3", + "flask[async] ~= 2.3", "httpx ~= 0.24", "mock ~= 5.1", "pytest ~= 7.4", @@ -61,7 +62,6 @@ dev = [ "pact-python[types]", "pact-python[test]", "black ~= 23.7", - "flask ~= 2.3", "ruff ~= 0.0", ] From 81ae787662754b6fede5a499a613460508bdb523 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Wed, 20 Sep 2023 13:20:06 +1000 Subject: [PATCH 5/9] chore(example): update readme Update the README for the examples to match the new structure of the examples. Signed-off-by: JP-Ellis --- examples/README.md | 344 +++++++++++++++++++-------------------------- 1 file changed, 143 insertions(+), 201 deletions(-) diff --git a/examples/README.md b/examples/README.md index 885cdc6dc..4520e8fb7 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,220 +1,162 @@ # Examples -## Table of Contents +This directory contains an end-to-end example of using Pact in Python. While +this document and the documentation within the examples themselves are intended +to be mostly self-contained, it is highly recommended that you read the [Pact +Documentation](https://docs.pact.io/) as well. - * [Overview](#overview) - * [broker](#broker) - * [common](#common) - * [consumer](#consumer) - * [flask_provider](#flask_provider) - * [fastapi_provider](#fastapi_provider) - * [message](#message) - * [pacts](#pacts) +Assuming you have [hatch](https://hatch.pypa.io/latest/) installed, the example +suite can be executed with: -## Overview - -Here you can find examples of how to use Pact using the python language. You can find more of an overview on Pact in the -[Pact Introduction]. - -Examples are given of both the [Consumer] and [Provider], this does not mean however that you must use python for both. -Different languages can be mixed and matched as required. - -In these examples, `1` is just used to meet the need of having *some* [Consumer] or [Provider] version. In reality, you -will generally want to use something more complicated and automated. Guidelines and best practices are available in the -[Versioning in the Pact Broker] - -## broker - -The [Pact Broker] stores [Pact file]s and [Pact verification] results. It is used here for the [consumer](#consumer), -[flask_provider](#flask-provider) and [message](#message) tests. - -### Running - -These examples run the [Pact Broker] as part of the tests when specified. It can be run outside the tests as well by -performing the following command from a separate terminal in the `examples/broker` folder: -```bash -docker-compose up +```sh +hatch run example ``` -You should then be able to open a browser and navigate to http://localhost where you will initially be able to see the -default Example App/Example API Pact. - -Running the [Pact Broker] outside the tests will mean you are able to then see the [Pact file]s submitted to the -[Pact Broker] as the various tests are performed. - -## common - -To avoid needing to duplicate certain fixtures, such as starting up a docker based Pact broker (to demonstrate how the -test process could work), the shared fixtures used by the pytests have all been placed into a single location.] -This means it is easier to see the relevant code for the example without having to go through the boilerplate fixtures. -See [Requiring/Loading plugins in a test module or conftest file] for further details of this approach. - -## consumer - -Pact is consumer-driven, which means first the contracts are created. These Pact contracts are generated during -execution of the consumer tests. - -### Running - -When the tests are run, the "minimum" is to generate the Pact contract JSON, additional options are available. The -following commands can be run from the `examples/consumer` folder: - -- Install any necessary dependencies: - ```bash - pip install -r requirements.txt - ``` -- To startup the broker, run the tests, and publish the results to the broker: - ```bash - pytest --run-broker True --publish-pact 1 - ``` -- Alternatively the same can be performed with the following command, which is called from a `make consumer`: - ```bash - ./run_pytest.sh - ``` -- To run the tests, and publish the results to the broker which is already running: - ```bash - pytest --publish-pact 1 - ``` -- To just run the tests: - ```bash - pytest - ``` - -### Output - -The following file(s) will be created when the tests are run: - -| Filename | Contents | -|---------------------------------------------| ----------| -| consumer/pact-mock-service.log | All interactions with the mock provider such as expected interactions, requests, and interaction verifications. | -| consumer/userserviceclient-userservice.json | This contains the Pact interactions between the `UserServiceClient` and `UserService`, as defined in the tests. The naming being derived from the named Pacticipants: `Consumer("UserServiceClient")` and `Provider("UserService")` | - -## flask_provider - -The Flask [Provider] example consists of a basic Flask app, with a single endpoint route. -This implements the service expected by the [consumer](#consumer). - -Functionally, this provides the same service and tests as the [fastapi_provider](#fastapi_provider). Both are included to -demonstrate how Pact can be used in different environments with different technology stacks and approaches. - -The [Provider] side is responsible for performing the tests to verify if it is compliant with the [Pact file] contracts -associated with it. - -As such, the tests use the pact-python Verifier to perform this verification. Two approaches are demonstrated: -- Testing against the [Pact broker]. Generally this is the preferred approach, see information on [Sharing Pacts]. -- Testing against the [Pact file] directly. If no [Pact broker] is available you can verify against a static [Pact file]. +The code within the examples is intended to be well documented and you are +encouraged to look through the code as well (or submit a PR if anything is +unclear!). -### Running +## Overview -To avoid package version conflicts with different applications, it is recommended to run these tests from a -[Virtual Environment] +Pact is a contract testing tool. Contract testing is a way to ensure that +services (such as an API provider and a client) can communicate with each other. +This example focuses on HTTP interactions, but Pact can be used to test more +general interactions as well such as through message queues. -The following commands can be run from within your [Virtual Environment], in the `examples/flask_provider`. +An interaction between a HTTP client (the _consumer_) and a server (the +_provider_) would typically look like this: -To perform the python tests: -```bash -pip install -r requirements.txt # Install the dependencies for the Flask example -pip install -e ../../ # Using setup.py in the pact-python root, install any pact dependencies and pact-python -./run_pytest.sh # Wrapper script to first run Flask, and then run the tests -``` +
-To perform verification using CLI to verify the [Pact file] against the Flask [Provider] instead of the python tests: -```bash -pip install -r requirements.txt # Install the dependencies for the Flask example -./verify_pact.sh # Wrapper script to first run Flask, and then use `pact-verifier` to verify locally +```mermaid +sequenceDiagram + participant Consumer + participant Provider + Consumer ->> Provider: GET /users/123 + Provider ->> Consumer: 200 OK + Consumer ->> Provider: GET /users/999 + Provider ->> Consumer: 404 Not Found ``` -To perform verification using CLI, but verifying the [Pact file] previously provided by a [Consumer], and publish the -results. This example requires that the [Pact broker] is already running, and the [Consumer] tests have been published -already, described in the [consumer](#consumer) section above. -```bash -pip install -r requirements.txt # Install the dependencies for the Flask example -./verify_pact.sh 1 # Wrapper script to first run Flask, and then use `pact-verifier` to verify and publish +
+ +To test this interaction naively would require both the consumer and provider to +be running at the same time. While this is straightforward in the above example, +this quickly becomes impractical as the number of interactions grows between +many microservices. Pact solves this by allowing the consumer and provider to be +tested independently. + +Pact achieves this be mocking the other side of the interaction: + +
+ +```mermaid +sequenceDiagram + box Consumer Side + participant Consumer + participant P1 as Pact + end + box Provider Side + participant P2 as Pact + participant Provider + end + Consumer->>P1: GET /users/123 + P1->>Consumer: 200 OK + Consumer->>P1: GET /users/999 + P1->>Consumer: 404 Not Found + + P1--)P2: Pact Broker + + P2->>Provider: GET /users/123 + Provider->>P2: 200 OK + P2->>Provider: GET /users/999 + Provider->>P2: 404 Not Found ``` -These examples demonstrate by first launching Flask via a `python -m flask run`, you may prefer to start Flask using an -`app.run()` call in the python code instead, see [How to Run a Flask Application]. Additionally for tests, you may want -to manage starting and stopping Flask as part of a fixture setup. Any approach can be chosen here, in line with your -existing Flask testing practices. - -### Output - -The following file(s) will be created when the tests are run - -| Filename | Contents | -|-----------------------------| ----------| -| flask_provider/log/pact.log | All Pact interactions with the Flask Provider. Every interaction example retrieved from the Pact Broker will be performed during the Verification test; the request/response logged here. | - -## fastapi_provider - -The FastAPI [Provider] example consists of a basic FastAPI app, with a single endpoint route. -This implements the service expected by the [consumer](#consumer). - -Functionally, this provides the same service and tests as the [flask_provider](#flask_provider). Both are included to -demonstrate how Pact can be used in different environments with different technology stacks and approaches. - -The [Provider] side is responsible for performing the tests to verify if it is compliant with the [Pact file] contracts -associated with it. - -As such, the tests use the pact-python Verifier to perform this verification. Two approaches are demonstrated: -- Testing against the [Pact broker]. Generally this is the preferred approach, see information on [Sharing Pacts]. -- Testing against the [Pact file] directly. If no [Pact broker] is available you can verify against a static [Pact file]. -- -### Running - -To avoid package version conflicts with different applications, it is recommended to run these tests from a -[Virtual Environment] - -The following commands can be run from within your [Virtual Environment], in the `examples/fastapi_provider`. - -To perform the python tests: -```bash -pip install -r requirements.txt # Install the dependencies for the FastAPI example -pip install -e ../../ # Using setup.py in the pact-python root, install any pact dependencies and pact-python -./run_pytest.sh # Wrapper script to first run FastAPI, and then run the tests -``` - -To perform verification using CLI to verify the [Pact file] against the FastAPI [Provider] instead of the python tests: -```bash -pip install -r requirements.txt # Install the dependencies for the FastAPI example -./verify_pact.sh # Wrapper script to first run FastAPI, and then use `pact-verifier` to verify locally +
+ +In the first stage, the consumer defines a number of interactions in the form +below. Pact sets up a mock server that will respond to the requests as defined +by the consumer. All these interactions, containing both the request and +expected response, are all sent to the Pact Broker. + +> Given {provider state} \ +> Upon receiving {description} \ +> With {request} \ +> Will respond with {response} + +In the second stage, the provider retrieves the interactions from the Pact +Broker. It then sets up a mock client that will make the requests as defined by +the consumer. Pact then verifies that the responses from the provider match the +expected responses defined by the consumer. + +In this way, Pact is consumer driven and can ensure that the provider is +compatible with the consumer. While this example showcases both sides in Python, +this is absolutely not required. The provider could be written in any language, +and satisfy contracts from a number of consumers all written in different +languages. + +### Consumer + +The consumer in this example is a simple Python script that makes a HTTP GET +request to a server. It is defined in [`src/consumer.py`](src/consumer.py). The +tests for the consumer are defined in +[`tests/test_00_consumer.py`](tests/test_00_consumer.py). Each interaction is +defined using the format mentioned above. Programmatically, this looks like: + +```py +expected: dict[str, Any] = { + "id": Format().integer, + "name": "Verna Hampton", + "created_on": Format().iso_8601_datetime(), +} +( + pact.given("user 123 exists") + .upon_receiving("a request for user 123") + .with_request("get", "/users/123") + .will_respond_with(200, body=Like(expected)) +) +# Code that makes the request to the server ``` -To perform verification using CLI, but verifying the [Pact file] previously provided by a [Consumer], and publish the -results. This example requires that the [Pact broker] is already running, and the [Consumer] tests have been published -already, described in the [consumer](#consumer) section above. -```bash -pip install -r requirements.txt # Install the dependencies for the FastAPI example -./verify_pact.sh 1 # Wrapper script to first run FastAPI, and then use `pact-verifier` to verify and publish +### Provider + +This example showcases to different providers, one written in Flask and one +written in FastAPI. Both are simple Python web servers that respond to a HTTP +GET request. The Flask provider is defined in [`src/flask.py`](src/flask.py) and +the FastAPI provider is defined in [`src/fastapi.py`](src/fastapi.py). The +tests for the providers are defined in +[`tests/test_01_provider_flask.py`](tests/test_01_provider_flask.py) and +[`tests/test_01_provider_fastapi.py`](tests/test_01_provider_fastapi.py). + +Unlike the consumer side, the provider side is responsible to responding to the +interactions defined by the consumers. In this regard, the provider testing +is rather simple: + +```py +code, _ = verifier.verify_with_broker( + broker_url=str(broker), + published_verification_results=True, + provider_states_setup_url=str(PROVIDER_URL / "_pact" / "provider_states"), +) +assert code == 0 ``` -### Output - -The following file(s) will be created when the tests are run - -| Filename | Contents | -|-------------------------------| ----------| -| fastapi_provider/log/pact.log | All Pact interactions with the FastAPI Provider. Every interaction example retrieved from the Pact Broker will be performed during the Verification test; the request/response logged here. | - - -## message - -TODO - -## pacts - -Both the Flask and the FastAPI [Provider] examples implement the same service the [Consumer] example interacts with. -This folder contains the generated [Pact file] for reference, which is also used when running the [Provider] tests -without a [Pact Broker]. - -[Pact Broker]: https://docs.pact.io/pact_broker -[Pact Introduction]: https://docs.pact.io/ -[Consumer]: https://docs.pact.io/getting_started/terminology#service-consumer -[Provider]: https://docs.pact.io/getting_started/terminology#service-provider -[Versioning in the Pact Broker]: https://docs.pact.io/getting_started/versioning_in_the_pact_broker/ -[Pact file]: https://docs.pact.io/getting_started/terminology#pact-file -[Pact verification]: https://docs.pact.io/getting_started/terminology#pact-verification] -[Virtual Environment]: https://docs.python.org/3/tutorial/venv.html -[Sharing Pacts]: https://docs.pact.io/getting_started/sharing_pacts/] -[How to Run a Flask Application]: https://www.twilio.com/blog/how-run-flask-application -[Requiring/Loading plugins in a test module or conftest file]: https://docs.pytest.org/en/6.2.x/writing_plugins.html#requiring-loading-plugins-in-a-test-module-or-conftest-file +The complication comes from the fact that the provider needs to know what state +to be in before responding to the request. In order to achieve this, a testing +endpoint is defined that sets the state of the provider as defined in the +`provider_states_setup_url` above. For example, the consumer requests has _Given +user 123 exists_ as the provider state, and the provider will need to ensure +that this state is satisfied. This would typically entail setting up a database +with the correct data, but it is advisable to achieve the equivalent state by +mocking the appropriate calls. This has been showcased in both provider +examples. + +### Broker + +The broker acts as the intermediary between these test suites. It stores the +interactions defined by the consumer and makes them available to the provider. +Once the provider has verified that it satisfies all interactions, the broker +also stores the verification results. The example here runs the open source +broker within a Docker container. An alternative is to use the hosted [Pactflow +service](https://pactflow.io). From e2b6389b50b72f4602301ff10797df18433ab362 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Wed, 20 Sep 2023 13:47:26 +1000 Subject: [PATCH 6/9] chore(example): migrate message pact example Migrate the old isolated example and combine the test with the other examples so that they can all be run at once. Massive thanks to @YOU54F for identifying the switch up between the `given` and `expected`! Signed-off-by: JP-Ellis --- examples/message/README.md | 246 ------------------ examples/message/conftest.py | 8 - examples/message/requirements.txt | 6 - examples/message/run_pytest.sh | 7 - examples/message/src/message_handler.py | 19 -- examples/message/tests/consumer/__init__.py | 0 .../tests/consumer/test_message_consumer.py | 156 ----------- examples/message/tests/provider/__init__.py | 0 .../tests/provider/test_message_provider.py | 83 ------ examples/src/message.py | 96 +++++++ examples/tests/test_02_message_consumer.py | 128 +++++++++ examples/tests/test_03_message_provider.py | 59 +++++ 12 files changed, 283 insertions(+), 525 deletions(-) delete mode 100644 examples/message/README.md delete mode 100644 examples/message/conftest.py delete mode 100644 examples/message/requirements.txt delete mode 100755 examples/message/run_pytest.sh delete mode 100644 examples/message/src/message_handler.py delete mode 100644 examples/message/tests/consumer/__init__.py delete mode 100644 examples/message/tests/consumer/test_message_consumer.py delete mode 100644 examples/message/tests/provider/__init__.py delete mode 100644 examples/message/tests/provider/test_message_provider.py create mode 100644 examples/src/message.py create mode 100644 examples/tests/test_02_message_consumer.py create mode 100644 examples/tests/test_03_message_provider.py diff --git a/examples/message/README.md b/examples/message/README.md deleted file mode 100644 index 13c738057..000000000 --- a/examples/message/README.md +++ /dev/null @@ -1,246 +0,0 @@ -# Introduction - -This is an e2e example that uses messages, including a sample implementation of a message handler. - -## Consumer - -A Consumer is the system that will be reading a message from a queue or some intermediary. In this example, the consumer is a Lambda function that handles the message. - -From a Pact testing point of view, Pact takes the place of the intermediary (MQ/broker etc.) and confirms whether or not the consumer is able to handle a request. - -``` -+-----------+ +-------------------+ -| (Pact) | message |(Message Consumer) | -| MQ/broker |--------->|Lambda Function | -| | |check valid doc | -+-----------+ +-------------------+ -``` - -Below is a sample message handler that only accepts that the key `documentType` would only be `microsoft-word`. If not, the message handler will throw an exception (`CustomError`) - -```python -class CustomError(Exception): - def __init__(self, *args): - if args: - self.topic = args[0] - else: - self.topic = None - - def __str__(self): - if self.topic: - return 'Custom Error:, {0}'.format(self.topic) - -class MessageHandler(object): - def __init__(self, event): - self.pass_event(event) - - @staticmethod - def pass_event(event): - if event.get('documentType') != 'microsoft-word': - raise CustomError("Not correct document type") -``` - -Below is a snippet from a test where the message handler has no error. -Since the expected event contains a key `documentType` with value `microsoft-word`, message handler does not throw an error and a pact file `f"{PACT_FILE}""` is expected to be generated. - -```python -def test_generate_new_pact_file(pact): - cleanup_json(PACT_FILE) - - expected_event = { - 'documentName': 'document.doc', - 'creator': 'TP', - 'documentType': 'microsoft-word' - } - - (pact - .given('A document create in Document Service') - .expects_to_receive('Description') - .with_content(expected_event) - .with_metadata({ - 'Content-Type': 'application/json' - })) - - with pact: - # handler needs 'documentType' == 'microsoft-word' - MessageHandler(expected_event) - - progressive_delay(f"{PACT_FILE}") - assert isfile(f"{PACT_FILE}") == 1 -``` - -For a similar test where the event does not contain a key `documentType` with value `microsoft-word`, a `CustomError` is generated and there there is no generated json file `f"{PACT_FILE}"`. - -```python -def test_throw_exception_handler(pact): - cleanup_json(PACT_FILE) - wrong_event = { - 'documentName': 'spreadsheet.xls', - 'creator': 'WI', - 'documentType': 'microsoft-excel' - } - - (pact - .given('Another document in Document Service') - .expects_to_receive('Description') - .with_content(wrong_event) - .with_metadata({ - 'Content-Type': 'application/json' - })) - - with pytest.raises(CustomError): - with pact: - # handler needs 'documentType' == 'microsoft-word' - MessageHandler(wrong_event) - - progressive_delay(f"{PACT_FILE}") - assert isfile(f"{PACT_FILE}") == 0 -``` - -## Provider - -``` -+-------------------+ +-----------+ -|(Message Provider) | message | (Pact) | -|Document Upload |--------->| MQ/broker | -|Service | | | -+-------------------+ +-----------+ -``` - -```python -import pytest -from pact import MessageProvider - -def document_created_handler(): - return { - "event": "ObjectCreated:Put", - "documentName": "document.doc", - "creator": "TP", - "documentType": "microsoft-word" - } - - -def test_verify_success(): - provider = MessageProvider( - message_providers={ - 'A document created successfully': document_created_handler - }, - provider='ContentProvider', - consumer='DetectContentLambda', - pact_dir='pacts' - - ) - with provider: - provider.verify() -``` - - -### Provider with pact broker -```python -import pytest -from pact import MessageProvider - - -PACT_BROKER_URL = "http://localhost" -PACT_BROKER_USERNAME = "pactbroker" -PACT_BROKER_PASSWORD = "pactbroker" -PACT_DIR = "pacts" - - -@pytest.fixture -def default_opts(): - return { - 'broker_username': PACT_BROKER_USERNAME, - 'broker_password': PACT_BROKER_PASSWORD, - 'broker_url': PACT_BROKER_URL, - 'publish_version': '3', - 'publish_verification_results': False - } - -def document_created_handler(): - return { - "event": "ObjectCreated:Put", - "documentName": "document.doc", - "creator": "TP", - "documentType": "microsoft-word" - } - -def test_verify_from_broker(default_opts): - provider = MessageProvider( - message_providers={ - 'A document created successfully': document_created_handler, - }, - provider='ContentProvider', - consumer='DetectContentLambda', - pact_dir='pacts' - - ) - - with pytest.raises(AssertionError): - with provider: - provider.verify_with_broker(**default_opts) - -``` - -## E2E Messaging - -``` -+-------------------+ +-----------+ +-------------------+ -|(Message Provider) | message | (Pact) | message |(Message Consumer) | -|Document Upload |--------->| MQ/broker |--------->|Lambda Function | -|Service | | | |check valid doc | -+-------------------+ +-----------+ +-------------------+ -``` - -# Setup - -## Virtual Environment - -Go to the `example/message` directory Create your own virtualenv for this. Run - -```bash -pip install -r requirements.txt -pip install -e ../../ -pytest -``` - -## Message Consumer - -From the root directory run: - -```bash -pytest -``` - -Or you can run individual tests like: - -```bash -pytest tests/consumer/test_message_consumer.py::test_generate_new_pact_file -``` - -## With Broker - -The current consumer test can run even without a local broker, -but this is added for demo purposes. - -Open a separate terminal in the `examples/broker` folder and run: - -```bash -docker-compose up -``` - -Open a browser to http://localhost and see the broker you have succeeded. -If needed, log-in using the provided details in tests such as: - -``` -PACT_BROKER_USERNAME = "pactbroker" -PACT_BROKER_PASSWORD = "pactbroker" -``` - -To get the consumer to publish a pact to broker, -open a new terminal in the `examples/message` and run the following (2 is an arbitary version number). The first part makes sure that the an existing json has been generated: - -```bash -pytest tests/consumer/test_message_consumer.py::test_publish_to_broker -pytest tests/consumer/test_message_consumer.py::test_publish_to_broker --publish-pact 2 -``` diff --git a/examples/message/conftest.py b/examples/message/conftest.py deleted file mode 100644 index 90ac63b9a..000000000 --- a/examples/message/conftest.py +++ /dev/null @@ -1,8 +0,0 @@ -import sys - -# Load in the fixtures from common/sharedfixtures.py -sys.path.append("../common") - -pytest_plugins = [ - "sharedfixtures", -] diff --git a/examples/message/requirements.txt b/examples/message/requirements.txt deleted file mode 100644 index 021958ce1..000000000 --- a/examples/message/requirements.txt +++ /dev/null @@ -1,6 +0,0 @@ -Flask==2.0.3; python_version < '3.7' -Flask==2.2.5; python_version >= '3.7' -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' diff --git a/examples/message/run_pytest.sh b/examples/message/run_pytest.sh deleted file mode 100755 index 35dade4fb..000000000 --- a/examples/message/run_pytest.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/bash -set -o pipefail - -pytest --run-broker True --publish-pact 2 - -# 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/src/message_handler.py b/examples/message/src/message_handler.py deleted file mode 100644 index 1be2b4641..000000000 --- a/examples/message/src/message_handler.py +++ /dev/null @@ -1,19 +0,0 @@ -class CustomError(Exception): - def __init__(self, *args): - if args: - self.topic = args[0] - else: - self.topic = None - - def __str__(self): - if self.topic: - return 'Custom Error:, {0}'.format(self.topic) - -class MessageHandler(object): - def __init__(self, event): - self.pass_event(event) - - @staticmethod - def pass_event(event): - if event.get('documentType') != 'microsoft-word': - raise CustomError("Not correct document type") diff --git a/examples/message/tests/consumer/__init__.py b/examples/message/tests/consumer/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/examples/message/tests/consumer/test_message_consumer.py b/examples/message/tests/consumer/test_message_consumer.py deleted file mode 100644 index 3c7de4c1a..000000000 --- a/examples/message/tests/consumer/test_message_consumer.py +++ /dev/null @@ -1,156 +0,0 @@ -"""pact test for a message consumer""" - -import logging -import pytest -import time - -from os import remove -from os.path import isfile - -from pact import MessageConsumer, Provider -from src.message_handler import MessageHandler, CustomError - -log = logging.getLogger(__name__) -logging.basicConfig(level=logging.INFO) - -PACT_BROKER_URL = "http://localhost" -PACT_BROKER_USERNAME = "pactbroker" -PACT_BROKER_PASSWORD = "pactbroker" -PACT_DIR = "pacts" - -CONSUMER_NAME = "DetectContentLambda" -PROVIDER_NAME = "ContentProvider" -PACT_FILE = (f"{PACT_DIR}/{CONSUMER_NAME.lower().replace(' ', '_')}-" - + f"{PROVIDER_NAME.lower().replace(' ', '_')}.json") - - -@pytest.fixture(scope="session") -def pact(request): - version = request.config.getoption("--publish-pact") - publish = True if version else False - - pact = MessageConsumer(CONSUMER_NAME, version=version).has_pact_with( - Provider(PROVIDER_NAME), - publish_to_broker=publish, broker_base_url=PACT_BROKER_URL, - broker_username=PACT_BROKER_USERNAME, broker_password=PACT_BROKER_PASSWORD, pact_dir=PACT_DIR) - - yield pact - - -@pytest.fixture(scope="session") -def pact_no_publish(request): - version = request.config.getoption("--publish-pact") - pact = MessageConsumer(CONSUMER_NAME, version=version).has_pact_with( - Provider(PROVIDER_NAME), - publish_to_broker=False, broker_base_url=PACT_BROKER_URL, - broker_username=PACT_BROKER_USERNAME, broker_password=PACT_BROKER_PASSWORD, pact_dir=PACT_DIR) - - yield pact - -def cleanup_json(file): - """ - Remove existing json file before test if any - """ - if (isfile(f"{file}")): - remove(f"{file}") - - -def progressive_delay(file, time_to_wait=10, second_interval=0.5, verbose=False): - """ - progressive delay - defaults to wait up to 5 seconds with 0.5 second intervals - """ - time_counter = 0 - while not isfile(file): - time.sleep(second_interval) - time_counter += 1 - if verbose: - print(f"Trying for {time_counter*second_interval} seconds") - if time_counter > time_to_wait: - if verbose: - print(f"Already waited {time_counter*second_interval} seconds") - break - - -def test_throw_exception_handler(pact_no_publish): - cleanup_json(PACT_FILE) - - wrong_event = { - "event": "ObjectCreated:Put", - "documentName": "spreadsheet.xls", - "creator": "WI", - "documentType": "microsoft-excel" - } - - (pact_no_publish - .given("Document unsupported type") - .expects_to_receive("Description") - .with_content(wrong_event) - .with_metadata({ - "Content-Type": "application/json" - })) - - with pytest.raises(CustomError): - with pact_no_publish: - # handler needs "documentType" == "microsoft-word" - MessageHandler(wrong_event) - - progressive_delay(f"{PACT_FILE}") - assert isfile(f"{PACT_FILE}") == 0 - - -def test_put_file(pact_no_publish): - cleanup_json(PACT_FILE) - - expected_event = { - "event": "ObjectCreated:Put", - "documentName": "document.doc", - "creator": "TP", - "documentType": "microsoft-word" - } - - (pact_no_publish - .given("A document created successfully") - .expects_to_receive("Description") - .with_content(expected_event) - .with_metadata({ - "Content-Type": "application/json" - })) - - with pact_no_publish: - MessageHandler(expected_event) - - progressive_delay(f"{PACT_FILE}") - assert isfile(f"{PACT_FILE}") == 1 - - -def test_publish_to_broker(pact): - """ - This test does not clean-up previously generated pact. - Sample execution where 2 is an arbitrary version: - - `pytest tests/consumer/test_message_consumer.py::test_publish_pact_to_broker` - - `pytest tests/consumer/test_message_consumer.py::test_publish_pact_to_broker --publish-pact 2` - """ - - expected_event = { - "event": "ObjectCreated:Delete", - "documentName": "document.doc", - "creator": "TP", - "documentType": "microsoft-word" - } - - (pact - .given("A document deleted successfully") - .expects_to_receive("Description with broker") - .with_content(expected_event) - .with_metadata({ - "Content-Type": "application/json" - })) - - with pact: - MessageHandler(expected_event) - - progressive_delay(f"{PACT_FILE}") - assert isfile(f"{PACT_FILE}") == 1 diff --git a/examples/message/tests/provider/__init__.py b/examples/message/tests/provider/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/examples/message/tests/provider/test_message_provider.py b/examples/message/tests/provider/test_message_provider.py deleted file mode 100644 index ae20ab3a1..000000000 --- a/examples/message/tests/provider/test_message_provider.py +++ /dev/null @@ -1,83 +0,0 @@ -import pytest -from pact import MessageProvider - -PACT_BROKER_URL = "http://localhost" -PACT_BROKER_USERNAME = "pactbroker" -PACT_BROKER_PASSWORD = "pactbroker" -PACT_DIR = "pacts" - - -@pytest.fixture -def default_opts(): - return { - 'broker_username': PACT_BROKER_USERNAME, - 'broker_password': PACT_BROKER_PASSWORD, - 'broker_url': PACT_BROKER_URL, - 'publish_version': '3', - 'publish_verification_results': False - } - - -def document_created_handler(): - return { - "event": "ObjectCreated:Put", - "documentName": "document.doc", - "creator": "TP", - "documentType": "microsoft-word" - } - - -def document_deleted_handler(): - return { - "event": "ObjectCreated:Delete", - "documentName": "document.doc", - "creator": "TP", - "documentType": "microsoft-word" - } - - -def test_verify_success(): - provider = MessageProvider( - message_providers={ - 'A document created successfully': document_created_handler, - 'A document deleted successfully': document_deleted_handler - }, - provider='ContentProvider', - consumer='DetectContentLambda', - pact_dir='pacts' - - ) - with provider: - provider.verify() - - -def test_verify_failure_when_a_provider_missing(): - provider = MessageProvider( - message_providers={ - 'A document created successfully': document_created_handler, - }, - provider='ContentProvider', - consumer='DetectContentLambda', - pact_dir='pacts' - - ) - - with pytest.raises(AssertionError): - with provider: - provider.verify() - - -def test_verify_from_broker(default_opts): - provider = MessageProvider( - message_providers={ - 'A document created successfully': document_created_handler, - 'A document deleted successfully': document_deleted_handler - }, - provider='ContentProvider', - consumer='DetectContentLambda', - pact_dir='pacts' - - ) - - with provider: - provider.verify_with_broker(**default_opts) diff --git a/examples/src/message.py b/examples/src/message.py new file mode 100644 index 000000000..6a617b271 --- /dev/null +++ b/examples/src/message.py @@ -0,0 +1,96 @@ +""" +Handler for non-HTTP interactions. + +This module implements a very basic handler to handle JSON payloads which might +be sent from Kafka, or some queueing system. Unlike a HTTP interaction, the +handler is solely responsible for processing the message, and does not +necessarily need to send a response. +""" +from __future__ import annotations + +from pathlib import Path +from typing import Any + + +class Filesystem: + """Filesystem interface.""" + + def __init__(self) -> None: + """Initialize the filesystem connection.""" + + def write(self, _file: str, _contents: str) -> None: + """Write contents to a file.""" + raise NotImplementedError + + def read(self, file: str) -> str: + """Read contents from a file.""" + raise NotImplementedError + + +class Handler: + """ + Message queue handler. + + This class is responsible for handling messages from the queue. + """ + + def __init__(self) -> None: + """ + Initialize the handler. + + This ensures the underlying filesystem is ready to be used. + """ + self.fs = Filesystem() + + def process(self, event: dict[str, Any]) -> str | None: + """ + Process an event from the queue. + + The event is expected to be a dictionary with the following mandatory + keys: + + - `action`: The action to be performed, either `READ` or `WRITE`. + - `path`: The path to the file to be read or written. + + The event may also contain an optional `contents` key, which is the + contents to be written to the file. If the `contents` key is not + present, an empty file will be written. + """ + self.validate_event(event) + + if event["action"] == "WRITE": + return self.fs.write(event["path"], event.get("contents", "")) + if event["action"] == "READ": + return self.fs.read(event["path"]) + + msg = "Invalid action." + raise ValueError(msg) + + @staticmethod + def validate_event(event: dict[str, Any] | Any) -> None: # noqa: ANN401 + """ + Validates the event received from the queue. + + The event is expected to be a dictionary with the following mandatory + keys: + + - `action`: The action to be performed, either `READ` or `WRITe`. + - `path`: The path to the file to be read or written. + """ + if not isinstance(event, dict): + msg = "Event must be a dictionary." + raise TypeError(msg) + if "action" not in event: + msg = "Event must contain an 'action' key." + raise ValueError(msg) + if "path" not in event: + msg = "Event must contain a 'path' key." + raise ValueError(msg) + if event["action"] not in ["READ", "WRITE"]: + msg = "Event must contain a valid 'action' key." + raise ValueError(msg) + try: + Path(event["path"]) + except TypeError as err: + msg = "Event must contain a valid 'path' key." + raise ValueError(msg) from err diff --git a/examples/tests/test_02_message_consumer.py b/examples/tests/test_02_message_consumer.py new file mode 100644 index 000000000..7a5c269aa --- /dev/null +++ b/examples/tests/test_02_message_consumer.py @@ -0,0 +1,128 @@ +""" +Test Message Pact consumer. + +Pact was originally designed for HTTP interactions involving a request and a +response. Message Pact is an addition to Pact that allows for testing of +non-HTTP interactions, such as message queues. This example demonstrates how to +use Message Pact to test whether a consumer can handle the messages it. + +A note on terminology, the _consumer_ for Message Pact is the system that +receives the message, and the _provider_ is the system that sends the message. +Pact is still consumer-driven, and the consumer defines the expected messages it +will receive from the provider. When the provider is being verified, Pact +ensures that the provider sends the expected messages. + +In this example, Pact simply ensures that the consumer is capable of processing +the message. The consumer need not send back a message, and any sideffects of +the message must be verified separately (such as through `assert` statements). + +> :warning: There is currently a bug whereby the `given` and +`expects_to_receive` have swapped meanings. This will be addressed in a future +release. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any, Generator +from unittest.mock import MagicMock + +import pytest +from pact import MessageConsumer, MessagePact, Provider + +from src.message import Handler + +if TYPE_CHECKING: + from pathlib import Path + + from yarl import URL + +log = logging.getLogger(__name__) + + +@pytest.fixture(scope="module") +def pact(broker: URL, pact_dir: Path) -> Generator[MessagePact, Any, None]: + """ + Set up Message Pact Consumer. + + This fixtures sets up the Message Pact consumer and the pact it has with a + provider. The consumer defines the expected messages it will receive from + the provider, and the Python test suite verifies that the correct actions + are taken. + + For each interaction, the consumer defines the following: + + ```python + ( + pact.given("a request to write test.txt") + .expects_to_receive("empty filesystem") + .with_content(msg) + .with_metadata({"Content-Type": "application/json"}) + ) + + NOTE: There is currently a bug whereby the `given` and `expects_to_receive` + have swapped meanings. This will be addressed in a future release. + ``` + """ + consumer = MessageConsumer("MessageConsumer") + pact = consumer.has_pact_with( + Provider("MessageProvider"), + pact_dir=pact_dir, + publish_to_broker=True, + # Broker configuration + broker_base_url=str(broker), + broker_username=broker.user, + broker_password=broker.password, + ) + with pact: + yield pact + + +@pytest.fixture() +def handler() -> Handler: + """ + Fixture for the Handler. + + This fixture mocks the filesystem calls in the handler, so that we can + verify that the handler is calling the filesystem correctly. + """ + handler = Handler() + handler.fs = MagicMock() + handler.fs.write.return_value = None + handler.fs.read.return_value = "Hello world!" + return handler + + +def test_write_file(pact: MessagePact, handler: Handler) -> None: + """ + Test write file. + + This test will be run against the mock provider. The mock provider will + expect to receive a request to write a file, and will respond with a 200 + status code. + """ + msg = {"action": "WRITE", "path": "test.txt", "contents": "Hello world!"} + ( + pact.given("a request to write test.txt") + .expects_to_receive("empty filesystem") + .with_content(msg) + .with_metadata({"Content-Type": "application/json"}) + ) + + result = handler.process(msg) + handler.fs.write.assert_called_once_with("test.txt", "Hello world!") + assert result is None + + +def test_read_file(pact: MessagePact, handler: Handler) -> None: + msg = {"action": "READ", "path": "test.txt"} + ( + pact.given("a request to read test.txt") + .expects_to_receive("test.txt exists") + .with_content(msg) + .with_metadata({"Content-Type": "application/json"}) + ) + + result = handler.process(msg) + handler.fs.read.assert_called_once_with("test.txt") + assert result == "Hello world!" diff --git a/examples/tests/test_03_message_provider.py b/examples/tests/test_03_message_provider.py new file mode 100644 index 000000000..4f4570a63 --- /dev/null +++ b/examples/tests/test_03_message_provider.py @@ -0,0 +1,59 @@ +""" +Test Message Pact provider. + +Unlike the standard Pact, which is designed for HTTP interactions, the Message +Pact is designed for non-HTTP interactions. This example demonstrates how to use +the Message Pact to test whether a provider generates the correct messages. + +In such examples, Pact simply checks the kind of messages produced. The consumer +need not send back a message, and any sideffects of the message must be verified +separately. + +The below example verifies that the consumer makes the correct filesystem calls +when it receives a message to read or write a file. The calls themselves are +mocked out so as to avoid actually writing to the filesystem. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +from flask import Flask +from pact import MessageProvider + +if TYPE_CHECKING: + from yarl import URL + +app = Flask(__name__) +PACT_DIR = (Path(__file__).parent / "pacts").resolve() + + +def generate_write_message() -> dict[str, str]: + return { + "action": "WRITE", + "path": "test.txt", + "contents": "Hello world!", + } + + +def generate_read_message() -> dict[str, str]: + return { + "action": "READ", + "path": "test.txt", + } + + +def test_verify(broker: URL) -> None: + provider = MessageProvider( + provider="MessageProvider", + consumer="MessageConsumer", + pact_dir=str(PACT_DIR), + message_providers={ + "a request to write test.txt": generate_write_message, + "a request to read test.txt": generate_read_message, + }, + ) + + with provider: + provider.verify_with_broker(broker_url=str(broker)) From c8e0e7fa964c14a965f61863653e11575f0af728 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Thu, 21 Sep 2023 12:46:44 +1000 Subject: [PATCH 7/9] chore(ci): split tests examples and lints Try splitting the CI workflow to make use of services Signed-off-by: JP-Ellis --- .github/workflows/test.yml | 84 +++++++++++++++++++++++++++++++++----- 1 file changed, 73 insertions(+), 11 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ea1ae8d5c..def2b9d3a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,12 +14,12 @@ concurrency: env: STABLE_PYTHON_VERSION: "3.11" + PYTEST_ADDOPTS: --color=yes jobs: - run: + test: name: >- - Python ${{ matrix.python-version }} - on ${{ matrix.os }} + Tests py${{ matrix.python-version }} on ${{ matrix.os }} runs-on: ${{ matrix.os }} continue-on-error: ${{ matrix.experimental }} @@ -47,14 +47,76 @@ jobs: - name: Install Hatch run: pip install --upgrade hatch - - # TODO: Fix lints before enabling this - name: Lint - if: matrix.python-version == env.STABLE_PYTHON_VERSION && runner.os == 'Linux' - run: echo hatch run lint + - name: Run tests + run: hatch run test + + - name: Upload coverage + # TODO: Configure code coverage monitoring + if: false && matrix.python-version == env.STABLE_PYTHON_VERSION && matrix.os == 'ubuntu-latest' + uses: codecov/codecov-action@v2 + with: + token: ${{ secrets.CODECOV_TOKEN }} + + example: + name: Example + + runs-on: ubuntu-latest + services: + broker: + image: pactfoundation/pact-broker:latest + ports: + - "9292:9292" + env: + # Basic auth credentials for the Broker + PACT_BROKER_ALLOW_PUBLIC_READ: "true" + PACT_BROKER_BASIC_AUTH_USERNAME: pactbroker + PACT_BROKER_BASIC_AUTH_PASSWORD: pactbroker + # Database + PACT_BROKER_DATABASE_URL: sqlite:////tmp/pact_broker.sqlite + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python 3 + uses: actions/setup-python@v4 + with: + python-version: ${{ env.STABLE_PYTHON_VERSION }} + + - name: Install Hatch + run: pip install --upgrade hatch + + - name: Ensure broker is live + run: | + i=0 + until curl -sSf http://localhost:9292/diagnostic/status/heartbeat; do + i=$((i+1)) + if [ $i -gt 120 ]; then + echo "Broker failed to start" + exit 1 + fi + sleep 1 + done - name: Examples - if: matrix.python-version == env.STABLE_PYTHON_VERSION && runner.os == 'Linux' - run: hatch run example --color=yes --capture=no + run: > + hatch run example --broker-url=http://pactbroker:pactbroker@localhost:9292 - - name: Run tests and track code coverage - run: hatch run test + # TODO: Fix lints before enabling this + # lint: + # name: Lint + + # runs-on: ubuntu-latest + + # steps: + # - uses: actions/checkout@v4 + + # - name: Set up Python + # uses: actions/setup-python@v4 + # with: + # python-version: ${{ env.STABLE_PYTHON_VERSION }} + + # - name: Install Hatch + # run: pip install --upgrade hatch + + # - name: Lint + # run: hatch run lint From f9d2024b19237f1005bee592691ecf237270093e Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Fri, 22 Sep 2023 13:28:26 +1000 Subject: [PATCH 8/9] chore(example): avoid changing python path Initially, when having the tests completely separate, the examples were scoped to their own space and required setting `PYTHONPATH` to work. This is not needed if we change `import src.{mod}` to `import examples.src.{mod}`. Signed-off-by: JP-Ellis --- examples/tests/test_00_consumer.py | 3 +-- examples/tests/test_01_provider_fastapi.py | 15 +++++++-------- examples/tests/test_01_provider_flask.py | 15 +++++++-------- examples/tests/test_02_message_consumer.py | 3 +-- pyproject.toml | 2 +- 5 files changed, 17 insertions(+), 21 deletions(-) diff --git a/examples/tests/test_00_consumer.py b/examples/tests/test_00_consumer.py index 029550714..af3220fb9 100644 --- a/examples/tests/test_00_consumer.py +++ b/examples/tests/test_00_consumer.py @@ -17,11 +17,10 @@ import pytest import requests +from examples.src.consumer import User, UserConsumer from pact import Consumer, Format, Like, Provider from yarl import URL -from src.consumer import User, UserConsumer - if TYPE_CHECKING: from pathlib import Path diff --git a/examples/tests/test_01_provider_fastapi.py b/examples/tests/test_01_provider_fastapi.py index ce05964f0..75503b18d 100644 --- a/examples/tests/test_01_provider_fastapi.py +++ b/examples/tests/test_01_provider_fastapi.py @@ -26,12 +26,11 @@ import pytest import uvicorn +from examples.src.fastapi import app from pact import Verifier from pydantic import BaseModel from yarl import URL -from src.fastapi import app - PROVIDER_URL = URL("http://localhost:8080") @@ -91,10 +90,10 @@ def verifier() -> Generator[Verifier, Any, None]: def mock_user_123_doesnt_exist() -> None: """Mock the database for the user 123 doesn't exist state.""" - import src.fastapi + import examples.src.fastapi - src.fastapi.FAKE_DB = MagicMock() - src.fastapi.FAKE_DB.get.return_value = None + examples.src.fastapi.FAKE_DB = MagicMock() + examples.src.fastapi.FAKE_DB.get.return_value = None def mock_user_123_exists() -> None: @@ -108,10 +107,10 @@ def mock_user_123_exists() -> None: By using consumer-driven contracts and testing the provider against the consumer's contract, we can ensure that the provider is only providing what """ - import src.fastapi + import examples.src.fastapi - src.fastapi.FAKE_DB = MagicMock() - src.fastapi.FAKE_DB.get.return_value = { + examples.src.fastapi.FAKE_DB = MagicMock() + examples.src.fastapi.FAKE_DB.get.return_value = { "id": 123, "name": "Verna Hampton", "created_on": "2016-12-15T20:16:01", diff --git a/examples/tests/test_01_provider_flask.py b/examples/tests/test_01_provider_flask.py index 1aff011e3..532057a4d 100644 --- a/examples/tests/test_01_provider_flask.py +++ b/examples/tests/test_01_provider_flask.py @@ -26,12 +26,11 @@ from unittest.mock import MagicMock import pytest +from examples.src.flask import app from flask import request from pact import Verifier from yarl import URL -from src.flask import app - PROVIDER_URL = URL("http://localhost:8080") @@ -84,10 +83,10 @@ def verifier() -> Generator[Verifier, Any, None]: def mock_user_123_doesnt_exist() -> None: """Mock the database for the user 123 doesn't exist state.""" - import src.flask + import examples.src.flask - src.flask.FAKE_DB = MagicMock() - src.flask.FAKE_DB.get.return_value = None + examples.src.flask.FAKE_DB = MagicMock() + examples.src.flask.FAKE_DB.get.return_value = None def mock_user_123_exists() -> None: @@ -101,10 +100,10 @@ def mock_user_123_exists() -> None: By using consumer-driven contracts and testing the provider against the consumer's contract, we can ensure that the provider is only providing what """ - import src.flask + import examples.src.flask - src.flask.FAKE_DB = MagicMock() - src.flask.FAKE_DB.get.return_value = { + examples.src.flask.FAKE_DB = MagicMock() + examples.src.flask.FAKE_DB.get.return_value = { "id": 123, "name": "Verna Hampton", "created_on": "2016-12-15T20:16:01", diff --git a/examples/tests/test_02_message_consumer.py b/examples/tests/test_02_message_consumer.py index 7a5c269aa..73457198a 100644 --- a/examples/tests/test_02_message_consumer.py +++ b/examples/tests/test_02_message_consumer.py @@ -28,10 +28,9 @@ from unittest.mock import MagicMock import pytest +from examples.src.message import Handler from pact import MessageConsumer, MessagePact, Provider -from src.message import Handler - if TYPE_CHECKING: from pathlib import Path diff --git a/pyproject.toml b/pyproject.toml index 5fb225b05..fdb53cb32 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -105,7 +105,7 @@ extra-dependencies = ["hatchling", "packaging", "requests"] [tool.hatch.envs.default.scripts] lint = ["black --check --diff {args:.}", "ruff {args:.}", "mypy {args:.}"] test = "pytest --cov-config=pyproject.toml --cov=pact --cov=tests tests/" -example = "PYTHONPATH=examples pytest examples/ {args}" +example = "pytest examples/ {args}" all = ["lint", "tests"] # Test environment for running unit tests. This automatically tests against all From 848fda18d66763b74cb856831eabe1597bbd76c3 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Wed, 27 Sep 2023 10:31:56 +1000 Subject: [PATCH 9/9] chore: address pr comments Signed-off-by: JP-Ellis --- Makefile | 111 ++++++--------------- examples/conftest.py | 4 +- examples/docker-compose.yml | 1 + examples/src/consumer.py | 9 +- examples/src/fastapi.py | 18 ++-- examples/src/flask.py | 14 +-- examples/src/message.py | 6 +- examples/tests/test_00_consumer.py | 19 +++- examples/tests/test_01_provider_fastapi.py | 17 +++- examples/tests/test_01_provider_flask.py | 15 ++- examples/tests/test_02_message_consumer.py | 15 ++- examples/tests/test_03_message_provider.py | 33 +++--- hatch_build.py | 4 +- pyproject.toml | 9 +- 14 files changed, 139 insertions(+), 136 deletions(-) diff --git a/Makefile b/Makefile index cdf2c9100..aa7981fb1 100644 --- a/Makefile +++ b/Makefile @@ -1,28 +1,23 @@ -DOCS_DIR := ./docs - -PROJECT := pact-python -PYTHON_MAJOR_VERSION := 3.11 - -sgr0 := $(shell tput sgr0) -red := $(shell tput setaf 1) -green := $(shell tput setaf 2) - help: @echo "" @echo " clean to clear build and distribution directories" - @echo " examples to run the example end to end tests (consumer, fastapi, flask, messaging)" - @echo " consumer to run the example consumer tests" - @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 " package to create a distribution package in /dist/" + @echo " package to build a wheel and sdist" @echo " release to perform a release build, including deps, test, and package targets" - @echo " test to run all tests" @echo "" + @echo " test to run all tests on the current python version" + @echo " test-all to run all tests on all supported python versions" + @echo " example to run the example end to end tests (requires docker)" + @echo " lint to run the lints" + @echo " ci to run test and lints" + @echo "" + @echo " help to show this help message" + @echo "" + @echo "Most of these targets are just wrappers around hatch commands." + @echo "See https://hatch.pypa.org for information to install hatch." .PHONY: release -release: test package +release: clean test package .PHONY: clean @@ -30,77 +25,31 @@ clean: hatch clean -define CONSUMER - echo "consumer make" - cd examples/consumer - pip install -q -r requirements.txt - pip install -e ../../ - ./run_pytest.sh -endef -export CONSUMER - - -define FLASK_PROVIDER - echo "flask make" - cd examples/flask_provider - pip install -q -r requirements.txt - pip install -e ../../ - ./run_pytest.sh -endef -export FLASK_PROVIDER - - -define FASTAPI_PROVIDER - echo "fastapi make" - cd examples/fastapi_provider - pip install -q -r requirements.txt - pip install -e ../../ - ./run_pytest.sh -endef -export FASTAPI_PROVIDER - - -define MESSAGING - echo "messaging make" - cd examples/message - pip install -q -r requirements.txt - pip install -e ../../ - ./run_pytest.sh -endef -export MESSAGING - - -.PHONY: consumer -consumer: - bash -c "$$CONSUMER" - - -.PHONY: flask -flask: - bash -c "$$FLASK_PROVIDER" +.PHONY: package +package: + hatch build -.PHONY: fastapi -fastapi: - bash -c "$$FASTAPI_PROVIDER" +.PHONY: test +test: + hatch run test + hatch run coverage report -m --fail-under=100 -.PHONY: messaging -messaging: - bash -c "$$MESSAGING" +.PHONY: test-all +test-all: + hatch run test:test -.PHONY: examples -examples: consumer flask fastapi messaging +.PHONY: example +example: + hatch run example -.PHONY: package -package: - hatch build +.PHONY: lint +lint: + hatch run lint -.PHONY: test -test: - hatch run all - hatch run test:all - coverage report -m --fail-under=100 +.PHONY: ci +ci: test lint diff --git a/examples/conftest.py b/examples/conftest.py index 3c0a5994a..aa1de61b1 100644 --- a/examples/conftest.py +++ b/examples/conftest.py @@ -12,7 +12,7 @@ from __future__ import annotations from pathlib import Path -from typing import Any, Generator +from typing import Any, Generator, Union import pytest from testcontainers.compose import DockerCompose @@ -45,7 +45,7 @@ def broker(request: pytest.FixtureRequest) -> Generator[URL, Any, None]: Otherwise, the Pact broker is started in a container. The URL of the containerised broker is then returned. """ - broker_url: str | None = request.config.getoption("--broker-url") + broker_url: Union[str, None] = request.config.getoption("--broker-url") # If we have been given a broker URL, there's nothing more to do here and we # can return early. diff --git a/examples/docker-compose.yml b/examples/docker-compose.yml index c845b1ee1..2405a49a9 100644 --- a/examples/docker-compose.yml +++ b/examples/docker-compose.yml @@ -18,6 +18,7 @@ services: - postgres ports: - "9292:9292" + restart: always environment: # Basic auth credentials for the Broker PACT_BROKER_ALLOW_PUBLIC_READ: "true" diff --git a/examples/src/consumer.py b/examples/src/consumer.py index 3aab181da..18819d4f7 100644 --- a/examples/src/consumer.py +++ b/examples/src/consumer.py @@ -3,8 +3,9 @@ This modules defines a simple [consumer](https://docs.pact.io/getting_started/terminology#service-consumer) -with Pact. As Pact is a consumer-driven framework, the consumer defines the -interactions which the provider must then satisfy. +which will be tested with Pact in the [consumer +test](../tests/test_00_consumer.py). As Pact is a consumer-driven framework, the +consumer defines the interactions which the provider must then satisfy. The consumer is the application which makes requests to another service (the provider) and receives a response to process. In this example, we have a simple @@ -20,7 +21,7 @@ from dataclasses import dataclass from datetime import datetime -from typing import Any +from typing import Any, Dict import requests @@ -95,7 +96,7 @@ def get_user(self, user_id: int) -> User: uri = f"{self.base_uri}/users/{user_id}" response = requests.get(uri, timeout=5) response.raise_for_status() - data: dict[str, Any] = response.json() + data: Dict[str, Any] = response.json() return User( id=data["id"], name=data["name"], diff --git a/examples/src/fastapi.py b/examples/src/fastapi.py index 2589fcd3d..52c6e3ff3 100644 --- a/examples/src/fastapi.py +++ b/examples/src/fastapi.py @@ -3,8 +3,10 @@ This modules defines a simple [provider](https://docs.pact.io/getting_started/terminology#service-provider) -with Pact. As Pact is a consumer-driven framework, the consumer defines the -contract which the provider must then satisfy. +which will be tested with Pact in the [provider +test](../tests/test_01_provider_fastapi.py). As Pact is a consumer-driven +framework, the consumer defines the contract which the provider must then +satisfy. The provider is the application which receives requests from another service (the consumer) and returns a response. In this example, we have a simple @@ -17,7 +19,7 @@ from __future__ import annotations -from typing import Any +from typing import Any, Dict from fastapi import FastAPI from fastapi.responses import JSONResponse @@ -28,15 +30,15 @@ As this is a simple example, we'll use a simple dict to represent a database. This would be replaced with a real database in a real application. -When testing the provider in a real application, the calls to the database -would be mocked out to avoid the need for a real database. An example of this -can be found in the test suite. +When testing the provider in a real application, the calls to the database would +be mocked out to avoid the need for a real database. An example of this can be +found in the [test suite](../tests/test_01_provider_fastapi.py). """ -FAKE_DB: dict[int, dict[str, Any]] = {} +FAKE_DB: Dict[int, Dict[str, Any]] = {} @app.get("/users/{uid}") -async def get_user_by_id(uid: int) -> dict[str, Any]: +async def get_user_by_id(uid: int) -> Dict[str, Any]: """ Fetch a user by their ID. diff --git a/examples/src/flask.py b/examples/src/flask.py index fd5095e03..da5424087 100644 --- a/examples/src/flask.py +++ b/examples/src/flask.py @@ -3,8 +3,10 @@ This modules defines a simple [provider](https://docs.pact.io/getting_started/terminology#service-provider) -with Pact. As Pact is a consumer-driven framework, the consumer defines the -contract which the provider must then satisfy. +which will be tested with Pact in the [provider +test](../tests/test_01_provider_flask.py). As Pact is a consumer-driven +framework, the consumer defines the contract which the provider must then +satisfy. The provider is the application which receives requests from another service (the consumer) and returns a response. In this example, we have a simple @@ -17,7 +19,7 @@ from __future__ import annotations -from typing import Any +from typing import Any, Dict, Union from flask import Flask @@ -29,13 +31,13 @@ When testing the provider in a real application, the calls to the database would be mocked out to avoid the need for a real database. An example of this -can be found in the test suite. +can be found in the [test suite](../tests/test_01_provider_flask.py). """ -FAKE_DB: dict[int, dict[str, Any]] = {} +FAKE_DB: Dict[int, Dict[str, Any]] = {} @app.route("/users/") -def get_user_by_id(uid: int) -> dict[str, Any] | tuple[dict[str, Any], int]: +def get_user_by_id(uid: int) -> Union[Dict[str, Any], tuple[Dict[str, Any], int]]: """ Fetch a user by their ID. diff --git a/examples/src/message.py b/examples/src/message.py index 6a617b271..ed0a755bf 100644 --- a/examples/src/message.py +++ b/examples/src/message.py @@ -9,7 +9,7 @@ from __future__ import annotations from pathlib import Path -from typing import Any +from typing import Any, Dict, Union class Filesystem: @@ -42,7 +42,7 @@ def __init__(self) -> None: """ self.fs = Filesystem() - def process(self, event: dict[str, Any]) -> str | None: + def process(self, event: Dict[str, Any]) -> Union[str, None]: """ Process an event from the queue. @@ -67,7 +67,7 @@ def process(self, event: dict[str, Any]) -> str | None: raise ValueError(msg) @staticmethod - def validate_event(event: dict[str, Any] | Any) -> None: # noqa: ANN401 + def validate_event(event: Union[Dict[str, Any], Any]) -> None: # noqa: ANN401 """ Validates the event received from the queue. diff --git a/examples/tests/test_00_consumer.py b/examples/tests/test_00_consumer.py index af3220fb9..7cb8369bd 100644 --- a/examples/tests/test_00_consumer.py +++ b/examples/tests/test_00_consumer.py @@ -7,13 +7,17 @@ is responding with the expected responses. Once these interactions are validated, the contracts can be published to a Pact Broker. The contracts can then be used to validate the provider's interactions. + +A good resource for understanding the consumer tests is the [Pact Consumer +Test](https://docs.pact.io/5-minute-getting-started-guide#scope-of-a-consumer-pact-test) +section of the Pact documentation. """ from __future__ import annotations import logging from http import HTTPStatus -from typing import TYPE_CHECKING, Any, Generator +from typing import TYPE_CHECKING, Any, Dict, Generator import pytest import requests @@ -40,6 +44,11 @@ def user_consumer() -> UserConsumer: the consumer to use Pact's mock provider. This allows us to define what requests the consumer will make to the provider, and what responses the provider will return. + + The ability for the client to specify the expected response from the + provider is critical to Pact's consumer-driven approach as it allows the + consumer to declare the minimal response it requires from the provider (even + if the provider is returning more data than the consumer needs). """ return UserConsumer(str(MOCK_URL)) @@ -53,7 +62,7 @@ def pact(broker: URL, pact_dir: Path) -> Generator[Pact, Any, None]: the provider. This mock provider will expect to receive defined requests and will respond with defined responses. - The fixture here simply defines the Consumer and Provide, and sets up the + The fixture here simply defines the Consumer and Provider, and sets up the mock provider. With each test, we define the expected request and response from the provider as follows: @@ -90,7 +99,11 @@ def test_get_existing_user(pact: Pact, user_consumer: UserConsumer) -> None: This test defines the expected request and response from the provider. The provider will be expected to return a response with a status code of 200, """ - expected: dict[str, Any] = { + # When setting up the expected response, the consumer should only define + # what it needs from the provider (as opposed to the full schema). Should + # the provider later decide to add or remove fields, Pact's consumer-driven + # approach will ensure that interaction is still valid. + expected: Dict[str, Any] = { "id": Format().integer, "name": "Verna Hampton", "created_on": Format().iso_8601_datetime(), diff --git a/examples/tests/test_01_provider_fastapi.py b/examples/tests/test_01_provider_fastapi.py index 75503b18d..d2d7486b8 100644 --- a/examples/tests/test_01_provider_fastapi.py +++ b/examples/tests/test_01_provider_fastapi.py @@ -16,12 +16,16 @@ additional endpoint on the provider, in this case `/_pact/provider_states`. Calls to this endpoint mock the relevant database calls to set the provider into the correct state. + +A good resource for understanding the provider tests is the [Pact Provider +Test](https://docs.pact.io/5-minute-getting-started-guide#scope-of-a-provider-pact-test) +section of the Pact documentation. """ from __future__ import annotations from multiprocessing import Process -from typing import Any, Generator +from typing import Any, Dict, Generator, Union from unittest.mock import MagicMock import pytest @@ -42,7 +46,9 @@ class ProviderState(BaseModel): @app.post("/_pact/provider_states") -async def mock_pact_provider_states(state: ProviderState) -> dict[str, str | None]: +async def mock_pact_provider_states( + state: ProviderState, +) -> Dict[str, Union[str, None]]: """ Define the provider state. @@ -102,10 +108,13 @@ def mock_user_123_exists() -> None: You may notice that the return value here differs from the consumer's expected response. This is because the consumer's expected response is - guided by what the consumer users. + guided by what the consumer uses. By using consumer-driven contracts and testing the provider against the - consumer's contract, we can ensure that the provider is only providing what + consumer's contract, we can ensure that the provider is what the consumer + needs. This allows the provider to safely evolve their API (by both adding + and removing fields) without fear of breaking the interactions with the + consumers. """ import examples.src.fastapi diff --git a/examples/tests/test_01_provider_flask.py b/examples/tests/test_01_provider_flask.py index 532057a4d..50ad3fc34 100644 --- a/examples/tests/test_01_provider_flask.py +++ b/examples/tests/test_01_provider_flask.py @@ -16,13 +16,17 @@ additional endpoint on the provider, in this case `/_pact/provider_states`. Calls to this endpoint mock the relevant database calls to set the provider into the correct state. + +A good resource for understanding the provider tests is the [Pact Provider +Test](https://docs.pact.io/5-minute-getting-started-guide#scope-of-a-provider-pact-test) +section of the Pact documentation. """ from __future__ import annotations from multiprocessing import Process -from typing import Any, Generator +from typing import Any, Dict, Generator, Union from unittest.mock import MagicMock import pytest @@ -35,7 +39,7 @@ @app.route("/_pact/provider_states", methods=["POST"]) -async def mock_pact_provider_states() -> dict[str, str | None]: +async def mock_pact_provider_states() -> Dict[str, Union[str, None]]: """ Define the provider state. @@ -95,10 +99,13 @@ def mock_user_123_exists() -> None: You may notice that the return value here differs from the consumer's expected response. This is because the consumer's expected response is - guided by what the consumer users. + guided by what the consumer uses. By using consumer-driven contracts and testing the provider against the - consumer's contract, we can ensure that the provider is only providing what + consumer's contract, we can ensure that the provider is what the consumer + needs. This allows the provider to safely evolve their API (by both adding + and removing fields) without fear of breaking the interactions with the + consumers. """ import examples.src.flask diff --git a/examples/tests/test_02_message_consumer.py b/examples/tests/test_02_message_consumer.py index 73457198a..b87b14a2e 100644 --- a/examples/tests/test_02_message_consumer.py +++ b/examples/tests/test_02_message_consumer.py @@ -4,7 +4,9 @@ Pact was originally designed for HTTP interactions involving a request and a response. Message Pact is an addition to Pact that allows for testing of non-HTTP interactions, such as message queues. This example demonstrates how to -use Message Pact to test whether a consumer can handle the messages it. +use Message Pact to test whether a consumer can handle the messages it. Due to +the large number of possible transports, Message Pact does not provide a mock +provider and the tests only verifies the messages. A note on terminology, the _consumer_ for Message Pact is the system that receives the message, and the _provider_ is the system that sends the message. @@ -14,11 +16,16 @@ In this example, Pact simply ensures that the consumer is capable of processing the message. The consumer need not send back a message, and any sideffects of -the message must be verified separately (such as through `assert` statements). +the message must be verified separately (such as through `assert` statements or +as part of the usual unit testing suite). > :warning: There is currently a bug whereby the `given` and -`expects_to_receive` have swapped meanings. This will be addressed in a future -release. +`expects_to_receive` have swapped meanings compared to the reference +implementation. This will be addressed in a future release. + +A good resource for understanding the message pact testing can be found [in the +Pact +documentation](https://docs.pact.io/getting_started/how_pact_works#non-http-testing-message-pact). """ from __future__ import annotations diff --git a/examples/tests/test_03_message_provider.py b/examples/tests/test_03_message_provider.py index 4f4570a63..bf75e736a 100644 --- a/examples/tests/test_03_message_provider.py +++ b/examples/tests/test_03_message_provider.py @@ -1,23 +1,32 @@ """ Test Message Pact provider. -Unlike the standard Pact, which is designed for HTTP interactions, the Message -Pact is designed for non-HTTP interactions. This example demonstrates how to use -the Message Pact to test whether a provider generates the correct messages. +Pact was originally designed for HTTP interactions involving a request and a +response. Message Pact is an addition to Pact that allows for testing of +non-HTTP interactions, such as message queues. This example demonstrates how to +use Message Pact to test whether a consumer can handle the messages it. Due to +the large number of possible transports, Message Pact does not provide a mock +provider and the tests only verifies the messages. -In such examples, Pact simply checks the kind of messages produced. The consumer -need not send back a message, and any sideffects of the message must be verified -separately. +A note on terminology, the _consumer_ for Message Pact is the system that +receives the message, and the _provider_ is the system that sends the message. +Pact is still consumer-driven, and the consumer defines the expected messages it +will receive from the provider. When the provider is being verified, Pact +ensures that the provider sends the expected messages. -The below example verifies that the consumer makes the correct filesystem calls -when it receives a message to read or write a file. The calls themselves are -mocked out so as to avoid actually writing to the filesystem. +The below example verifies that the provider sends the expected messages. The +consumer need not send back a message, and any sideffects of the message must +be verified on the consumer side. + +A good resource for understanding the message pact testing can be found [in the +Pact +documentation](https://docs.pact.io/getting_started/how_pact_works#non-http-testing-message-pact). """ from __future__ import annotations from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Dict from flask import Flask from pact import MessageProvider @@ -29,7 +38,7 @@ PACT_DIR = (Path(__file__).parent / "pacts").resolve() -def generate_write_message() -> dict[str, str]: +def generate_write_message() -> Dict[str, str]: return { "action": "WRITE", "path": "test.txt", @@ -37,7 +46,7 @@ def generate_write_message() -> dict[str, str]: } -def generate_read_message() -> dict[str, str]: +def generate_read_message() -> Dict[str, str]: return { "action": "READ", "path": "test.txt", diff --git a/hatch_build.py b/hatch_build.py index 940dba7c1..02651af21 100644 --- a/hatch_build.py +++ b/hatch_build.py @@ -6,7 +6,7 @@ import shutil import typing from pathlib import Path -from typing import Any +from typing import Any, Dict from hatchling.builders.hooks.plugin.interface import BuildHookInterface from packaging.tags import sys_tags @@ -37,7 +37,7 @@ def clean(self, versions: list[str]) -> None: # noqa: ARG002 def initialize( self, version: str, # noqa: ARG002 - build_data: dict[str, Any], + build_data: Dict[str, Any], ) -> None: """Hook into Hatchling's build process.""" build_data["infer_tag"] = True diff --git a/pyproject.toml b/pyproject.toml index fdb53cb32..e7f2d3825 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -106,7 +106,7 @@ extra-dependencies = ["hatchling", "packaging", "requests"] lint = ["black --check --diff {args:.}", "ruff {args:.}", "mypy {args:.}"] test = "pytest --cov-config=pyproject.toml --cov=pact --cov=tests tests/" example = "pytest examples/ {args}" -all = ["lint", "tests"] +all = ["lint", "test", "example"] # Test environment for running unit tests. This automatically tests against all # supported Python versions. @@ -118,8 +118,8 @@ python = ["3.8", "3.9", "3.10", "3.11"] [tool.hatch.envs.test.scripts] test = "pytest --cov-config=pyproject.toml --cov=pact --cov=tests tests/" -# TODO: Adapt the examples to work in Hatch -all = ["tests"] +example = "pytest examples/ {args}" +all = ["test", "example"] ################################################################################ ## PyTest Configuration @@ -154,5 +154,8 @@ ignore = [ "ANN102", # `cls` must be typed ] +[tool.ruff.pyupgrade] +keep-runtime-typing = true + [tool.ruff.pydocstyle] convention = "google"