From 71d61f9fcdd350767adc8ac1a0c6e3df1c7de752 Mon Sep 17 00:00:00 2001 From: Aditya Giri <74224708+adityagiri3600@users.noreply.github.com> Date: Thu, 26 Feb 2026 09:45:17 +0530 Subject: [PATCH 1/6] docs(examples): add service-as-consumer-provider HTTP example --- examples/README.md | 7 + examples/http/README.md | 1 + .../http/service_consumer_provider/README.md | 59 ++++++++ .../service_consumer_provider/__init__.py | 1 + .../service_consumer_provider/auth_client.py | 50 +++++++ .../service_consumer_provider/conftest.py | 28 ++++ .../frontend_client.py | 63 ++++++++ .../service_consumer_provider/pyproject.toml | 28 ++++ .../test_consumer_auth.py | 87 +++++++++++ .../test_consumer_frontend.py | 103 +++++++++++++ .../test_provider.py | 138 ++++++++++++++++++ .../service_consumer_provider/user_service.py | 137 +++++++++++++++++ 12 files changed, 702 insertions(+) create mode 100644 examples/http/service_consumer_provider/README.md create mode 100644 examples/http/service_consumer_provider/__init__.py create mode 100644 examples/http/service_consumer_provider/auth_client.py create mode 100644 examples/http/service_consumer_provider/conftest.py create mode 100644 examples/http/service_consumer_provider/frontend_client.py create mode 100644 examples/http/service_consumer_provider/pyproject.toml create mode 100644 examples/http/service_consumer_provider/test_consumer_auth.py create mode 100644 examples/http/service_consumer_provider/test_consumer_frontend.py create mode 100644 examples/http/service_consumer_provider/test_provider.py create mode 100644 examples/http/service_consumer_provider/user_service.py diff --git a/examples/README.md b/examples/README.md index 0651f9e45..2af1fdc04 100644 --- a/examples/README.md +++ b/examples/README.md @@ -30,6 +30,13 @@ The code within the examples is intended to be well-documented and you are encou - **Consumer**: requests-based HTTP client - **Provider**: FastAPI-based HTTP server +#### [Service as Consumer and Provider](./http/service_consumer_provider/README.md) + +- **Location**: `examples/http/service_consumer_provider/` +- **Scenario**: A single service (`user-service`) acting as both: + - **Provider** to a frontend client + - **Consumer** of an upstream auth service + ### Message Examples - **Location**: `examples/message/` diff --git a/examples/http/README.md b/examples/http/README.md index 1579c6312..aa7eb21a4 100644 --- a/examples/http/README.md +++ b/examples/http/README.md @@ -6,4 +6,5 @@ This directory contains examples of HTTP-based contract testing with Pact. - [`aiohttp_and_flask/`](aiohttp_and_flask/) - Async aiohttp consumer with Flask provider - [`requests_and_fastapi/`](requests_and_fastapi/) - requests consumer with FastAPI provider +- [`service_consumer_provider/`](service_consumer_provider/) - One service acting as both consumer and provider - [`xml_example/`](xml_example/) - requests consumer with FastAPI provider using XML bodies diff --git a/examples/http/service_consumer_provider/README.md b/examples/http/service_consumer_provider/README.md new file mode 100644 index 000000000..4183303f0 --- /dev/null +++ b/examples/http/service_consumer_provider/README.md @@ -0,0 +1,59 @@ +# Service as Consumer and Provider + +This example demonstrates a common microservice pattern where one service plays both roles in contract testing: + +- **Provider** to a frontend client (`frontend-web -> user-service`) +- **Consumer** of an upstream auth service (`user-service -> auth-service`) + +## Overview + +- [**Frontend Client**][examples.http.service_consumer_provider.frontend_client]: Consumer-facing client used by `frontend-web` +- [**Auth Client**][examples.http.service_consumer_provider.auth_client]: Upstream client used by `user-service` +- [**User Service**][examples.http.service_consumer_provider.user_service]: FastAPI app under test +- [**Frontend Consumer Tests**][examples.http.service_consumer_provider.test_consumer_frontend]: Defines frontend expectations of `user-service` +- [**Auth Consumer Tests**][examples.http.service_consumer_provider.test_consumer_auth]: Defines `user-service` expectations of `auth-service` +- [**Provider Verification**][examples.http.service_consumer_provider.test_provider]: Verifies `user-service` against the frontend pact + +## What This Example Demonstrates + +- One service owning two separate contracts in opposite directions +- Consumer tests for each dependency boundary +- Provider verification with state handlers that model upstream auth behaviour +- Contract scope focused on behaviour used by each consumer + +## Running the Example + +### Using uv (Recommended) + +```console +uv run --group test pytest +``` + +### Using pip + +1. Create and activate a virtual environment: + + ```console + python -m venv .venv + source .venv/bin/activate # On macOS/Linux + .venv\Scripts\activate # On Windows + ``` + +2. Install dependencies: + + ```console + pip install -U pip + pip install --group test -e . + ``` + +3. Run tests: + + ```console + pytest + ``` + +## Related Documentation + +- [Pact Documentation](https://docs.pact.io/) +- [Provider States](https://docs.pact.io/getting_started/provider_states) +- [FastAPI Documentation](https://fastapi.tiangolo.com/) diff --git a/examples/http/service_consumer_provider/__init__.py b/examples/http/service_consumer_provider/__init__.py new file mode 100644 index 000000000..6e031999e --- /dev/null +++ b/examples/http/service_consumer_provider/__init__.py @@ -0,0 +1 @@ +# noqa: D104 diff --git a/examples/http/service_consumer_provider/auth_client.py b/examples/http/service_consumer_provider/auth_client.py new file mode 100644 index 000000000..d118a2fc2 --- /dev/null +++ b/examples/http/service_consumer_provider/auth_client.py @@ -0,0 +1,50 @@ +""" +HTTP client used by user-service to call auth-service. +""" + +from __future__ import annotations + +import requests + + +class AuthClient: + """ + Small HTTP client for auth-service contract interactions. + """ + + def __init__(self, base_url: str) -> None: + """ + Initialise the auth client. + + Args: + base_url: + Base URL of auth-service. + """ + self._base_url = base_url.rstrip("/") + self._session = requests.Session() + + def validate_credentials(self, username: str, password: str) -> bool: + """ + Validate credentials against auth-service. + + Args: + username: + Username to validate. + + password: + Password to validate. + + Returns: + True when credentials are valid; otherwise False. + + Raises: + requests.HTTPError: + If auth-service responds with a non-2xx status. + """ + response = self._session.post( + f"{self._base_url}/auth/validate", + json={"username": username, "password": password}, + ) + response.raise_for_status() + body = response.json() + return bool(body.get("valid", False)) diff --git a/examples/http/service_consumer_provider/conftest.py b/examples/http/service_consumer_provider/conftest.py new file mode 100644 index 000000000..a8fd43edd --- /dev/null +++ b/examples/http/service_consumer_provider/conftest.py @@ -0,0 +1,28 @@ +""" +Shared PyTest configuration for the service-as-consumer/provider example. +""" + +from __future__ import annotations + +from pathlib import Path + +import pact_ffi +import pytest + +EXAMPLE_DIR = Path(__file__).parent.resolve() + + +@pytest.fixture(scope="session") +def pacts_path() -> Path: + """ + Fixture for the Pact directory. + """ + return EXAMPLE_DIR / "pacts" + + +@pytest.fixture(scope="session", autouse=True) +def _setup_pact_logging() -> None: + """ + Set up logging for the pact package. + """ + pact_ffi.log_to_stderr("INFO") diff --git a/examples/http/service_consumer_provider/frontend_client.py b/examples/http/service_consumer_provider/frontend_client.py new file mode 100644 index 000000000..ff99cc25c --- /dev/null +++ b/examples/http/service_consumer_provider/frontend_client.py @@ -0,0 +1,63 @@ +""" +HTTP client representing a frontend calling user-service. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import requests + + +@dataclass +class Account: + """ + Minimal account model used by the frontend. + """ + + id: int + username: str + status: str + + +class FrontendClient: + """ + HTTP client used by frontend-web to call user-service. + """ + + def __init__(self, base_url: str) -> None: + """ + Initialise the frontend client. + + Args: + base_url: + Base URL of user-service. + """ + self._base_url = base_url.rstrip("/") + self._session = requests.Session() + + def create_account(self, username: str, password: str) -> Account: + """ + Create an account through user-service. + + Args: + username: + Desired username. + + password: + Password used during credential validation. + + Returns: + Account data returned by user-service. + + Raises: + requests.HTTPError: + If user-service responds with a non-2xx status. + """ + response = self._session.post( + f"{self._base_url}/accounts", + json={"username": username, "password": password}, + ) + response.raise_for_status() + body = response.json() + return Account(id=body["id"], username=body["username"], status=body["status"]) diff --git a/examples/http/service_consumer_provider/pyproject.toml b/examples/http/service_consumer_provider/pyproject.toml new file mode 100644 index 000000000..8b6ea33f4 --- /dev/null +++ b/examples/http/service_consumer_provider/pyproject.toml @@ -0,0 +1,28 @@ +#:schema https://www.schemastore.org/pyproject.json +[project] +name = "example-service-consumer-provider" + +description = "Example of a service acting as both a Pact consumer and provider" + +dependencies = ["fastapi~=0.0", "requests~=2.0", "typing-extensions~=4.0"] +requires-python = ">=3.10" +version = "1.0.0" + +[dependency-groups] + +test = ["pact-python", "pytest~=9.0", "uvicorn~=0.29"] + +[tool.uv.sources] +pact-python = { path = "../../../" } + +[tool.ruff] +extend = "../../../pyproject.toml" + +[tool.pytest] +addopts = ["--import-mode=importlib"] + +asyncio_default_fixture_loop_scope = "session" + +log_date_format = "%H:%M:%S" +log_format = "%(asctime)s.%(msecs)03d [%(levelname)-8s] %(name)s: %(message)s" +log_level = "NOTSET" diff --git a/examples/http/service_consumer_provider/test_consumer_auth.py b/examples/http/service_consumer_provider/test_consumer_auth.py new file mode 100644 index 000000000..3220f3b2b --- /dev/null +++ b/examples/http/service_consumer_provider/test_consumer_auth.py @@ -0,0 +1,87 @@ +""" +Consumer contract tests for user-service -> auth-service. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from examples.http.service_consumer_provider.auth_client import AuthClient +from pact import Pact + +if TYPE_CHECKING: + from collections.abc import Generator + from pathlib import Path + + +@pytest.fixture +def pact(pacts_path: Path) -> Generator[Pact, None, None]: + """ + Pact fixture for user-service as consumer. + + Args: + pacts_path: + Directory where Pact files are written. + + Yields: + Pact configured for user-service -> auth-service. + """ + pact = Pact("user-service", "auth-service").with_specification("V4") + yield pact + pact.write_file(pacts_path) + + +@pytest.mark.parametrize( + ("password", "expected_valid"), + [ + pytest.param("correct-horse-battery-staple", True, id="valid"), + pytest.param("wrong-password", False, id="invalid"), + ], +) +def test_validate_credentials(pact: Pact, password: str, expected_valid: bool) -> None: + """ + Verify user-service auth client contract. + + Args: + pact: + Pact fixture. + + password: + Password sent to auth-service. + + expected_valid: + Expected validation result. + """ + state = ( + "user credentials are valid" + if expected_valid + else "user credentials are invalid" + ) + + ( + pact + .upon_receiving(f"Credential validation for {state}") + .given(state) + .with_request("POST", "/auth/validate") + .with_body( + { + "username": "alice", + "password": password, + }, + content_type="application/json", + ) + .will_respond_with(200) + .with_body( + { + "valid": expected_valid, + "subject": "alice", + }, + content_type="application/json", + ) + ) + + with pact.serve() as srv: + client = AuthClient(str(srv.url)) + assert client.validate_credentials("alice", password) is expected_valid diff --git a/examples/http/service_consumer_provider/test_consumer_frontend.py b/examples/http/service_consumer_provider/test_consumer_frontend.py new file mode 100644 index 000000000..a6bf8d60b --- /dev/null +++ b/examples/http/service_consumer_provider/test_consumer_frontend.py @@ -0,0 +1,103 @@ +""" +Consumer contract tests for frontend-web -> user-service. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +import requests + +from examples.http.service_consumer_provider.frontend_client import FrontendClient +from pact import Pact, match + +if TYPE_CHECKING: + from collections.abc import Generator + from pathlib import Path + + +@pytest.fixture +def pact(pacts_path: Path) -> Generator[Pact, None, None]: + """ + Pact fixture for frontend-web as consumer. + + Args: + pacts_path: + Directory where Pact files are written. + + Yields: + Pact configured for frontend-web -> user-service. + """ + pact = Pact("frontend-web", "user-service").with_specification("V4") + yield pact + pact.write_file(pacts_path) + + +def test_create_account_success(pact: Pact) -> None: + """ + Verify frontend behaviour when credentials are valid. + + Args: + pact: + Pact fixture. + """ + ( + pact + .upon_receiving("A request to create an account") + .given("auth accepts credentials") + .with_request("POST", "/accounts") + .with_body( + { + "username": "alice", + "password": "correct-horse-battery-staple", + }, + content_type="application/json", + ) + .will_respond_with(201) + .with_body( + { + "id": match.int(1001), + "username": "alice", + "status": "created", + }, + content_type="application/json", + ) + ) + + with pact.serve() as srv: + client = FrontendClient(str(srv.url)) + account = client.create_account("alice", "correct-horse-battery-staple") + assert account.id == 1001 + assert account.username == "alice" + assert account.status == "created" + + +def test_create_account_invalid_credentials(pact: Pact) -> None: + """ + Verify frontend behaviour when credentials are invalid. + + Args: + pact: + Pact fixture. + """ + ( + pact + .upon_receiving("A request with invalid credentials") + .given("auth rejects credentials") + .with_request("POST", "/accounts") + .with_body( + { + "username": "alice", + "password": "wrong-password", + }, + content_type="application/json", + ) + .will_respond_with(401) + .with_body({"detail": "Invalid credentials"}, content_type="application/json") + ) + + with pact.serve() as srv: + client = FrontendClient(str(srv.url)) + with pytest.raises(requests.HTTPError): + client.create_account("alice", "wrong-password") diff --git a/examples/http/service_consumer_provider/test_provider.py b/examples/http/service_consumer_provider/test_provider.py new file mode 100644 index 000000000..f57c1c752 --- /dev/null +++ b/examples/http/service_consumer_provider/test_provider.py @@ -0,0 +1,138 @@ +""" +Provider verification for user-service against frontend-web contract. +""" + +from __future__ import annotations + +import contextlib +import time +from threading import Thread +from typing import TYPE_CHECKING + +import pytest +import requests +import uvicorn + +import pact._util +from examples.http.service_consumer_provider.user_service import ( + app, + reset_state, + set_auth_verifier, +) +from pact import Verifier + +if TYPE_CHECKING: + from pathlib import Path + + +class StubAuthVerifier: + """ + Test verifier used by provider state handlers. + """ + + def __init__(self, valid: bool) -> None: + """ + Create a stub verifier. + + Args: + valid: + Result to return for all validations. + """ + self._valid = valid + + def validate_credentials(self, username: str, password: str) -> bool: + """ + Validate credentials. + + Args: + username: + Ignored in this stub. + + password: + Ignored in this stub. + + Returns: + The configured validation result. + """ + del username, password + return self._valid + + +@pytest.fixture(scope="session") +def app_server() -> str: + """ + Run the FastAPI server used for provider verification. + + Returns: + Base URL for user-service. + """ + hostname = "localhost" + port = pact._util.find_free_port() # noqa: SLF001 + Thread( + target=uvicorn.run, + args=(app,), + kwargs={"host": hostname, "port": port}, + daemon=True, + ).start() + + base_url = f"http://{hostname}:{port}" + for _ in range(50): + with contextlib.suppress(requests.RequestException): + response = requests.get(f"{base_url}/docs", timeout=0.2) + if response.status_code < 500: + return base_url + time.sleep(0.1) + + msg = f"user-service did not start at {base_url}" + raise RuntimeError(msg) + + +def set_auth_accepts(_parameters: dict[str, object] | None) -> None: + """ + Provider state: auth-service accepts credentials. + + Args: + _parameters: + Optional Pact state parameters. + """ + reset_state() + set_auth_verifier(StubAuthVerifier(valid=True)) + + +def set_auth_rejects(_parameters: dict[str, object] | None) -> None: + """ + Provider state: auth-service rejects credentials. + + Args: + _parameters: + Optional Pact state parameters. + """ + reset_state() + set_auth_verifier(StubAuthVerifier(valid=False)) + + +def test_provider(app_server: str, pacts_path: Path) -> None: + """ + Verify user-service against frontend-web consumer contract. + + Args: + app_server: + Base URL of the running provider. + + pacts_path: + Directory containing generated Pact files. + """ + verifier = ( + Verifier("user-service") + .add_source(pacts_path) + .add_transport(url=app_server) + .state_handler( + { + "auth accepts credentials": set_auth_accepts, + "auth rejects credentials": set_auth_rejects, + }, + teardown=False, + ) + ) + + verifier.verify() diff --git a/examples/http/service_consumer_provider/user_service.py b/examples/http/service_consumer_provider/user_service.py new file mode 100644 index 000000000..e67683b26 --- /dev/null +++ b/examples/http/service_consumer_provider/user_service.py @@ -0,0 +1,137 @@ +""" +FastAPI service that acts as both a consumer and a provider. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol + +from fastapi import FastAPI, HTTPException, status +from pydantic import BaseModel + +from examples.http.service_consumer_provider.auth_client import AuthClient + + +class CredentialsVerifier(Protocol): + """ + Behaviour required for credential verification. + """ + + def validate_credentials(self, username: str, password: str) -> bool: + """ + Validate credentials. + """ + + +@dataclass +class UserAccount: + """ + Stored account record. + """ + + id: int + username: str + + +class InMemoryAccountStore: + """ + Small in-memory store for example purposes. + """ + + def __init__(self) -> None: + """ + Initialise the in-memory store. + """ + self._next_id = 1 + self._accounts: dict[int, UserAccount] = {} + + def create(self, username: str) -> UserAccount: + """ + Create and store a new account. + + Args: + username: + Username for the new account. + + Returns: + The created account. + """ + account = UserAccount(id=self._next_id, username=username) + self._accounts[account.id] = account + self._next_id += 1 + return account + + def reset(self) -> None: + """ + Reset all stored accounts. + """ + self._next_id = 1 + self._accounts.clear() + + +class CreateAccountRequest(BaseModel): + """ + Request payload used by frontend-web. + """ + + username: str + password: str + + +class CreateAccountResponse(BaseModel): + """ + Response payload returned to frontend-web. + """ + + id: int + username: str + status: str = "created" + + +ACCOUNT_STORE = InMemoryAccountStore() +AUTH_VERIFIER: CredentialsVerifier = AuthClient("http://auth-service") + +app = FastAPI() + + +def set_auth_verifier(verifier: CredentialsVerifier) -> None: + """ + Replace the auth verifier implementation. + + Args: + verifier: + New verifier implementation. + """ + global AUTH_VERIFIER + AUTH_VERIFIER = verifier + + +def reset_state() -> None: + """ + Reset internal provider state. + """ + ACCOUNT_STORE.reset() + + +@app.post("/accounts", status_code=status.HTTP_201_CREATED) +async def create_account(payload: CreateAccountRequest) -> CreateAccountResponse: + """ + Create an account after validating credentials with auth-service. + + Args: + payload: + Account request payload. + + Returns: + Created account response. + + Raises: + HTTPException: + If credentials are invalid. + """ + if not AUTH_VERIFIER.validate_credentials(payload.username, payload.password): + raise HTTPException(status_code=401, detail="Invalid credentials") + + account = ACCOUNT_STORE.create(payload.username) + return CreateAccountResponse(id=account.id, username=account.username) From 9a6de7860fc22fe48eea4360918f432dda7a82a6 Mon Sep 17 00:00:00 2001 From: Aditya Giri <74224708+adityagiri3600@users.noreply.github.com> Date: Thu, 26 Feb 2026 09:49:29 +0530 Subject: [PATCH 2/6] test(examples): fix provider state handler signatures --- examples/http/service_consumer_provider/pyproject.toml | 2 -- .../http/service_consumer_provider/test_provider.py | 10 ++++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/examples/http/service_consumer_provider/pyproject.toml b/examples/http/service_consumer_provider/pyproject.toml index 8b6ea33f4..96ece6ee1 100644 --- a/examples/http/service_consumer_provider/pyproject.toml +++ b/examples/http/service_consumer_provider/pyproject.toml @@ -21,8 +21,6 @@ extend = "../../../pyproject.toml" [tool.pytest] addopts = ["--import-mode=importlib"] -asyncio_default_fixture_loop_scope = "session" - log_date_format = "%H:%M:%S" log_format = "%(asctime)s.%(msecs)03d [%(levelname)-8s] %(name)s: %(message)s" log_level = "NOTSET" diff --git a/examples/http/service_consumer_provider/test_provider.py b/examples/http/service_consumer_provider/test_provider.py index f57c1c752..c59280a66 100644 --- a/examples/http/service_consumer_provider/test_provider.py +++ b/examples/http/service_consumer_provider/test_provider.py @@ -87,26 +87,28 @@ def app_server() -> str: raise RuntimeError(msg) -def set_auth_accepts(_parameters: dict[str, object] | None) -> None: +def set_auth_accepts(parameters: dict[str, object] | None = None) -> None: """ Provider state: auth-service accepts credentials. Args: - _parameters: + parameters: Optional Pact state parameters. """ + del parameters reset_state() set_auth_verifier(StubAuthVerifier(valid=True)) -def set_auth_rejects(_parameters: dict[str, object] | None) -> None: +def set_auth_rejects(parameters: dict[str, object] | None = None) -> None: """ Provider state: auth-service rejects credentials. Args: - _parameters: + parameters: Optional Pact state parameters. """ + del parameters reset_state() set_auth_verifier(StubAuthVerifier(valid=False)) From 0520284353e044eda4afabe5affaa1eb20c27d58 Mon Sep 17 00:00:00 2001 From: Aditya Giri <74224708+adityagiri3600@users.noreply.github.com> Date: Sun, 5 Apr 2026 10:20:30 +0530 Subject: [PATCH 3/6] chore(examples): satisfy lint rules in service consumer/provider example --- .../service_consumer_provider/conftest.py | 3 ++- .../test_consumer_auth.py | 7 +++++- .../test_provider.py | 2 +- .../service_consumer_provider/user_service.py | 24 +++++++++++++++---- 4 files changed, 29 insertions(+), 7 deletions(-) diff --git a/examples/http/service_consumer_provider/conftest.py b/examples/http/service_consumer_provider/conftest.py index a8fd43edd..448a46d32 100644 --- a/examples/http/service_consumer_provider/conftest.py +++ b/examples/http/service_consumer_provider/conftest.py @@ -6,9 +6,10 @@ from pathlib import Path -import pact_ffi import pytest +import pact_ffi + EXAMPLE_DIR = Path(__file__).parent.resolve() diff --git a/examples/http/service_consumer_provider/test_consumer_auth.py b/examples/http/service_consumer_provider/test_consumer_auth.py index 3220f3b2b..595ac2348 100644 --- a/examples/http/service_consumer_provider/test_consumer_auth.py +++ b/examples/http/service_consumer_provider/test_consumer_auth.py @@ -40,7 +40,12 @@ def pact(pacts_path: Path) -> Generator[Pact, None, None]: pytest.param("wrong-password", False, id="invalid"), ], ) -def test_validate_credentials(pact: Pact, password: str, expected_valid: bool) -> None: +def test_validate_credentials( + pact: Pact, + password: str, + *, + expected_valid: bool, +) -> None: """ Verify user-service auth client contract. diff --git a/examples/http/service_consumer_provider/test_provider.py b/examples/http/service_consumer_provider/test_provider.py index c59280a66..ee4e22154 100644 --- a/examples/http/service_consumer_provider/test_provider.py +++ b/examples/http/service_consumer_provider/test_provider.py @@ -30,7 +30,7 @@ class StubAuthVerifier: Test verifier used by provider state handlers. """ - def __init__(self, valid: bool) -> None: + def __init__(self, *, valid: bool) -> None: """ Create a stub verifier. diff --git a/examples/http/service_consumer_provider/user_service.py b/examples/http/service_consumer_provider/user_service.py index e67683b26..643953523 100644 --- a/examples/http/service_consumer_provider/user_service.py +++ b/examples/http/service_consumer_provider/user_service.py @@ -90,7 +90,21 @@ class CreateAccountResponse(BaseModel): ACCOUNT_STORE = InMemoryAccountStore() -AUTH_VERIFIER: CredentialsVerifier = AuthClient("http://auth-service") + + +class _ServiceState: + """ + Mutable state used by provider-state handlers in tests. + """ + + def __init__(self) -> None: + """ + Initialise default collaborators. + """ + self.auth_verifier: CredentialsVerifier = AuthClient("http://auth-service") + + +SERVICE_STATE = _ServiceState() app = FastAPI() @@ -103,8 +117,7 @@ def set_auth_verifier(verifier: CredentialsVerifier) -> None: verifier: New verifier implementation. """ - global AUTH_VERIFIER - AUTH_VERIFIER = verifier + SERVICE_STATE.auth_verifier = verifier def reset_state() -> None: @@ -130,7 +143,10 @@ async def create_account(payload: CreateAccountRequest) -> CreateAccountResponse HTTPException: If credentials are invalid. """ - if not AUTH_VERIFIER.validate_credentials(payload.username, payload.password): + if not SERVICE_STATE.auth_verifier.validate_credentials( + payload.username, + payload.password, + ): raise HTTPException(status_code=401, detail="Invalid credentials") account = ACCOUNT_STORE.create(payload.username) From 3c212d40a918ff12f38920ef9d136c61f6bcc082 Mon Sep 17 00:00:00 2001 From: Aditya Giri <74224708+adityagiri3600@users.noreply.github.com> Date: Mon, 6 Apr 2026 10:35:09 +0530 Subject: [PATCH 4/6] docs(examples): fix markdownlint in service example README --- .../http/service_consumer_provider/README.md | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/examples/http/service_consumer_provider/README.md b/examples/http/service_consumer_provider/README.md index 4183303f0..c7c6f2ce4 100644 --- a/examples/http/service_consumer_provider/README.md +++ b/examples/http/service_consumer_provider/README.md @@ -2,24 +2,24 @@ This example demonstrates a common microservice pattern where one service plays both roles in contract testing: -- **Provider** to a frontend client (`frontend-web -> user-service`) -- **Consumer** of an upstream auth service (`user-service -> auth-service`) +- **Provider** to a frontend client (`frontend-web -> user-service`) +- **Consumer** of an upstream auth service (`user-service -> auth-service`) ## Overview -- [**Frontend Client**][examples.http.service_consumer_provider.frontend_client]: Consumer-facing client used by `frontend-web` -- [**Auth Client**][examples.http.service_consumer_provider.auth_client]: Upstream client used by `user-service` -- [**User Service**][examples.http.service_consumer_provider.user_service]: FastAPI app under test -- [**Frontend Consumer Tests**][examples.http.service_consumer_provider.test_consumer_frontend]: Defines frontend expectations of `user-service` -- [**Auth Consumer Tests**][examples.http.service_consumer_provider.test_consumer_auth]: Defines `user-service` expectations of `auth-service` -- [**Provider Verification**][examples.http.service_consumer_provider.test_provider]: Verifies `user-service` against the frontend pact +- [**Frontend Client**][examples.http.service_consumer_provider.frontend_client]: Consumer-facing client used by `frontend-web` +- [**Auth Client**][examples.http.service_consumer_provider.auth_client]: Upstream client used by `user-service` +- [**User Service**][examples.http.service_consumer_provider.user_service]: FastAPI app under test +- [**Frontend Consumer Tests**][examples.http.service_consumer_provider.test_consumer_frontend]: Defines frontend expectations of `user-service` +- [**Auth Consumer Tests**][examples.http.service_consumer_provider.test_consumer_auth]: Defines `user-service` expectations of `auth-service` +- [**Provider Verification**][examples.http.service_consumer_provider.test_provider]: Verifies `user-service` against the frontend pact ## What This Example Demonstrates -- One service owning two separate contracts in opposite directions -- Consumer tests for each dependency boundary -- Provider verification with state handlers that model upstream auth behaviour -- Contract scope focused on behaviour used by each consumer +- One service owning two separate contracts in opposite directions +- Consumer tests for each dependency boundary +- Provider verification with state handlers that model upstream auth behaviour +- Contract scope focused on behaviour used by each consumer ## Running the Example @@ -31,7 +31,7 @@ uv run --group test pytest ### Using pip -1. Create and activate a virtual environment: +1. Create and activate a virtual environment: ```console python -m venv .venv @@ -39,14 +39,14 @@ uv run --group test pytest .venv\Scripts\activate # On Windows ``` -2. Install dependencies: +1. Install dependencies: ```console pip install -U pip pip install --group test -e . ``` -3. Run tests: +1. Run tests: ```console pytest @@ -54,6 +54,6 @@ uv run --group test pytest ## Related Documentation -- [Pact Documentation](https://docs.pact.io/) -- [Provider States](https://docs.pact.io/getting_started/provider_states) -- [FastAPI Documentation](https://fastapi.tiangolo.com/) +- [Pact Documentation](https://docs.pact.io/) +- [Provider States](https://docs.pact.io/getting_started/provider_states) +- [FastAPI Documentation](https://fastapi.tiangolo.com/) From b5bd1f52f00cf14505d2b4805d9ae787089c570a Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Tue, 14 Apr 2026 14:24:05 +1000 Subject: [PATCH 5/6] fix(examples): suppress RuntimeError in pact_ffi log setup Signed-off-by: JP-Ellis --- examples/http/service_consumer_provider/conftest.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/examples/http/service_consumer_provider/conftest.py b/examples/http/service_consumer_provider/conftest.py index 448a46d32..0182939b6 100644 --- a/examples/http/service_consumer_provider/conftest.py +++ b/examples/http/service_consumer_provider/conftest.py @@ -4,6 +4,7 @@ from __future__ import annotations +import contextlib from pathlib import Path import pytest @@ -17,6 +18,9 @@ def pacts_path() -> Path: """ Fixture for the Pact directory. + + Returns: + Path to the directory where Pact contract files are written. """ return EXAMPLE_DIR / "pacts" @@ -26,4 +30,8 @@ def _setup_pact_logging() -> None: """ Set up logging for the pact package. """ - pact_ffi.log_to_stderr("INFO") + # If the logger is already configured (e.g., when running alongside other + # examples), log_to_stderr raises RuntimeError. We suppress it here so + # that the first configuration wins. + with contextlib.suppress(RuntimeError): + pact_ffi.log_to_stderr("INFO") From 57e3492e0bc6f4b35887c9a32ed781eb5a8ede00 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Tue, 14 Apr 2026 14:26:26 +1000 Subject: [PATCH 6/6] docs(examples): expand text significantly As these examples are intended to be self-contained reference snippets for others, I have greatly expanded on the documentation contained within. Signed-off-by: JP-Ellis --- .../http/service_consumer_provider/README.md | 44 ++------ .../service_consumer_provider/auth_client.py | 38 +++++-- .../frontend_client.py | 53 ++++++++-- .../test_consumer_auth.py | 52 +++++++-- .../test_consumer_frontend.py | 67 +++++++++--- .../test_provider.py | 100 ++++++++++++++++-- .../service_consumer_provider/user_service.py | 44 +++++++- tests/compatibility_suite/definition | 2 +- 8 files changed, 318 insertions(+), 82 deletions(-) diff --git a/examples/http/service_consumer_provider/README.md b/examples/http/service_consumer_provider/README.md index c7c6f2ce4..78a9a1398 100644 --- a/examples/http/service_consumer_provider/README.md +++ b/examples/http/service_consumer_provider/README.md @@ -2,58 +2,36 @@ This example demonstrates a common microservice pattern where one service plays both roles in contract testing: -- **Provider** to a frontend client (`frontend-web -> user-service`) -- **Consumer** of an upstream auth service (`user-service -> auth-service`) +- **Provider** to a frontend client (`frontend-web → user-service`) +- **Consumer** of an upstream auth service (`user-service → auth-service`) ## Overview - [**Frontend Client**][examples.http.service_consumer_provider.frontend_client]: Consumer-facing client used by `frontend-web` -- [**Auth Client**][examples.http.service_consumer_provider.auth_client]: Upstream client used by `user-service` -- [**User Service**][examples.http.service_consumer_provider.user_service]: FastAPI app under test -- [**Frontend Consumer Tests**][examples.http.service_consumer_provider.test_consumer_frontend]: Defines frontend expectations of `user-service` -- [**Auth Consumer Tests**][examples.http.service_consumer_provider.test_consumer_auth]: Defines `user-service` expectations of `auth-service` +- [**Auth Client**][examples.http.service_consumer_provider.auth_client]: Upstream client used by `user-service` to call `auth-service` +- [**User Service**][examples.http.service_consumer_provider.user_service]: FastAPI app under test (the service in the middle) +- [**Frontend Consumer Tests**][examples.http.service_consumer_provider.test_consumer_frontend]: Defines `frontend-web`'s expectations of `user-service` +- [**Auth Consumer Tests**][examples.http.service_consumer_provider.test_consumer_auth]: Defines `user-service`'s expectations of `auth-service` - [**Provider Verification**][examples.http.service_consumer_provider.test_provider]: Verifies `user-service` against the frontend pact +Use the links above to view detailed documentation within each file. + ## What This Example Demonstrates - One service owning two separate contracts in opposite directions - Consumer tests for each dependency boundary -- Provider verification with state handlers that model upstream auth behaviour -- Contract scope focused on behaviour used by each consumer +- Provider verification with state handlers that model upstream `auth-service` behaviour without needing it to run +- A `Protocol`-based seam that allows the real FastAPI application to run during verification while the upstream dependency is replaced in-process ## Running the Example -### Using uv (Recommended) - ```console uv run --group test pytest ``` -### Using pip - -1. Create and activate a virtual environment: - - ```console - python -m venv .venv - source .venv/bin/activate # On macOS/Linux - .venv\Scripts\activate # On Windows - ``` - -1. Install dependencies: - - ```console - pip install -U pip - pip install --group test -e . - ``` - -1. Run tests: - - ```console - pytest - ``` - ## Related Documentation - [Pact Documentation](https://docs.pact.io/) - [Provider States](https://docs.pact.io/getting_started/provider_states) - [FastAPI Documentation](https://fastapi.tiangolo.com/) +- [pytest Documentation](https://docs.pytest.org/) diff --git a/examples/http/service_consumer_provider/auth_client.py b/examples/http/service_consumer_provider/auth_client.py index d118a2fc2..b95b60463 100644 --- a/examples/http/service_consumer_provider/auth_client.py +++ b/examples/http/service_consumer_provider/auth_client.py @@ -1,5 +1,16 @@ """ -HTTP client used by user-service to call auth-service. +HTTP client used by `user-service` to call `auth-service`. + +This module is intentionally free of any Pact dependency, it is production code. +The Pact dependency only appears in +[`test_consumer_auth`][examples.http.service_consumer_provider.test_consumer_auth], +which exercises this client against a Pact mock server to define the contract +between [`user_service`][examples.http.service_consumer_provider.user_service] +(consumer) and `auth-service` (provider). + +This also demonstrates the consumer-driven philosophy: the client only requests +and parses the fields it actually needs (`valid`), even though `auth-service` +may return additional information in its response. """ from __future__ import annotations @@ -9,7 +20,16 @@ class AuthClient: """ - Small HTTP client for auth-service contract interactions. + HTTP client for credential validation against `auth-service`. + + This client is used by + [`user_service`][examples.http.service_consumer_provider.user_service] to + verify user credentials before creating accounts. It satisfies the + [`CredentialsVerifier`][examples.http.service_consumer_provider.user_service.CredentialsVerifier] + protocol and is the real implementation that would run in production. + + The matching Pact consumer tests live in + [`test_consumer_auth`][examples.http.service_consumer_provider.test_consumer_auth]. """ def __init__(self, base_url: str) -> None: @@ -18,14 +38,20 @@ def __init__(self, base_url: str) -> None: Args: base_url: - Base URL of auth-service. + Base URL of `auth-service`, e.g. `http://auth-service`. Trailing + slashes are stripped automatically. """ self._base_url = base_url.rstrip("/") self._session = requests.Session() def validate_credentials(self, username: str, password: str) -> bool: """ - Validate credentials against auth-service. + Validate credentials against `auth-service`. + + Sends a `POST /auth/validate` request with the supplied credentials and + returns whether `auth-service` considers them valid. This is the only + field the client reads from the response: an example of how + consumer-driven contracts focus on what the consumer *actually uses*. Args: username: @@ -35,11 +61,11 @@ def validate_credentials(self, username: str, password: str) -> bool: Password to validate. Returns: - True when credentials are valid; otherwise False. + `True` when credentials are valid; otherwise `False`. Raises: requests.HTTPError: - If auth-service responds with a non-2xx status. + If `auth-service` responds with a non-2xx status. """ response = self._session.post( f"{self._base_url}/auth/validate", diff --git a/examples/http/service_consumer_provider/frontend_client.py b/examples/http/service_consumer_provider/frontend_client.py index ff99cc25c..093517ab7 100644 --- a/examples/http/service_consumer_provider/frontend_client.py +++ b/examples/http/service_consumer_provider/frontend_client.py @@ -1,5 +1,20 @@ """ -HTTP client representing a frontend calling user-service. +HTTP client representing `frontend-web` calling `user-service`. + +This module is intentionally free of any Pact dependency; it is production code. +The Pact dependency only appears in +[`test_consumer_frontend`][examples.http.service_consumer_provider.test_consumer_frontend], +which exercises this client against a Pact mock server to define the contract +between `frontend-web` (consumer) and +[`user_service`][examples.http.service_consumer_provider.user_service] +(provider). + +Notice that the +[`Account`][examples.http.service_consumer_provider.frontend_client.Account] +dataclass only captures the fields `frontend-web` cares about (`id`, `username`, +`status`). This is a deliberate illustration of how consumer-driven contracts +differ from testing an OpenAPI specification: the contract describes what *this +consumer* uses, not everything the provider exposes. """ from __future__ import annotations @@ -12,7 +27,13 @@ @dataclass class Account: """ - Minimal account model used by the frontend. + Minimal account model as seen by `frontend-web`. + + This class intentionally reflects only the fields the frontend consumer + needs. It may differ from the internal representation in + [`user_service`][examples.http.service_consumer_provider.user_service], + which stores additional state. This asymmetry is expected and is a key + feature of consumer-driven contract testing. """ id: int @@ -22,7 +43,12 @@ class Account: class FrontendClient: """ - HTTP client used by frontend-web to call user-service. + HTTP client used by `frontend-web` to call `user-service`. + + This client is the consumer under test in + [`test_consumer_frontend`][examples.http.service_consumer_provider.test_consumer_frontend]. + Keeping it free of Pact dependencies means it can be used as-is in + production while the tests handle all contract verification. """ def __init__(self, base_url: str) -> None: @@ -31,28 +57,37 @@ def __init__(self, base_url: str) -> None: Args: base_url: - Base URL of user-service. + Base URL of `user-service`, e.g. `http://user-service`. Trailing + slashes are stripped automatically. """ self._base_url = base_url.rstrip("/") self._session = requests.Session() def create_account(self, username: str, password: str) -> Account: """ - Create an account through user-service. + Create an account through `user-service`. + + Sends a `POST /accounts` request and deserialises the response into an + [`Account`][examples.http.service_consumer_provider.frontend_client.Account]. + Only the `id`, `username`, and `status` fields are read from the + response, and any additional fields returned by the provider are + ignored. Args: username: - Desired username. + Desired username for the new account. password: - Password used during credential validation. + Password forwarded to `user-service`, which validates it against + `auth-service` before creating the account. Returns: - Account data returned by user-service. + Account data returned by `user-service`. Raises: requests.HTTPError: - If user-service responds with a non-2xx status. + If `user-service` responds with a non-2xx status (e.g., `401` + when credentials are rejected). """ response = self._session.post( f"{self._base_url}/accounts", diff --git a/examples/http/service_consumer_provider/test_consumer_auth.py b/examples/http/service_consumer_provider/test_consumer_auth.py index 595ac2348..8bfdd6bbf 100644 --- a/examples/http/service_consumer_provider/test_consumer_auth.py +++ b/examples/http/service_consumer_provider/test_consumer_auth.py @@ -1,5 +1,21 @@ """ -Consumer contract tests for user-service -> auth-service. +Consumer contract tests for `user-service` → `auth-service`. + +This module defines the contract that +[`user_service`][examples.http.service_consumer_provider.user_service] (acting +as a *consumer*) expects `auth-service` (the *provider*) to honour. When these +tests run, Pact starts a mock server in place of `auth-service` and verifies +that +[`AuthClient`][examples.http.service_consumer_provider.auth_client.AuthClient] +makes exactly the requests specified here and can handle the responses. + +The generated Pact file (written to the `pacts/` directory) would normally be +published to a Pact Broker so that the `auth-service` team can run provider +verification against it. In this self-contained example the file is consumed +locally by the provider verification tests. + +For background on consumer testing, see the [Pact consumer test +guide](https://docs.pact.io/5-minute-getting-started-guide#scope-of-a-consumer-pact-test). """ from __future__ import annotations @@ -19,14 +35,21 @@ @pytest.fixture def pact(pacts_path: Path) -> Generator[Pact, None, None]: """ - Pact fixture for user-service as consumer. + Pact fixture for `user-service` as consumer of `auth-service`. + + Creates a V4 Pact between `user-service` (consumer) and `auth-service` + (provider). Each test in this module can define one or more expected + interactions on the returned `Pact` object; the mock provider will validate + that the consumer code sends exactly those requests and handles the + responses correctly. After the test, the contract is written to *pacts_path* + for use in provider verification. Args: pacts_path: - Directory where Pact files are written. + Directory where the generated Pact file is written. Yields: - Pact configured for user-service -> auth-service. + Pact configured for `user-service` → `auth-service`. """ pact = Pact("user-service", "auth-service").with_specification("V4") yield pact @@ -47,17 +70,30 @@ def test_validate_credentials( expected_valid: bool, ) -> None: """ - Verify user-service auth client contract. + Verify the `AuthClient` contract for both valid and invalid credentials. + + This parametrised test covers two interactions in a single contract: + + - **Valid credentials**: `auth-service` responds `{"valid": true}`, and + [`AuthClient.validate_credentials`][examples.http.service_consumer_provider.auth_client.AuthClient.validate_credentials] + returns `True`. + - **Invalid credentials**: `auth-service` responds `{"valid": false}`, and + `AuthClient.validate_credentials` returns `False`. + + Both cases map to the same endpoint (`POST /auth/validate`) but are modelled + as separate Pact interactions with different provider states. This ensures + that `auth-service` must support both outcomes, not just the happy path. Args: pact: - Pact fixture. + Pact fixture for `user-service` → `auth-service`. password: - Password sent to auth-service. + Password sent to `auth-service`; determines which provider state + (and therefore which mock response) is used. expected_valid: - Expected validation result. + The validation result the consumer expects to receive and act on. """ state = ( "user credentials are valid" diff --git a/examples/http/service_consumer_provider/test_consumer_frontend.py b/examples/http/service_consumer_provider/test_consumer_frontend.py index a6bf8d60b..e7ded130d 100644 --- a/examples/http/service_consumer_provider/test_consumer_frontend.py +++ b/examples/http/service_consumer_provider/test_consumer_frontend.py @@ -1,5 +1,19 @@ """ -Consumer contract tests for frontend-web -> user-service. +Consumer contract tests for `frontend-web` → `user-service`. + +This module defines the contract that `frontend-web` (acting as a *consumer*) +expects [`user_service`][examples.http.service_consumer_provider.user_service] +(the *provider*) to honour. When these tests run, Pact starts a mock server in +place of `user-service` and verifies that +[`FrontendClient`][examples.http.service_consumer_provider.frontend_client.FrontendClient] +makes exactly the requests specified here and can handle the responses. + +The generated Pact file is used by the provider verification test in +[`test_provider`][examples.http.service_consumer_provider.test_provider], which +runs the real `user-service` and replays these interactions against it. + +For background on consumer testing, see the [Pact consumer test +guide](https://docs.pact.io/5-minute-getting-started-guide#scope-of-a-consumer-pact-test). """ from __future__ import annotations @@ -20,14 +34,21 @@ @pytest.fixture def pact(pacts_path: Path) -> Generator[Pact, None, None]: """ - Pact fixture for frontend-web as consumer. + Pact fixture for `frontend-web` as consumer of `user-service`. + + Creates a V4 Pact between `frontend-web` (consumer) and `user-service` + (provider). Each test in this module defines one or more expected + interactions on the returned `Pact` object; Pact validates that + `FrontendClient` sends exactly those requests and handles the responses + correctly. After the test, the contract is written to *pacts_path* for + provider verification. Args: pacts_path: - Directory where Pact files are written. + Directory where the generated Pact file is written. Yields: - Pact configured for frontend-web -> user-service. + Pact configured for `frontend-web` → `user-service`. """ pact = Pact("frontend-web", "user-service").with_specification("V4") yield pact @@ -36,11 +57,22 @@ def pact(pacts_path: Path) -> Generator[Pact, None, None]: def test_create_account_success(pact: Pact) -> None: """ - Verify frontend behaviour when credentials are valid. - - Args: - pact: - Pact fixture. + Verify `FrontendClient` behaviour when credentials are valid. + + This test defines the happy-path interaction: `frontend-web` POSTs an + account creation request with valid credentials, and `user-service` responds + with `201 Created` and the new account details. + + Note the use of `match.int(1001)` for the `id` field. This tells Pact to + verify that the field *type* is an integer, not that the value is exactly + `1001`. This makes the contract resilient to auto-incremented IDs while + still ensuring the consumer receives a numeric identifier it can work with. + + The provider state `"auth accepts credentials"` signals to the provider + verification test (see + [`test_provider`][examples.http.service_consumer_provider.test_provider]) + that it must configure a stub `auth-service` that accepts the supplied + credentials. """ ( pact @@ -75,11 +107,20 @@ def test_create_account_success(pact: Pact) -> None: def test_create_account_invalid_credentials(pact: Pact) -> None: """ - Verify frontend behaviour when credentials are invalid. + Verify `FrontendClient` behaviour when credentials are invalid. - Args: - pact: - Pact fixture. + This test defines the failure-path interaction: `frontend-web` POSTs an + account creation request with invalid credentials, and `user-service` + responds with `401 Unauthorized`. The consumer is expected to propagate the + error as a `requests.HTTPError`. + + Testing error paths in Pact contracts is important: it ensures the provider + contract covers not just the happy path but also the error responses that + consumers must handle gracefully. + + The provider state `"auth rejects credentials"` signals to the provider + verification test that the stub `auth-service` must reject the supplied + credentials. """ ( pact diff --git a/examples/http/service_consumer_provider/test_provider.py b/examples/http/service_consumer_provider/test_provider.py index ee4e22154..cea79b7cc 100644 --- a/examples/http/service_consumer_provider/test_provider.py +++ b/examples/http/service_consumer_provider/test_provider.py @@ -1,5 +1,31 @@ """ -Provider verification for user-service against frontend-web contract. +Provider verification for `user-service` against the `frontend-web` contract. + +This module runs the Pact verifier against the real +[`user_service`][examples.http.service_consumer_provider.user_service] FastAPI +application to confirm that it honours the contract defined by the consumer +tests in +[`test_consumer_frontend`][examples.http.service_consumer_provider.test_consumer_frontend]. + +## How provider verification works + +Pact replays each interaction from the contract file against the running +`user-service`. Before each interaction it calls the appropriate *provider state +handler* to put the service in the right state. For example, the interaction `"A +request to create an account"` requires the state `"auth accepts credentials"`, +so Pact calls `set_auth_accepts` first, which installs a +[`StubAuthVerifier`][examples.http.service_consumer_provider.test_provider.StubAuthVerifier] +that always returns `True`. + +This lets the entire `user-service` run for real while still being independent +of a live `auth-service`. The +[`CredentialsVerifier`][examples.http.service_consumer_provider.user_service.CredentialsVerifier] +protocol in `user_service.py` is the seam that makes this possible. + +For more background, see the [Pact provider test +guide](https://docs.pact.io/5-minute-getting-started-guide#scope-of-a-provider-pact-test) +and the documentation for +[`Verifier.state_handler`][pact.verifier.Verifier.state_handler]. """ from __future__ import annotations @@ -27,7 +53,21 @@ class StubAuthVerifier: """ - Test verifier used by provider state handlers. + In-process stub for `auth-service`, used by provider state handlers. + + Rather than starting a real `auth-service` during provider verification, the + tests replace the + [`AuthClient`][examples.http.service_consumer_provider.auth_client.AuthClient] + with this stub via + [`set_auth_verifier`][examples.http.service_consumer_provider.user_service.set_auth_verifier]. + The stub satisfies the + [`CredentialsVerifier`][examples.http.service_consumer_provider.user_service.CredentialsVerifier] + protocol and returns a fixed result for every call, making provider states + simple and deterministic. + + The real `AuthClient` behaviour is separately verified by the consumer tests + in + [`test_consumer_auth`][examples.http.service_consumer_provider.test_consumer_auth]. """ def __init__(self, *, valid: bool) -> None: @@ -61,10 +101,15 @@ def validate_credentials(self, username: str, password: str) -> bool: @pytest.fixture(scope="session") def app_server() -> str: """ - Run the FastAPI server used for provider verification. + Start the `user-service` FastAPI application for provider verification. + + Launches the application in a daemon thread so it is torn down when the test + process exits. The fixture polls the `/docs` endpoint until the server is + accepting connections (up to 5 seconds), which avoids race conditions when + the verifier immediately begins replaying interactions. Returns: - Base URL for user-service. + Base URL of the running `user-service`, e.g. `http://localhost:54321`. """ hostname = "localhost" port = pact._util.find_free_port() # noqa: SLF001 @@ -89,11 +134,19 @@ def app_server() -> str: def set_auth_accepts(parameters: dict[str, object] | None = None) -> None: """ - Provider state: auth-service accepts credentials. + Provider state: `auth-service` accepts credentials. + + Configures `user-service` so that any credential validation attempt + succeeds. This models the scenario where the upstream `auth-service` + considers the supplied credentials valid, allowing account creation to + proceed normally. + + Called by the Pact verifier before interactions that carry the provider + state `"auth accepts credentials"`. Args: parameters: - Optional Pact state parameters. + Optional Pact state parameters. Not used by this state. """ del parameters reset_state() @@ -102,11 +155,19 @@ def set_auth_accepts(parameters: dict[str, object] | None = None) -> None: def set_auth_rejects(parameters: dict[str, object] | None = None) -> None: """ - Provider state: auth-service rejects credentials. + Provider state: `auth-service` rejects credentials. + + Configures `user-service` so that any credential validation attempt fails. + This models the scenario where the upstream `auth-service` considers the + supplied credentials invalid, causing `user-service` to return `401 + Unauthorized`. + + Called by the Pact verifier before interactions that carry the provider + state `"auth rejects credentials"`. Args: parameters: - Optional Pact state parameters. + Optional Pact state parameters. Not used by this state. """ del parameters reset_state() @@ -115,14 +176,31 @@ def set_auth_rejects(parameters: dict[str, object] | None = None) -> None: def test_provider(app_server: str, pacts_path: Path) -> None: """ - Verify user-service against frontend-web consumer contract. + Verify `user-service` against the `frontend-web` consumer contract. + + This test uses the Pact verifier to replay each interaction from the + contract generated by + [`test_consumer_frontend`][examples.http.service_consumer_provider.test_consumer_frontend] + against the running `user-service`. Before each interaction, the verifier + calls the appropriate provider state handler to configure the service. After + all interactions have been replayed, Pact reports any mismatches. + + Provider state handlers are the mechanism Pact uses to decouple verification + from infrastructure: instead of wiring up a real `auth-service`, each state + handler installs a `StubAuthVerifier` that returns a predetermined result. + This makes verification fast, deterministic, and free of external + dependencies. + + Note that `teardown=False` is set on the state handler because the handlers + use `reset_state()` at the *start* of each setup call. Explicit teardown is + unnecessary when the next setup always resets to a clean slate. Args: app_server: - Base URL of the running provider. + Base URL of the running `user-service` provider. pacts_path: - Directory containing generated Pact files. + Directory containing the Pact contract files to verify against. """ verifier = ( Verifier("user-service") diff --git a/examples/http/service_consumer_provider/user_service.py b/examples/http/service_consumer_provider/user_service.py index 643953523..8d144fb43 100644 --- a/examples/http/service_consumer_provider/user_service.py +++ b/examples/http/service_consumer_provider/user_service.py @@ -1,5 +1,47 @@ """ -FastAPI service that acts as both a consumer and a provider. +FastAPI service acting as both a Pact consumer and a Pact provider. + +This module is the centrepiece of the example. `user-service` sits in the middle +of a two-hop request path: + +```text +frontend-web → user-service → auth-service +``` + +This means it plays two Pact roles simultaneously: + +- **Provider** of the `POST /accounts` endpoint consumed by `frontend-web`. The + provider verification test lives in + [`test_provider`][examples.http.service_consumer_provider.test_provider]. + +- **Consumer** of `auth-service`'s `POST /auth/validate` endpoint. The consumer + contract test lives in + [`test_consumer_auth`][examples.http.service_consumer_provider.test_consumer_auth]. + +## Testability design + +Provider verification requires the service to be started as a real HTTP server. +To avoid needing a real `auth-service` during those tests, this module uses the +[`CredentialsVerifier`][examples.http.service_consumer_provider.user_service.CredentialsVerifier] +protocol as a seam. In production the seam is filled by +[`AuthClient`][examples.http.service_consumer_provider.auth_client.AuthClient]; +in tests it is replaced with a +[`StubAuthVerifier`][examples.http.service_consumer_provider.test_provider.StubAuthVerifier] +via +[`set_auth_verifier`][examples.http.service_consumer_provider.user_service.set_auth_verifier]. + +This avoids mocking at the HTTP level. The real FastAPI application runs, and +only the collaborator that calls `auth-service` is swapped out. + +## Module-level state + +[`SERVICE_STATE`][examples.http.service_consumer_provider.user_service.SERVICE_STATE] +and +[`ACCOUNT_STORE`][examples.http.service_consumer_provider.user_service.ACCOUNT_STORE] +are intentional module-level globals. Because provider state handlers in Pact +run in the same process as the application, these globals are the simplest way +for the test harness to reconfigure the service between interactions without an +additional HTTP endpoint. """ from __future__ import annotations diff --git a/tests/compatibility_suite/definition b/tests/compatibility_suite/definition index b03375f00..1acfa1ecb 160000 --- a/tests/compatibility_suite/definition +++ b/tests/compatibility_suite/definition @@ -1 +1 @@ -Subproject commit b03375f00f5adf1346bd76edb0c7968cc0b495cf +Subproject commit 1acfa1ecbd9d63e4465c687b3cdd7e0d3ac5811c