Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`
Expand Down
1 change: 1 addition & 0 deletions examples/http/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
59 changes: 59 additions & 0 deletions examples/http/service_consumer_provider/README.md
Original file line number Diff line number Diff line change
@@ -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/)
1 change: 1 addition & 0 deletions examples/http/service_consumer_provider/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# noqa: D104
50 changes: 50 additions & 0 deletions examples/http/service_consumer_provider/auth_client.py
Original file line number Diff line number Diff line change
@@ -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))
29 changes: 29 additions & 0 deletions examples/http/service_consumer_provider/conftest.py
Original file line number Diff line number Diff line change
@@ -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")
63 changes: 63 additions & 0 deletions examples/http/service_consumer_provider/frontend_client.py
Original file line number Diff line number Diff line change
@@ -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"])
26 changes: 26 additions & 0 deletions examples/http/service_consumer_provider/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
92 changes: 92 additions & 0 deletions examples/http/service_consumer_provider/test_consumer_auth.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading