Skip to content
Merged
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
37 changes: 37 additions & 0 deletions examples/http/service_consumer_provider/README.md
Original file line number Diff line number Diff line change
@@ -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/)
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
76 changes: 76 additions & 0 deletions examples/http/service_consumer_provider/auth_client.py
Original file line number Diff line number Diff line change
@@ -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))
37 changes: 37 additions & 0 deletions examples/http/service_consumer_provider/conftest.py
Original file line number Diff line number Diff line change
@@ -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")
98 changes: 98 additions & 0 deletions examples/http/service_consumer_provider/frontend_client.py
Original file line number Diff line number Diff line change
@@ -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"])
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"
Loading
Loading