diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 0695b7ff2..7e56120ef 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -18,17 +18,30 @@ env:
HATCH_VERBOSE: 1
jobs:
- test:
+ test-container:
name: >-
Tests py${{ matrix.python-version }} on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
continue-on-error: ${{ matrix.experimental }}
+ services:
+ broker:
+ image: pactfoundation/pact-broker:latest@sha256:8f10947f230f661ef21f270a4abcf53214ba27cd68063db81de555fcd93e07dd
+ 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
+
strategy:
fail-fast: false
matrix:
- os: [ubuntu-latest, windows-latest, macos-latest]
+ os: [ubuntu-latest]
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
experimental: [false]
include:
@@ -42,6 +55,12 @@ jobs:
with:
submodules: true
+ - name: Apply temporary definitions update
+ shell: bash
+ run: |
+ cd tests/v3/compatibility_suite
+ patch -p1 -d definition < definition-update.diff
+
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@0a5c61591373683505ea898e09a3ea4f39ef2b9c # v5
with:
@@ -51,8 +70,20 @@ jobs:
- 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: Run tests
- run: hatch run test
+ run: hatch run test --broker-url=http://pactbroker:pactbroker@localhost:9292 --container
- name: Upload coverage
# TODO: Configure code coverage monitoring
@@ -61,12 +92,50 @@ jobs:
with:
token: ${{ secrets.CODECOV_TOKEN }}
+ test-no-container:
+ name: >-
+ Tests py${{ matrix.python-version }} on ${{ matrix.os }}
+
+ runs-on: ${{ matrix.os }}
+ continue-on-error: ${{ matrix.experimental }}
+
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [windows-latest, macos-latest]
+ python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
+ experimental: [false]
+
+ steps:
+ - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
+ with:
+ submodules: true
+
+ - name: Apply temporary definitions update
+ shell: bash
+ run: |
+ cd tests/v3/compatibility_suite
+ patch -p1 -d definition < definition-update.diff
+
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@0a5c61591373683505ea898e09a3ea4f39ef2b9c # v5
+ with:
+ python-version: ${{ matrix.python-version }}
+ cache: pip
+
+ - name: Install Hatch
+ run: pip install --upgrade hatch
+
+ - name: Run tests
+ run: hatch run test
+
test-conlusion:
name: Test matrix complete
runs-on: ubuntu-latest
needs:
- - test
+ - test-container
+ - test-no-container
steps:
- run: echo "Test matrix completed successfully."
diff --git a/conftest.py b/conftest.py
index f6b59a455..359b5e916 100644
--- a/conftest.py
+++ b/conftest.py
@@ -19,3 +19,16 @@ def pytest_addoption(parser: pytest.Parser) -> None:
),
type=str,
)
+ parser.addoption(
+ "--container",
+ action="store_true",
+ help="Run tests using a container",
+ )
+
+
+def pytest_runtest_setup(item: pytest.Item) -> None:
+ """
+ Hook into the setup phase of tests.
+ """
+ if "container" in item.keywords and not item.config.getoption("--container"):
+ pytest.skip("need --container to run this test")
diff --git a/pyproject.toml b/pyproject.toml
index 820218d8a..0bc02ad05 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -189,12 +189,16 @@ filterwarnings = [
]
log_level = "NOTSET"
-log_format = "%(asctime)s [%(levelname)-8s] %(name)s: %(message)s"
-log_date-format = "%H:%M:%S"
+log_format = "%(asctime)s.%(msecs)03d [%(levelname)-8s] %(name)s: %(message)s"
+log_date_format = "%H:%M:%S"
markers = [
+ # Marker for tests that require a container
+ "container",
+
# Markers for the compatibility suite
"consumer",
+ "provider",
]
################################################################################
diff --git a/src/pact/v3/ffi.py b/src/pact/v3/ffi.py
index e809e173d..16fd47dfb 100644
--- a/src/pact/v3/ffi.py
+++ b/src/pact/v3/ffi.py
@@ -6638,9 +6638,9 @@ def verifier_set_no_pacts_is_error(handle: VerifierHandle, *, enabled: bool) ->
def verifier_set_publish_options(
handle: VerifierHandle,
provider_version: str,
- build_url: str,
- provider_tags: List[str],
- provider_branch: str,
+ build_url: str | None,
+ provider_tags: List[str] | None,
+ provider_branch: str | None,
) -> None:
"""
Set the options used when publishing verification results to the Broker.
@@ -6667,10 +6667,10 @@ def verifier_set_publish_options(
retval: int = lib.pactffi_verifier_set_publish_options(
handle._ref,
provider_version.encode("utf-8"),
- build_url.encode("utf-8"),
+ build_url.encode("utf-8") if build_url else ffi.NULL,
[ffi.new("char[]", t.encode("utf-8")) for t in provider_tags or []],
- len(provider_tags),
- provider_branch.encode("utf-8"),
+ len(provider_tags or []),
+ provider_branch.encode("utf-8") if provider_branch else ffi.NULL,
)
if retval != 0:
msg = f"Failed to set publish options for {handle}."
diff --git a/src/pact/v3/verifier.py b/src/pact/v3/verifier.py
index c6cf8f436..2e8ce85b5 100644
--- a/src/pact/v3/verifier.py
+++ b/src/pact/v3/verifier.py
@@ -350,8 +350,8 @@ def set_error_on_empty_pact(self, *, enabled: bool = True) -> Self:
def set_publish_options(
self,
version: str,
- url: str,
- branch: str,
+ url: str | None = None,
+ branch: str | None = None,
tags: list[str] | None = None,
) -> Self:
"""
@@ -589,7 +589,7 @@ def _add_source_remote(
pact.v3.ffi.verifier_url_source(
self._handle,
- str(url),
+ str(url.with_user(None).with_password(None)),
username,
password,
token,
@@ -686,14 +686,14 @@ def broker_source( # noqa: PLR0913
if selector:
return BrokerSelectorBuilder(
self,
- str(url),
+ str(url.with_user(None).with_password(None)),
username,
password,
token,
)
pact.v3.ffi.verifier_broker_source(
self._handle,
- str(url),
+ str(url.with_user(None).with_password(None)),
username,
password,
token,
diff --git a/tests/v3/compatibility_suite/definition-update.diff b/tests/v3/compatibility_suite/definition-update.diff
new file mode 100644
index 000000000..23538b1ab
--- /dev/null
+++ b/tests/v3/compatibility_suite/definition-update.diff
@@ -0,0 +1,79 @@
+diff --git a/features/V1/http_provider.feature b/features/V1/http_provider.feature
+index 94fda44..2838116 100644
+--- a/features/V1/http_provider.feature
++++ b/features/V1/http_provider.feature
+@@ -118,16 +118,16 @@ Feature: Basic HTTP provider
+
+ Scenario: Verifies the response status code
+ Given a provider is started that returns the response from interaction 1, with the following changes:
+- | status |
+- | 400 |
++ | response |
++ | 400 |
+ And a Pact file for interaction 1 is to be verified
+ When the verification is run
+ Then the verification will NOT be successful
+ And the verification results will contain a "Response status did not match" error
+
+ Scenario: Verifies the response headers
+- Given a provider is started that returns the response from interaction 1, with the following changes:
+- | headers |
++ Given a provider is started that returns the response from interaction 5, with the following changes:
++ | response headers |
+ | 'X-TEST: Compatibility' |
+ And a Pact file for interaction 5 is to be verified
+ When the verification is run
+@@ -142,7 +142,7 @@ Feature: Basic HTTP provider
+
+ Scenario: Response with plain text body (negative case)
+ Given a provider is started that returns the response from interaction 6, with the following changes:
+- | body |
++ | response body |
+ | Hello Compatibility Suite! |
+ And a Pact file for interaction 6 is to be verified
+ When the verification is run
+@@ -157,7 +157,7 @@ Feature: Basic HTTP provider
+
+ Scenario: Response with JSON body (negative case)
+ Given a provider is started that returns the response from interaction 1, with the following changes:
+- | body |
++ | response body |
+ | JSON: { "one": 100, "two": "b" } |
+ And a Pact file for interaction 1 is to be verified
+ When the verification is run
+@@ -172,7 +172,7 @@ Feature: Basic HTTP provider
+
+ Scenario: Response with XML body (negative case)
+ Given a provider is started that returns the response from interaction 7, with the following changes:
+- | body |
++ | response body |
+ | XML: A |
+ And a Pact file for interaction 7 is to be verified
+ When the verification is run
+@@ -187,7 +187,7 @@ Feature: Basic HTTP provider
+
+ Scenario: Response with binary body (negative case)
+ Given a provider is started that returns the response from interaction 8, with the following changes:
+- | body |
++ | response body |
+ | file: spider.jpg |
+ And a Pact file for interaction 8 is to be verified
+ When the verification is run
+@@ -202,7 +202,7 @@ Feature: Basic HTTP provider
+
+ Scenario: Response with form post body (negative case)
+ Given a provider is started that returns the response from interaction 9, with the following changes:
+- | body |
++ | response body |
+ | a=1&b=2&c=33&d=4 |
+ And a Pact file for interaction 9 is to be verified
+ When the verification is run
+@@ -217,7 +217,7 @@ Feature: Basic HTTP provider
+
+ Scenario: Response with multipart body (negative case)
+ Given a provider is started that returns the response from interaction 10, with the following changes:
+- | body |
++ | response body |
+ | file: multipart2-body.xml |
+ And a Pact file for interaction 10 is to be verified
+ When the verification is run
diff --git a/tests/v3/compatibility_suite/test_v1_provider.py b/tests/v3/compatibility_suite/test_v1_provider.py
new file mode 100644
index 000000000..880847d1a
--- /dev/null
+++ b/tests/v3/compatibility_suite/test_v1_provider.py
@@ -0,0 +1,944 @@
+"""
+Basic HTTP provider feature test.
+"""
+
+from __future__ import annotations
+
+import copy
+import json
+import logging
+import pickle
+import re
+import signal
+import subprocess
+import sys
+import time
+from contextvars import ContextVar
+from pathlib import Path
+from threading import Thread
+from typing import Any, Generator, NoReturn, Union
+
+import pytest
+import requests
+from pytest_bdd import given, parsers, scenario, then, when
+from testcontainers.compose import DockerCompose # type: ignore[import-untyped]
+from yarl import URL
+
+from pact.v3.pact import Pact
+from pact.v3.verifier import Verifier
+from tests.v3.compatibility_suite.util import (
+ InteractionDefinition,
+ parse_headers,
+ parse_markdown_table,
+)
+from tests.v3.compatibility_suite.util.provider import PactBroker
+
+logger = logging.getLogger(__name__)
+
+reset_broker_var = ContextVar("reset_broker", default=True)
+"""
+This context variable is used to determine whether the Pact broker should be
+cleaned up. It is used to ensure that the broker is only cleaned up once, even
+if a step is run multiple times.
+
+All scenarios which make use of the Pact broker should set this to `True` at the
+start of the scenario.
+"""
+
+
+@pytest.fixture()
+def verifier() -> Verifier:
+ """Return a new Verifier."""
+ return Verifier()
+
+
+@pytest.fixture(scope="session")
+def broker_url(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: 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.
+ if broker_url:
+ yield URL(broker_url)
+ return
+
+ with DockerCompose(
+ Path(__file__).parent / "util",
+ compose_file_name="pact-broker.yml",
+ pull=True,
+ ) as _:
+ yield URL("http://pactbroker:pactbroker@localhost:9292")
+ return
+
+
+################################################################################
+## Scenario
+################################################################################
+
+
+@scenario(
+ "definition/features/V1/http_provider.feature",
+ "Verifying a simple HTTP request",
+)
+def test_verifying_a_simple_http_request() -> None:
+ """Verifying a simple HTTP request."""
+
+
+@scenario(
+ "definition/features/V1/http_provider.feature",
+ "Verifying multiple Pact files",
+)
+def test_verifying_multiple_pact_files() -> None:
+ """Verifying multiple Pact files."""
+
+
+@scenario(
+ "definition/features/V1/http_provider.feature",
+ "Incorrect request is made to provider",
+)
+def test_incorrect_request_is_made_to_provider() -> None:
+ """Incorrect request is made to provider."""
+
+
+@pytest.mark.container()
+@scenario(
+ "definition/features/V1/http_provider.feature",
+ "Verifying a simple HTTP request via a Pact broker",
+)
+def test_verifying_a_simple_http_request_via_a_pact_broker() -> None:
+ """Verifying a simple HTTP request via a Pact broker."""
+ reset_broker_var.set(True) # noqa: FBT003
+
+
+@pytest.mark.container()
+@scenario(
+ "definition/features/V1/http_provider.feature",
+ "Verifying a simple HTTP request via a Pact broker with publishing results enabled",
+)
+def test_verifying_a_simple_http_request_via_a_pact_broker_with_publishing() -> None:
+ """Verifying a simple HTTP request via a Pact broker with publishing."""
+ reset_broker_var.set(True) # noqa: FBT003
+
+
+@pytest.mark.container()
+@scenario(
+ "definition/features/V1/http_provider.feature",
+ "Verifying multiple Pact files via a Pact broker",
+)
+def test_verifying_multiple_pact_files_via_a_pact_broker() -> None:
+ """Verifying multiple Pact files via a Pact broker."""
+ reset_broker_var.set(True) # noqa: FBT003
+
+
+@pytest.mark.container()
+@scenario(
+ "definition/features/V1/http_provider.feature",
+ "Incorrect request is made to provider via a Pact broker",
+)
+def test_incorrect_request_is_made_to_provider_via_a_pact_broker() -> None:
+ """Incorrect request is made to provider via a Pact broker."""
+ reset_broker_var.set(True) # noqa: FBT003
+
+
+@scenario(
+ "definition/features/V1/http_provider.feature",
+ "Verifying an interaction with a defined provider state",
+)
+def test_verifying_an_interaction_with_a_defined_provider_state() -> None:
+ """Verifying an interaction with a defined provider state."""
+
+
+@scenario(
+ "definition/features/V1/http_provider.feature",
+ "Verifying an interaction with no defined provider state",
+)
+def test_verifying_an_interaction_with_no_defined_provider_state() -> None:
+ """Verifying an interaction with no defined provider state."""
+
+
+@scenario(
+ "definition/features/V1/http_provider.feature",
+ "Verifying an interaction where the provider state callback fails",
+)
+def test_verifying_an_interaction_where_the_provider_state_callback_fails() -> None:
+ """Verifying an interaction where the provider state callback fails."""
+
+
+@scenario(
+ "definition/features/V1/http_provider.feature",
+ "Verifying an interaction where a provider state callback is not configured",
+)
+def test_verifying_an_interaction_where_no_provider_state_callback_configured() -> None:
+ """Verifying an interaction where a provider state callback is not configured."""
+
+
+@scenario(
+ "definition/features/V1/http_provider.feature",
+ "Verifying a HTTP request with a request filter configured",
+)
+def test_verifying_a_http_request_with_a_request_filter_configured() -> None:
+ """Verifying a HTTP request with a request filter configured."""
+
+
+@scenario(
+ "definition/features/V1/http_provider.feature",
+ "Verifies the response status code",
+)
+def test_verifies_the_response_status_code() -> None:
+ """Verifies the response status code."""
+
+
+@scenario(
+ "definition/features/V1/http_provider.feature",
+ "Verifies the response headers",
+)
+def test_verifies_the_response_headers() -> None:
+ """Verifies the response headers."""
+
+
+@scenario(
+ "definition/features/V1/http_provider.feature",
+ "Response with plain text body (positive case)",
+)
+def test_response_with_plain_text_body_positive_case() -> None:
+ """Response with plain text body (positive case)."""
+
+
+@scenario(
+ "definition/features/V1/http_provider.feature",
+ "Response with plain text body (negative case)",
+)
+def test_response_with_plain_text_body_negative_case() -> None:
+ """Response with plain text body (negative case)."""
+
+
+@scenario(
+ "definition/features/V1/http_provider.feature",
+ "Response with JSON body (positive case)",
+)
+def test_response_with_json_body_positive_case() -> None:
+ """Response with JSON body (positive case)."""
+
+
+@scenario(
+ "definition/features/V1/http_provider.feature",
+ "Response with JSON body (negative case)",
+)
+def test_response_with_json_body_negative_case() -> None:
+ """Response with JSON body (negative case)."""
+
+
+@scenario(
+ "definition/features/V1/http_provider.feature",
+ "Response with XML body (positive case)",
+)
+def test_response_with_xml_body_positive_case() -> None:
+ """Response with XML body (positive case)."""
+
+
+@scenario(
+ "definition/features/V1/http_provider.feature",
+ "Response with XML body (negative case)",
+)
+def test_response_with_xml_body_negative_case() -> None:
+ """Response with XML body (negative case)."""
+
+
+@scenario(
+ "definition/features/V1/http_provider.feature",
+ "Response with binary body (positive case)",
+)
+def test_response_with_binary_body_positive_case() -> None:
+ """Response with binary body (positive case)."""
+
+
+@scenario(
+ "definition/features/V1/http_provider.feature",
+ "Response with binary body (negative case)",
+)
+def test_response_with_binary_body_negative_case() -> None:
+ """Response with binary body (negative case)."""
+
+
+@scenario(
+ "definition/features/V1/http_provider.feature",
+ "Response with form post body (positive case)",
+)
+def test_response_with_form_post_body_positive_case() -> None:
+ """Response with form post body (positive case)."""
+
+
+@scenario(
+ "definition/features/V1/http_provider.feature",
+ "Response with form post body (negative case)",
+)
+def test_response_with_form_post_body_negative_case() -> None:
+ """Response with form post body (negative case)."""
+
+
+@scenario(
+ "definition/features/V1/http_provider.feature",
+ "Response with multipart body (positive case)",
+)
+def test_response_with_multipart_body_positive_case() -> None:
+ """Response with multipart body (positive case)."""
+
+
+@scenario(
+ "definition/features/V1/http_provider.feature",
+ "Response with multipart body (negative case)",
+)
+def test_response_with_multipart_body_negative_case() -> None:
+ """Response with multipart body (negative case)."""
+
+
+################################################################################
+## Given
+################################################################################
+
+
+@given(
+ parsers.parse("the following HTTP interactions have been defined:\n{content}"),
+ target_fixture="interaction_definitions",
+ converters={"content": parse_markdown_table},
+)
+def the_following_http_interactions_have_been_defined(
+ content: list[dict[str, str]],
+) -> dict[int, InteractionDefinition]:
+ """
+ Parse the HTTP interactions table into a dictionary.
+
+ The table columns are expected to be:
+
+ - No
+ - method
+ - path
+ - query
+ - headers
+ - body
+ - response
+ - response headers
+ - response content
+ - response body
+
+ The first row is ignored, as it is assumed to be the column headers. The
+ order of the columns is similarly ignored.
+ """
+ logger.debug("Parsing interaction definitions")
+
+ # Check that the table is well-formed
+ assert len(content[0]) == 10, f"Expected 10 columns, got {len(content[0])}"
+ assert "No" in content[0], "'No' column not found"
+
+ # Parse the table into a more useful format
+ interactions: dict[int, InteractionDefinition] = {}
+ for row in content:
+ interactions[int(row["No"])] = InteractionDefinition(**row)
+ return interactions
+
+
+@given(
+ parsers.re(
+ r"a provider is started that returns the responses? "
+ r'from interactions? "?(?P[0-9, ]+)"?',
+ ),
+ converters={"interactions": lambda x: [int(i) for i in x.split(",") if i]},
+ target_fixture="provider_url",
+)
+def a_provider_is_started_that_returns_the_responses_from_interactions(
+ interaction_definitions: dict[int, InteractionDefinition],
+ interactions: list[int],
+ temp_dir: Path,
+) -> Generator[URL, None, None]:
+ """
+ Start a provider that returns the responses from the given interactions.
+ """
+ logger.debug("Starting provider for interactions %s", interactions)
+
+ for i in interactions:
+ logger.debug("Interaction %d: %s", i, interaction_definitions[i])
+
+ with (temp_dir / "interactions.pkl").open("wb") as pkl_file:
+ pickle.dump([interaction_definitions[i] for i in interactions], pkl_file)
+
+ yield from start_provider(temp_dir)
+
+
+@given(
+ parsers.re(
+ r"a provider is started that returns the responses?"
+ r' from interactions? "?(?P[0-9, ]+)"?'
+ r" with the following changes:\n(?P.+)",
+ re.DOTALL,
+ ),
+ converters={
+ "interactions": lambda x: [int(i) for i in x.split(",") if i],
+ "changes": parse_markdown_table,
+ },
+ target_fixture="provider_url",
+)
+def a_provider_is_started_that_returns_the_responses_from_interactions_with_changes(
+ interaction_definitions: dict[int, InteractionDefinition],
+ interactions: list[int],
+ changes: list[dict[str, str]],
+ temp_dir: Path,
+) -> Generator[URL, None, None]:
+ """
+ Start a provider that returns the responses from the given interactions.
+ """
+ logger.debug("Starting provider for interactions %s", interactions)
+
+ assert len(changes) == 1, "Only one set of changes is supported"
+ defns: list[InteractionDefinition] = []
+ for interaction in interactions:
+ defn = copy.deepcopy(interaction_definitions[interaction])
+ defn.update(**changes[0])
+ defns.append(defn)
+ logger.debug(
+ "Update interaction %d: %s",
+ interaction,
+ defn,
+ )
+
+ with (temp_dir / "interactions.pkl").open("wb") as pkl_file:
+ pickle.dump(defns, pkl_file)
+
+ yield from start_provider(temp_dir)
+
+
+def start_provider(provider_dir: str | Path) -> Generator[URL, None, None]: # noqa: C901
+ """Start the provider app with the given interactions."""
+ process = subprocess.Popen(
+ [ # noqa: S603
+ sys.executable,
+ Path(__file__).parent / "util" / "provider.py",
+ str(provider_dir),
+ ],
+ cwd=Path.cwd(),
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ encoding="utf-8",
+ )
+
+ pattern = re.compile(r" \* Running on (?P[^ ]+)")
+ while True:
+ if process.poll() is not None:
+ logger.error("Provider process exited with code %d", process.returncode)
+ logger.error(
+ "Provider stdout: %s", process.stdout.read() if process.stdout else ""
+ )
+ logger.error(
+ "Provider stderr: %s", process.stderr.read() if process.stderr else ""
+ )
+ msg = f"Provider process exited with code {process.returncode}"
+ raise RuntimeError(msg)
+ if (
+ process.stderr
+ and (line := process.stderr.readline())
+ and (match := pattern.match(line))
+ ):
+ break
+ time.sleep(0.1)
+
+ url = URL(match.group("url"))
+ logger.debug("Provider started on %s", url)
+ for _ in range(50):
+ try:
+ response = requests.get(str(url / "_test" / "ping"), timeout=1)
+ assert response.text == "pong"
+ break
+ except (requests.RequestException, AssertionError):
+ time.sleep(0.1)
+ continue
+ else:
+ msg = "Failed to ping provider"
+ raise RuntimeError(msg)
+
+ def redirect() -> NoReturn:
+ while True:
+ if process.stdout:
+ while line := process.stdout.readline():
+ logger.debug("Provider stdout: %s", line.strip())
+ if process.stderr:
+ while line := process.stderr.readline():
+ logger.debug("Provider stderr: %s", line.strip())
+
+ thread = Thread(target=redirect, daemon=True)
+ thread.start()
+
+ yield url
+
+ process.send_signal(signal.SIGINT)
+
+
+@given(
+ parsers.re(
+ r"a Pact file for interaction (?P\d+) is to be verified",
+ ),
+ converters={"interaction": int},
+)
+def a_pact_file_for_interaction_is_to_be_verified(
+ interaction_definitions: dict[int, InteractionDefinition],
+ verifier: Verifier,
+ interaction: int,
+ temp_dir: Path,
+) -> None:
+ """
+ Verify the Pact file for the given interaction.
+ """
+ logger.debug(
+ "Adding interaction %d to be verified: %s",
+ interaction,
+ interaction_definitions[interaction],
+ )
+
+ defn = interaction_definitions[interaction]
+
+ pact = Pact("consumer", "provider")
+ pact.with_specification("V1")
+ defn.add_to_pact(pact, f"interaction {interaction}")
+ (temp_dir / "pacts").mkdir(exist_ok=True, parents=True)
+ pact.write_file(temp_dir / "pacts")
+
+ verifier.add_source(temp_dir / "pacts")
+
+
+@given(
+ parsers.re(
+ r"a Pact file for interaction (?P\d+)"
+ r" is to be verified from a Pact broker",
+ ),
+ converters={"interaction": int},
+ target_fixture="pact_broker",
+)
+def a_pact_file_for_interaction_is_to_be_verified_from_a_pact_broker(
+ interaction_definitions: dict[int, InteractionDefinition],
+ broker_url: URL,
+ verifier: Verifier,
+ interaction: int,
+ temp_dir: Path,
+) -> Generator[PactBroker, None, None]:
+ """
+ Verify the Pact file for the given interaction from a Pact broker.
+ """
+ logger.debug("Adding interaction %d to be verified from a Pact broker", interaction)
+
+ defn = interaction_definitions[interaction]
+
+ pact = Pact("consumer", "provider")
+ pact.with_specification("V1")
+ defn.add_to_pact(pact, f"interaction {interaction}")
+
+ pacts_dir = temp_dir / "pacts"
+ pacts_dir.mkdir(exist_ok=True, parents=True)
+ pact.write_file(pacts_dir)
+
+ pact_broker = PactBroker(broker_url)
+ if reset_broker_var.get():
+ logger.debug("Resetting Pact broker")
+ pact_broker.reset()
+ reset_broker_var.set(False) # noqa: FBT003
+ pact_broker.publish(pacts_dir)
+ verifier.broker_source(pact_broker.url)
+ yield pact_broker
+
+
+@given("publishing of verification results is enabled")
+def publishing_of_verification_results_is_enabled(verifier: Verifier) -> None:
+ """
+ Enable publishing of verification results.
+ """
+ logger.debug("Publishing verification results")
+
+ verifier.set_publish_options(
+ "0.0.0",
+ )
+
+
+@given(
+ parsers.re(
+ r"a provider state callback is configured"
+ r"(?P(, but will return a failure)?)",
+ ),
+ converters={"failure": lambda x: x != ""},
+)
+def a_provider_state_callback_is_configured(
+ verifier: Verifier,
+ provider_url: URL,
+ temp_dir: Path,
+ failure: bool, # noqa: FBT001
+) -> None:
+ """
+ Configure a provider state callback.
+ """
+ logger.debug("Configuring provider state callback")
+
+ if failure:
+ with (temp_dir / "fail_callback").open("w") as f:
+ f.write("true")
+
+ verifier.set_state(
+ provider_url / "_test" / "callback",
+ teardown=True,
+ )
+
+
+@given(
+ parsers.re(
+ r"a Pact file for interaction (?P\d+) is to be verified"
+ r' with a provider state "(?P[^"]+)" defined',
+ ),
+ converters={"interaction": int},
+)
+def a_pact_file_for_interaction_is_to_be_verified_with_a_provider_state_define(
+ interaction_definitions: dict[int, InteractionDefinition],
+ verifier: Verifier,
+ interaction: int,
+ state: str,
+ temp_dir: Path,
+) -> None:
+ """
+ Verify the Pact file for the given interaction with a provider state defined.
+ """
+ logger.debug(
+ "Adding interaction %d to be verified with provider state %s",
+ interaction,
+ state,
+ )
+
+ defn = interaction_definitions[interaction]
+ defn.state = state
+
+ pact = Pact("consumer", "provider")
+ pact.with_specification("V1")
+ defn.add_to_pact(pact, f"interaction {interaction}")
+ (temp_dir / "pacts").mkdir(exist_ok=True, parents=True)
+ pact.write_file(temp_dir / "pacts")
+
+ verifier.add_source(temp_dir / "pacts")
+
+ with (temp_dir / "provider_state").open("w") as f:
+ logger.debug("Writing provider state to %s", temp_dir / "provider_state")
+ f.write(state)
+
+
+@given(
+ parsers.parse(
+ "a request filter is configured to make the following changes:\n{content}"
+ ),
+ converters={"content": parse_markdown_table},
+)
+def a_request_filter_is_configured_to_make_the_following_changes(
+ content: list[dict[str, str]],
+ verifier: Verifier,
+) -> None:
+ """
+ Configure a request filter to make the given changes.
+ """
+ logger.debug("Configuring request filter")
+
+ if "headers" in content[0]:
+ verifier.add_custom_headers(parse_headers(content[0]["headers"]).items())
+ else:
+ msg = "Unsupported filter type"
+ raise RuntimeError(msg)
+
+
+################################################################################
+## When
+################################################################################
+
+
+@when("the verification is run", target_fixture="verifier_result")
+def the_verification_is_run(
+ verifier: Verifier,
+ provider_url: URL,
+) -> tuple[Verifier, Exception | None]:
+ """
+ Run the verification.
+ """
+ logger.debug("Running verification on %r", verifier)
+
+ verifier.set_info("provider", url=provider_url)
+ try:
+ verifier.verify()
+ except Exception as e: # noqa: BLE001
+ return verifier, e
+ return verifier, None
+
+
+################################################################################
+## Then
+################################################################################
+
+
+@then(
+ parsers.re(r"the verification will(?P( NOT)?) be successful"),
+ converters={"negated": lambda x: x == " NOT"},
+)
+def the_verification_will_be_successful(
+ verifier_result: tuple[Verifier, Exception | None],
+ negated: bool, # noqa: FBT001
+) -> None:
+ """
+ Check that the verification was successful.
+ """
+ logger.debug("Checking verification result")
+ logger.debug("Verifier result: %s", verifier_result)
+
+ if negated:
+ assert verifier_result[1] is not None
+ else:
+ assert verifier_result[1] is None
+
+
+@then(
+ parsers.re(r'the verification results will contain a "(?P[^"]+)" error'),
+)
+def the_verification_results_will_contain_a_error(
+ verifier_result: tuple[Verifier, Exception | None], error: str
+) -> None:
+ """
+ Check that the verification results contain the given error.
+ """
+ logger.debug("Checking that verification results contain error %s", error)
+
+ verifier = verifier_result[0]
+ logger.debug("Verification results: %s", json.dumps(verifier.results, indent=2))
+
+ if error == "Response status did not match":
+ mismatch_type = "StatusMismatch"
+ elif error == "Headers had differences":
+ mismatch_type = "HeaderMismatch"
+ elif error == "Body had differences":
+ mismatch_type = "BodyMismatch"
+ elif error == "State change request failed":
+ assert "One or more of the setup state change handlers has failed" in [
+ error["mismatch"]["message"] for error in verifier.results["errors"]
+ ]
+ return
+ else:
+ msg = f"Unknown error type: {error}"
+ raise ValueError(msg)
+
+ assert mismatch_type in [
+ mismatch["type"]
+ for error in verifier.results["errors"]
+ for mismatch in error["mismatch"]["mismatches"]
+ ]
+
+
+@then(
+ parsers.re(r"a verification result will NOT be published back"),
+)
+def a_verification_result_will_not_be_published_back(pact_broker: PactBroker) -> None:
+ """
+ Check that the verification result was published back to the Pact broker.
+ """
+ logger.debug("Checking that verification result was not published back")
+
+ response = pact_broker.latest_verification_results()
+ if response:
+ with pytest.raises(requests.HTTPError, match="404 Client Error"):
+ response.raise_for_status()
+
+
+@then(
+ parsers.re(
+ "a successful verification result "
+ "will be published back "
+ r"for interaction \{(?P\d+)\}",
+ ),
+ converters={"interaction": int},
+)
+def a_successful_verification_result_will_be_published_back(
+ pact_broker: PactBroker,
+ interaction: int,
+) -> None:
+ """
+ Check that the verification result was published back to the Pact broker.
+ """
+ logger.debug(
+ "Checking that verification result was published back for interaction %d",
+ interaction,
+ )
+
+ interaction_id = pact_broker.interaction_id(interaction)
+ response = pact_broker.latest_verification_results()
+ assert response is not None
+ assert response.ok
+ data: dict[str, Any] = response.json()
+ assert data["success"]
+
+ for test_result in data["testResults"]:
+ if test_result["interactionId"] == interaction_id:
+ assert test_result["success"]
+ break
+ else:
+ msg = f"Interaction {interaction} not found in verification results"
+ raise ValueError(msg)
+
+
+@then(
+ parsers.re(
+ "a failed verification result "
+ "will be published back "
+ r"for the interaction \{(?P\d+)\}",
+ ),
+ converters={"interaction": int},
+)
+def a_failed_verification_result_will_be_published_back(
+ pact_broker: PactBroker,
+ interaction: int,
+) -> None:
+ """
+ Check that the verification result was published back to the Pact broker.
+ """
+ logger.debug(
+ "Checking that failed verification result"
+ " was published back for interaction %d",
+ interaction,
+ )
+
+ interaction_id = pact_broker.interaction_id(interaction)
+ response = pact_broker.latest_verification_results()
+ assert response is not None
+ assert response.ok
+ data: dict[str, Any] = response.json()
+ assert not data["success"]
+
+ for test_result in data["testResults"]:
+ if test_result["interactionId"] == interaction_id:
+ assert not test_result["success"]
+ break
+ else:
+ msg = f"Interaction {interaction} not found in verification results"
+ raise ValueError(msg)
+
+
+@then("the provider state callback will be called before the verification is run")
+def the_provider_state_callback_will_be_called_before_the_verification_is_run() -> None:
+ """
+ Check that the provider state callback was called before the verification was run.
+ """
+ logger.debug("Checking provider state callback was called before verification")
+
+
+@then(
+ parsers.re(
+ r"the provider state callback will receive a (?Psetup|teardown) call"
+ r' (with )?"(?P[^"]*)" as the provider state parameter',
+ ),
+)
+def the_provider_state_callback_will_receive_a_setup_call(
+ temp_dir: Path,
+ action: str,
+ state: str,
+) -> None:
+ """
+ Check that the provider state callback received a setup call.
+ """
+ logger.info("Checking provider state callback received a %s call", action)
+ logger.info("Callback files: %s", list(temp_dir.glob("callback.*.json")))
+ for file in temp_dir.glob("callback.*.json"):
+ with file.open("r") as f:
+ data: dict[str, Any] = json.load(f)
+ logger.debug("Checking callback data: %s", data)
+ if (
+ "action" in data["query_params"]
+ and data["query_params"]["action"] == action
+ and data["query_params"]["state"] == state
+ ):
+ break
+ else:
+ msg = f"No {action} call found"
+ raise AssertionError(msg)
+
+
+@then(
+ parsers.re(
+ r"the provider state callback will "
+ r"NOT receive a (?Psetup|teardown) call"
+ )
+)
+def the_provider_state_callback_will_not_receive_a_setup_call(
+ temp_dir: Path,
+ action: str,
+) -> None:
+ """
+ Check that the provider state callback did not receive a setup call.
+ """
+ for file in temp_dir.glob("callback.*.json"):
+ with file.open("r") as f:
+ data: dict[str, Any] = json.load(f)
+ logger.debug("Checking callback data: %s", data)
+ if (
+ "action" in data["query_params"]
+ and data["query_params"]["action"] == action
+ ):
+ msg = f"Unexpected {action} call found"
+ raise AssertionError(msg)
+
+
+@then("the provider state callback will be called after the verification is run")
+def the_provider_state_callback_will_be_called_after_the_verification_is_run() -> None:
+ """
+ Check that the provider state callback was called after the verification was run.
+ """
+
+
+@then(
+ parsers.re(
+ r"a warning will be displayed "
+ r"that there was no provider state callback configured "
+ r'for provider state "(?P[^"]*)"',
+ )
+)
+def a_warning_will_be_displayed_that_there_was_no_callback_configured(
+ state: str,
+) -> None:
+ """
+ Check that a warning was displayed that there was no callback configured.
+ """
+ logger.debug("Checking for warning about missing provider state callback")
+ assert state
+
+
+@then(
+ parsers.re(
+ r'the request to the provider will contain the header "(?P[^"]+)"',
+ ),
+ converters={"header": lambda x: parse_headers(f"'{x}'")},
+)
+def the_request_to_the_provider_will_contain_the_header(
+ verifier_result: tuple[Verifier, Exception | None],
+ header: dict[str, str],
+ temp_dir: Path,
+) -> None:
+ """
+ Check that the request to the provider contained the given header.
+ """
+ verifier = verifier_result[0]
+ logger.debug("verifier output: %s", verifier.output(strip_ansi=True))
+ logger.debug("verifier results: %s", json.dumps(verifier.results, indent=2))
+ for request in temp_dir.glob("request.*.json"):
+ with request.open("r") as f:
+ data: dict[str, Any] = json.load(f)
+ if data["path"].startswith("/_test"):
+ continue
+ logger.debug("Checking request data: %s", data)
+ assert all([k, v] in data["headers_list"] for k, v in header.items())
+ break
+ else:
+ msg = "No request found"
+ raise AssertionError(msg)
diff --git a/tests/v3/compatibility_suite/util/__init__.py b/tests/v3/compatibility_suite/util/__init__.py
index 919543db5..1f47080b2 100644
--- a/tests/v3/compatibility_suite/util/__init__.py
+++ b/tests/v3/compatibility_suite/util/__init__.py
@@ -21,19 +21,25 @@ def _():
from __future__ import annotations
+import base64
import contextlib
import hashlib
import logging
+import sys
import typing
+from collections.abc import Collection, Mapping
+from datetime import date, datetime, time
from pathlib import Path
+from typing import Any
from xml.etree import ElementTree
+import flask
+from flask import request
from multidict import MultiDict
from yarl import URL
if typing.TYPE_CHECKING:
- import pact.v3
- import pact.v3.pact
+ from pact.v3.pact import Pact
logger = logging.getLogger(__name__)
SUITE_ROOT = Path(__file__).parent.parent / "definition"
@@ -96,7 +102,7 @@ def truncate(data: str | bytes) -> str:
"""
if len(data) <= 32:
if isinstance(data, str):
- return f"{data!r}"
+ return f"{data}"
return data.decode("utf-8", "backslashreplace")
length = len(data)
@@ -149,6 +155,92 @@ def parse_markdown_table(content: str) -> list[dict[str, str]]:
return [dict(zip(rows[0], row)) for row in rows[1:]]
+def serialize(obj: Any) -> Any: # noqa: ANN401, PLR0911
+ """
+ Convert an object to a dictionary.
+
+ This function converts an object to a dictionary by calling `vars` on the
+ object. This is useful for classes which are not otherwise serializable
+ using `json.dumps`.
+
+ A few special cases are handled:
+
+ - If the object is a `datetime` object, it is converted to an ISO 8601
+ string.
+ - All forms of [`Mapping`][collections.abc.Mapping] are converted to
+ dictionaries.
+ - All forms of [`Collection`][collections.abc.Collection] are converted to
+ lists.
+
+ All other types are converted to strings using the `repr` function.
+ """
+ if isinstance(obj, (datetime, date, time)):
+ return obj.isoformat()
+
+ # Basic types which are already serializable
+ if isinstance(obj, (str, int, float, bool, type(None))):
+ return obj
+
+ # Bytes
+ if isinstance(obj, bytes):
+ return {
+ "__class__": obj.__class__.__name__,
+ "data": base64.b64encode(obj).decode("utf-8"),
+ }
+
+ # Collections
+ if isinstance(obj, Mapping):
+ return {k: serialize(v) for k, v in obj.items()}
+
+ if isinstance(obj, Collection):
+ return [serialize(v) for v in obj]
+
+ # Objects
+ if hasattr(obj, "__dict__"):
+ return {
+ "__class__": obj.__class__.__name__,
+ "__module__": obj.__class__.__module__,
+ **{k: serialize(v) for k, v in obj.__dict__.items()},
+ }
+
+ return repr(obj)
+
+
+def parse_headers(headers: str) -> MultiDict[str]:
+ """
+ Parse the headers.
+
+ The headers are in the format:
+
+ ```text
+ 'X-A: 1', 'X-B: 2', 'X-A: 3'
+ ```
+
+ As headers can be repeated, the result is a MultiDict.
+ """
+ kvs: list[tuple[str, str]] = []
+ for header in headers.split(", "):
+ k, _sep, v = header.strip("'").partition(": ")
+ kvs.append((k, v))
+ return MultiDict(kvs)
+
+
+def parse_matching_rules(matching_rules: str) -> str:
+ """
+ Parse the matching rules.
+
+ The matching rules are in one of two formats:
+
+ - An explicit JSON object, prefixed by `JSON: `.
+ - A fixture file which contains the matching rules.
+ """
+ if matching_rules.startswith("JSON: "):
+ return matching_rules[6:]
+
+ with (FIXTURES_ROOT / matching_rules).open("r") as file:
+ return file.read()
+
+
class InteractionDefinition:
"""
Interaction definition.
@@ -269,13 +361,14 @@ def parse_file(self, file: Path) -> None:
def __init__(self, **kwargs: str) -> None:
"""Initialise the interaction definition."""
self.id: int | None = None
+ self.state: str | None = None
self.method: str = kwargs.pop("method")
self.path: str = kwargs.pop("path")
self.response: int = int(kwargs.pop("response", 200))
self.query: str | None = None
self.headers: MultiDict[str] = MultiDict()
self.body: InteractionDefinition.Body | None = None
- self.response_content: str | None = None
+ self.response_headers: MultiDict[str] = MultiDict()
self.response_body: InteractionDefinition.Body | None = None
self.matching_rules: str | None = None
self.update(**kwargs)
@@ -300,12 +393,12 @@ def update(self, **kwargs: str) -> None: # noqa: C901, PLR0912
self.query = query
if headers := kwargs.pop("headers", None):
- self.headers = self.parse_headers(headers)
+ self.headers = parse_headers(headers)
if headers := (
kwargs.pop("raw headers", None) or kwargs.pop("raw_headers", None)
):
- self.headers = self.parse_headers(headers)
+ self.headers = parse_headers(headers)
if body := kwargs.pop("body", None):
# When updating the body, we _only_ update the body content, not
@@ -321,25 +414,36 @@ def update(self, **kwargs: str) -> None: # noqa: C901, PLR0912
self.body = InteractionDefinition.Body("")
self.body.mime_type = content_type
- if response := kwargs.pop("response", None):
+ if response := kwargs.pop("response", None) or kwargs.pop("status", None):
self.response = int(response)
+ if response_headers := (
+ kwargs.pop("response_headers", None) or kwargs.pop("response headers", None)
+ ):
+ self.response_headers = parse_headers(response_headers)
+
if response_content := (
kwargs.pop("response_content", None) or kwargs.pop("response content", None)
):
- self.response_content = response_content
+ if self.response_body is None:
+ self.response_body = InteractionDefinition.Body("")
+ self.response_body.mime_type = response_content
if response_body := (
kwargs.pop("response_body", None) or kwargs.pop("response body", None)
):
+ orig_content_type = (
+ self.response_body.mime_type if self.response_body else None
+ )
self.response_body = InteractionDefinition.Body(response_body)
+ self.response_body.mime_type = (
+ self.response_body.mime_type or orig_content_type
+ )
if matching_rules := (
kwargs.pop("matching_rules", None) or kwargs.pop("matching rules", None)
):
- self.matching_rules = InteractionDefinition.parse_matching_rules(
- matching_rules
- )
+ self.matching_rules = parse_matching_rules(matching_rules)
if len(kwargs) > 0:
msg = f"Unexpected arguments: {kwargs.keys()}"
@@ -353,42 +457,7 @@ def __repr__(self) -> str:
", ".join(f"{k}={v!r}" for k, v in vars(self).items()),
)
- @classmethod
- def parse_headers(cls, headers: str) -> MultiDict[str]:
- """
- Parse the headers.
-
- The headers are in the format:
-
- ```text
- 'X-A: 1', 'X-B: 2', 'X-A: 3'
- ```
-
- As headers can be repeated, the result is a MultiDict.
- """
- kvs: list[tuple[str, str]] = []
- for header in headers.split(", "):
- k, _sep, v = header.strip("'").partition(": ")
- kvs.append((k, v))
- return MultiDict(kvs)
-
- @classmethod
- def parse_matching_rules(cls, matching_rules: str) -> str:
- """
- Parse the matching rules.
-
- The matching rules are in one of two formats:
-
- - An explicit JSON object, prefixed by `JSON: `.
- - A fixture file which contains the matching rules.
- """
- if matching_rules.startswith("JSON: "):
- return matching_rules[6:]
-
- with (FIXTURES_ROOT / matching_rules).open("r") as file:
- return file.read()
-
- def add_to_pact(self, pact: pact.v3.Pact, name: str) -> None: # noqa: PLR0912, C901
+ def add_to_pact(self, pact: Pact, name: str) -> None: # noqa: C901, PLR0912
"""
Add the interaction to the pact.
@@ -407,6 +476,11 @@ def add_to_pact(self, pact: pact.v3.Pact, name: str) -> None: # noqa: PLR0912,
logging.info("with_request(%s, %s)", self.method, self.path)
interaction.with_request(self.method, self.path)
+ # We distinguish between "" and None here.
+ if self.state is not None:
+ logging.info("given(%s)", self.state)
+ interaction.given(self.state)
+
if self.query:
query = URL.build(query_string=self.query).query
logging.info("with_query_parameters(%s)", query.items())
@@ -449,31 +523,90 @@ def add_to_pact(self, pact: pact.v3.Pact, name: str) -> None: # noqa: PLR0912,
logging.info("will_respond_with(%s)", self.response)
interaction.will_respond_with(self.response)
- if self.response_content:
- if self.response_body is None:
- msg = "Expected response body along with response content type"
- raise ValueError(msg)
-
+ if self.response_body:
if self.response_body.string:
logging.info(
"with_body(%s, %s)",
truncate(self.response_body.string),
- self.response_content,
+ self.response_body.mime_type,
)
interaction.with_body(
self.response_body.string,
- self.response_content,
+ self.response_body.mime_type,
)
elif self.response_body.bytes:
logging.info(
"with_binary_file(%s, %s)",
truncate(self.response_body.bytes),
- self.response_content,
+ self.response_body.mime_type,
)
interaction.with_binary_body(
self.response_body.bytes,
- self.response_content,
+ self.response_body.mime_type,
)
else:
msg = "Unexpected body definition"
raise RuntimeError(msg)
+
+ def add_to_flask(self, app: flask.Flask) -> None:
+ """
+ Add an interaction to a Flask app.
+
+ Args:
+ app:
+ The Flask app to add the interaction to.
+ """
+ sys.stderr.write(
+ f"Adding interaction to Flask app: {self.method} {self.path}\n"
+ )
+ sys.stderr.write(f" Query: {self.query}\n")
+ sys.stderr.write(f" Headers: {self.headers}\n")
+ sys.stderr.write(f" Body: {self.body}\n")
+ sys.stderr.write(f" Response: {self.response}\n")
+ sys.stderr.write(f" Response headers: {self.response_headers}\n")
+ sys.stderr.write(f" Response body: {self.response_body}\n")
+
+ def route_fn() -> flask.Response:
+ sys.stderr.write(f"Received request: {self.method} {self.path}\n")
+ if self.query:
+ query = URL.build(query_string=self.query).query
+ # Perform a two-way check to ensure that the query parameters
+ # are present in the request, and that the request contains no
+ # unexpected query parameters.
+ for k, v in query.items():
+ assert request.args[k] == v
+ for k, v in request.args.items():
+ assert query[k] == v
+
+ if self.headers:
+ # Perform a one-way check to ensure that the expected headers
+ # are present in the request, but don't check for any unexpected
+ # headers.
+ for k, v in self.headers.items():
+ assert k in request.headers
+ assert request.headers[k] == v
+
+ if self.body:
+ assert request.data == self.body.bytes
+
+ return flask.Response(
+ response=self.response_body.bytes or self.response_body.string or None
+ if self.response_body
+ else None,
+ status=self.response,
+ headers=self.response_headers,
+ content_type=self.response_body.mime_type
+ if self.response_body
+ else None,
+ direct_passthrough=True,
+ )
+
+ # The route function needs to have a unique name
+ clean_name = self.path.replace("/", "_").replace("__", "_")
+ route_fn.__name__ = f"{self.method.lower()}_{clean_name}"
+
+ app.add_url_rule(
+ self.path,
+ view_func=route_fn,
+ methods=[self.method],
+ )
diff --git a/tests/v3/compatibility_suite/util/pact-broker.yml b/tests/v3/compatibility_suite/util/pact-broker.yml
new file mode 100644
index 000000000..53b3f6d72
--- /dev/null
+++ b/tests/v3/compatibility_suite/util/pact-broker.yml
@@ -0,0 +1,43 @@
+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
+ depends_on:
+ - postgres
+ ports:
+ - "9292:9292"
+ restart: always
+ 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
diff --git a/tests/v3/compatibility_suite/util/provider.py b/tests/v3/compatibility_suite/util/provider.py
new file mode 100644
index 000000000..2968152b0
--- /dev/null
+++ b/tests/v3/compatibility_suite/util/provider.py
@@ -0,0 +1,446 @@
+"""
+Provider utilities for compatibility suite tests.
+
+The main functionality provided by this module is the ability to start a
+provider application with a set of interactions. Since this is done
+in a subprocess, any configuration must be passed in through files.
+"""
+
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+sys.path.append(str(Path(__file__).parent.parent.parent.parent.parent))
+
+
+import contextlib
+import json
+import logging
+import os
+import pickle
+import shutil
+import socket
+import subprocess
+from contextvars import ContextVar
+from datetime import datetime
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+import flask
+import requests
+from flask import request
+from yarl import URL
+
+import pact.constants # type: ignore[import-untyped]
+from tests.v3.compatibility_suite.util import serialize
+
+if TYPE_CHECKING:
+ from tests.v3.compatibility_suite.util import InteractionDefinition
+
+if sys.version_info < (3, 11):
+ from datetime import timezone
+
+ UTC = timezone.utc
+else:
+ from datetime import UTC
+
+
+logger = logging.getLogger(__name__)
+
+version_var = ContextVar("version_var", default="0")
+
+
+def next_version() -> str:
+ """
+ Get the next version for the consumer.
+
+ This is used to generate a new version for the consumer application to use
+ when publishing the interactions to the Pact Broker.
+
+ Returns:
+ The next version.
+ """
+ version = version_var.get()
+ version_var.set(str(int(version) + 1))
+ return version
+
+
+def _find_free_port() -> int:
+ """
+ Find a free port.
+
+ This is used to find a free port to host the API on when running locally. It
+ is allocated, and then released immediately so that it can be used by the
+ API.
+
+ Returns:
+ The port number.
+ """
+ with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s:
+ s.bind(("", 0))
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ return s.getsockname()[1]
+
+
+class Provider:
+ """
+ HTTP Provider.
+ """
+
+ def __init__(self, provider_dir: Path | str) -> None:
+ """
+ Instantiate a new provider.
+
+ Args:
+ provider_dir:
+ The directory containing various files used to configure the
+ provider. At a minimum, this directory must contain a file
+ called `interactions.pkl`. This file must contain a list of
+ [`InteractionDefinition`] objects.
+ """
+ self.provider_dir = Path(provider_dir)
+ if not self.provider_dir.is_dir():
+ msg = f"Directory {self.provider_dir} does not exist"
+ raise ValueError(msg)
+
+ self.app: flask.Flask = flask.Flask("provider")
+ self._add_ping(self.app)
+ self._add_callback(self.app)
+ self._add_after_request(self.app)
+ self._add_interactions(self.app)
+
+ def _add_ping(self, app: flask.Flask) -> None:
+ """
+ Add a ping endpoint to the provider.
+
+ This is used to check that the provider is running.
+ """
+
+ @app.get("/_test/ping")
+ def ping() -> str:
+ """Simple ping endpoint for testing."""
+ return "pong"
+
+ def _add_callback(self, app: flask.Flask) -> None:
+ """
+ Add a callback endpoint to the provider.
+
+ This is used to receive any callbacks from Pact to configure any
+ internal state (e.g., "given a user exists"). As far as the testing
+ is concerned, this is just a simple endpoint that records the request
+ and returns an empty response.
+
+ If the provider directory contains a file called `fail_callback`, then
+ the callback will return a 404 response.
+
+ If the provider directory contains a file called `provider_state`, then
+ the callback will check that the `state` query parameter matches the
+ contents of the file.
+ """
+
+ @app.route("/_test/callback", methods=["GET", "POST"])
+ def callback() -> tuple[str, int] | str:
+ if (self.provider_dir / "fail_callback").exists():
+ return "Provider state not found", 404
+
+ provider_state_path = self.provider_dir / "provider_state"
+ if provider_state_path.exists():
+ state = provider_state_path.read_text()
+ assert request.args["state"] == state
+
+ json_file = (
+ self.provider_dir
+ / f"callback.{datetime.now(tz=UTC).strftime('%H:%M:%S.%f')}.json"
+ )
+ with json_file.open("w") as f:
+ json.dump(
+ {
+ "method": request.method,
+ "path": request.path,
+ "query_string": request.query_string.decode("utf-8"),
+ "query_params": serialize(request.args),
+ "headers_list": serialize(request.headers),
+ "headers_dict": serialize(dict(request.headers)),
+ "body": request.data.decode("utf-8"),
+ "form": serialize(request.form),
+ },
+ f,
+ )
+
+ return ""
+
+ def _add_after_request(self, app: flask.Flask) -> None:
+ """
+ Add a handler to log requests and responses.
+
+ This is used to log the requests and responses to the provider
+ application (both to the logger as well as to files).
+ """
+
+ @app.after_request
+ def log_request(response: flask.Response) -> flask.Response:
+ sys.stderr.write(f"START REQUEST: {request.method} {request.path}\n")
+ sys.stderr.write(f"Query string: {request.query_string.decode('utf-8')}\n")
+ sys.stderr.write(f"Header: {serialize(request.headers)}\n")
+ sys.stderr.write(f"Body: {request.data.decode('utf-8')}\n")
+ sys.stderr.write(f"Form: {serialize(request.form)}\n")
+ sys.stderr.write("END REQUEST\n")
+
+ with (
+ self.provider_dir
+ / f"request.{datetime.now(tz=UTC).strftime('%H:%M:%S.%f')}.json"
+ ).open("w") as f:
+ json.dump(
+ {
+ "method": request.method,
+ "path": request.path,
+ "query_string": request.query_string.decode("utf-8"),
+ "query_params": serialize(request.args),
+ "headers_list": serialize(request.headers),
+ "headers_dict": serialize(dict(request.headers)),
+ "body": request.data.decode("utf-8"),
+ "form": serialize(request.form),
+ },
+ f,
+ )
+ return response
+
+ @app.after_request
+ def log_response(response: flask.Response) -> flask.Response:
+ sys.stderr.write(f"START RESPONSE: {response.status_code}\n")
+ sys.stderr.write(f"Headers: {serialize(response.headers)}\n")
+ sys.stderr.write(
+ f"Body: {response.get_data().decode('utf-8', errors='replace')}\n"
+ )
+ sys.stderr.write("END RESPONSE\n")
+
+ with (
+ self.provider_dir
+ / f"response.{datetime.now(tz=UTC).strftime('%H:%M:%S.%f')}.json"
+ ).open("w") as f:
+ json.dump(
+ {
+ "status_code": response.status_code,
+ "headers_list": serialize(response.headers),
+ "headers_dict": serialize(dict(response.headers)),
+ "body": response.get_data().decode("utf-8", errors="replace"),
+ },
+ f,
+ )
+ return response
+
+ def _add_interactions(self, app: flask.Flask) -> None:
+ """
+ Add the interactions to the provider.
+ """
+ with (self.provider_dir / "interactions.pkl").open("rb") as f:
+ interactions: list[InteractionDefinition] = pickle.load(f) # noqa: S301
+
+ for interaction in interactions:
+ interaction.add_to_flask(app)
+
+ def run(self) -> None:
+ """
+ Start the provider.
+ """
+ url = URL(f"http://localhost:{_find_free_port()}")
+ self.app.run(
+ host=url.host,
+ port=url.port,
+ debug=True,
+ )
+
+
+class PactBroker:
+ """
+ Interface to the Pact Broker.
+ """
+
+ def __init__( # noqa: PLR0913
+ self,
+ broker_url: URL,
+ *,
+ username: str | None = None,
+ password: str | None = None,
+ provider: str = "provider",
+ consumer: str = "consumer",
+ ) -> None:
+ """
+ Instantiate a new Pact Broker interface.
+ """
+ self.url = broker_url
+ self.username = broker_url.user or username
+ self.password = broker_url.password or password
+ self.provider = provider
+ self.consumer = consumer
+
+ self.broker_bin: str = (
+ shutil.which("pact-broker") or pact.constants.BROKER_CLIENT_PATH
+ )
+ if not self.broker_bin:
+ if "CI" in os.environ:
+ self._install()
+ bin_path = shutil.which("pact-broker")
+ assert bin_path, "pact-broker not found"
+ self.broker_bin = bin_path
+ else:
+ msg = "pact-broker not found"
+ raise RuntimeError(msg)
+
+ def _install(self) -> None:
+ """
+ Install the Pact Broker CLI tool.
+
+ This function is intended to be run in CI environments, where the pact-broker
+ CLI tool may not be installed already. This will download and extract
+ the tool
+ """
+ msg = "pact-broker not found"
+ raise NotImplementedError(msg)
+
+ def reset(self) -> None:
+ """
+ Reset the Pact Broker.
+
+ This function will reset the Pact Broker by deleting all pacts and
+ verification results.
+ """
+ requests.delete(
+ str(
+ self.url
+ / "integrations"
+ / "provider"
+ / self.provider
+ / "consumer"
+ / self.consumer
+ ),
+ timeout=2,
+ )
+
+ def publish(self, directory: Path | str, version: str | None = None) -> None:
+ """
+ Publish the interactions to the Pact Broker.
+
+ Args:
+ directory:
+ The directory containing the pact files.
+
+ version:
+ The version of the consumer application.
+ """
+ cmd = [
+ self.broker_bin,
+ "publish",
+ str(directory),
+ "--broker-base-url",
+ str(self.url.with_user(None).with_password(None)),
+ ]
+ if self.username:
+ cmd.extend(["--broker-username", self.username])
+ if self.password:
+ cmd.extend(["--broker-password", self.password])
+
+ cmd.extend(["--consumer-app-version", version or next_version()])
+
+ subprocess.run(
+ cmd, # noqa: S603
+ encoding="utf-8",
+ check=True,
+ )
+
+ def interaction_id(self, num: int) -> str:
+ """
+ Find the interaction ID for the given interaction.
+
+ This function is used to find the Pact Broker interaction ID for the given
+ interaction. It does this by looking for the interaction with the
+ description `f"interaction {num}"`.
+
+ Args:
+ num:
+ The ID of the interaction.
+ """
+ response = requests.get(
+ str(
+ self.url
+ / "pacts"
+ / "provider"
+ / self.provider
+ / "consumer"
+ / self.consumer
+ / "latest"
+ ),
+ timeout=2,
+ )
+ response.raise_for_status()
+ for interaction in response.json()["interactions"]:
+ if interaction["description"] == f"interaction {num}":
+ return interaction["_id"]
+ msg = f"Interaction {num} not found"
+ raise ValueError(msg)
+
+ def verification_results(self, num: int) -> requests.Response:
+ """
+ Fetch the verification results for the given interaction.
+
+ Args:
+ num:
+ The ID of the interaction.
+ """
+ interaction_id = self.interaction_id(num)
+ response = requests.get(
+ str(
+ self.url
+ / "pacts"
+ / "provider"
+ / self.provider
+ / "consumer"
+ / self.consumer
+ / "latest"
+ / "verification-results"
+ / interaction_id
+ ),
+ timeout=2,
+ )
+ response.raise_for_status()
+ return response
+
+ def latest_verification_results(self) -> requests.Response | None:
+ """
+ Fetch the latest verification results for the provider.
+
+ If there are no verification results, then this function will return
+ `None`.
+ """
+ response = requests.get(
+ str(
+ self.url
+ / "pacts"
+ / "provider"
+ / self.provider
+ / "consumer"
+ / self.consumer
+ / "latest"
+ ),
+ timeout=2,
+ )
+ response.raise_for_status()
+ links = response.json()["_links"]
+ response = requests.get(
+ links["pb:latest-verification-results"]["href"], timeout=2
+ )
+ if response.status_code == 404:
+ return None
+ response.raise_for_status()
+ return response
+
+
+if __name__ == "__main__":
+ import sys
+
+ if len(sys.argv) != 2:
+ sys.stderr.write(f"Usage: {sys.argv[0]} ")
+ sys.exit(1)
+
+ Provider(sys.argv[1]).run()