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..c7c6f2ce4 --- /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 + ``` + +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/) 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..448a46d32 --- /dev/null +++ b/examples/http/service_consumer_provider/conftest.py @@ -0,0 +1,29 @@ +""" +Shared PyTest configuration for the service-as-consumer/provider example. +""" + +from __future__ import annotations + +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. + """ + 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..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..595ac2348 --- /dev/null +++ b/examples/http/service_consumer_provider/test_consumer_auth.py @@ -0,0 +1,92 @@ +""" +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..ee4e22154 --- /dev/null +++ b/examples/http/service_consumer_provider/test_provider.py @@ -0,0 +1,140 @@ +""" +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) -> None: + """ + Provider state: auth-service accepts credentials. + + Args: + 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) -> None: + """ + Provider state: auth-service rejects credentials. + + Args: + parameters: + Optional Pact state parameters. + """ + del 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..643953523 --- /dev/null +++ b/examples/http/service_consumer_provider/user_service.py @@ -0,0 +1,153 @@ +""" +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() + + +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)