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..78a9a1398 --- /dev/null +++ b/examples/http/service_consumer_provider/README.md @@ -0,0 +1,37 @@ +# 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` 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-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 + +```console +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/) +- [pytest Documentation](https://docs.pytest.org/) 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..b95b60463 --- /dev/null +++ b/examples/http/service_consumer_provider/auth_client.py @@ -0,0 +1,76 @@ +""" +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 + +import requests + + +class AuthClient: + """ + 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: + """ + Initialise the auth client. + + Args: + base_url: + 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`. + + 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: + 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..0182939b6 --- /dev/null +++ b/examples/http/service_consumer_provider/conftest.py @@ -0,0 +1,37 @@ +""" +Shared PyTest configuration for the service-as-consumer/provider example. +""" + +from __future__ import annotations + +import contextlib +from pathlib import Path + +import pytest + +import pact_ffi + +EXAMPLE_DIR = Path(__file__).parent.resolve() + + +@pytest.fixture(scope="session") +def pacts_path() -> Path: + """ + Fixture for the Pact directory. + + Returns: + Path to the directory where Pact contract files are written. + """ + return EXAMPLE_DIR / "pacts" + + +@pytest.fixture(scope="session", autouse=True) +def _setup_pact_logging() -> None: + """ + Set up logging for the pact package. + """ + # 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") 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..093517ab7 --- /dev/null +++ b/examples/http/service_consumer_provider/frontend_client.py @@ -0,0 +1,98 @@ +""" +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 + +from dataclasses import dataclass + +import requests + + +@dataclass +class Account: + """ + 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 + username: str + status: str + + +class FrontendClient: + """ + 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: + """ + Initialise the frontend client. + + Args: + base_url: + 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`. + + 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 for the new account. + + password: + Password forwarded to `user-service`, which validates it against + `auth-service` before creating the account. + + Returns: + Account data returned by `user-service`. + + Raises: + requests.HTTPError: + 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", + 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..96ece6ee1 --- /dev/null +++ b/examples/http/service_consumer_provider/pyproject.toml @@ -0,0 +1,26 @@ +#: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"] + +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..8bfdd6bbf --- /dev/null +++ b/examples/http/service_consumer_provider/test_consumer_auth.py @@ -0,0 +1,128 @@ +""" +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 + +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 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 the generated Pact file is 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 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 for `user-service` → `auth-service`. + + password: + Password sent to `auth-service`; determines which provider state + (and therefore which mock response) is used. + + expected_valid: + The validation result the consumer expects to receive and act on. + """ + 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..e7ded130d --- /dev/null +++ b/examples/http/service_consumer_provider/test_consumer_frontend.py @@ -0,0 +1,144 @@ +""" +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 + +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 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 the generated Pact file is 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 `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 + .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 `FrontendClient` behaviour when credentials are invalid. + + 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 + .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..cea79b7cc --- /dev/null +++ b/examples/http/service_consumer_provider/test_provider.py @@ -0,0 +1,218 @@ +""" +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 + +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: + """ + 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: + """ + 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: + """ + 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 of the running `user-service`, e.g. `http://localhost:54321`. + """ + 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) -> None: + """ + 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. Not used by this state. + """ + del parameters + reset_state() + set_auth_verifier(StubAuthVerifier(valid=True)) + + +def set_auth_rejects(parameters: dict[str, object] | None = None) -> None: + """ + 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. Not used by this state. + """ + del parameters + reset_state() + set_auth_verifier(StubAuthVerifier(valid=False)) + + +def test_provider(app_server: str, pacts_path: Path) -> None: + """ + 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 `user-service` provider. + + pacts_path: + Directory containing the Pact contract files to verify against. + """ + 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..8d144fb43 --- /dev/null +++ b/examples/http/service_consumer_provider/user_service.py @@ -0,0 +1,195 @@ +""" +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 + +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() + + +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() + + +def set_auth_verifier(verifier: CredentialsVerifier) -> None: + """ + Replace the auth verifier implementation. + + Args: + verifier: + New verifier implementation. + """ + SERVICE_STATE.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 SERVICE_STATE.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) 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