diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2cc2eb93a..4f703bf13 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -164,11 +164,16 @@ jobs: set -o errexit set -o pipefail + root="$(pwd)" + while IFS= read -r -d $'\0' file <&3; do cd "$(dirname "$file")" - echo "Running example in $(pwd)" + example="${PWD#"$root"/}" + + echo "::group::${example}" uv run --python ${{ matrix.python-version }} --group test --with pytest-cov pytest --junit-xml=junit.xml --cov=pact --cov-report=xml:coverage-example.xml - done 3< <(find "$(pwd)/examples" -name pyproject.toml -print0) + echo "::endgroup::" + done 3< <(find "$root/examples" -name pyproject.toml -print0) - name: Upload coverage if: matrix.python-version == env.STABLE_PYTHON_VERSION && matrix.os == 'ubuntu-latest' diff --git a/examples/plugins/grpc/README.md b/examples/plugins/grpc/README.md new file mode 100644 index 000000000..4c6c675e8 --- /dev/null +++ b/examples/plugins/grpc/README.md @@ -0,0 +1,137 @@ +# Example: gRPC Contract Testing with the Protobuf Plugin + +This example demonstrates contract testing for a true [gRPC](https://grpc.io/) +service using the [Pact protobuf +plugin](https://github.com/pactflow/pact-protobuf-plugin). Unlike the +[`protobuf`](../protobuf/) example, which exchanges Protocol Buffer messages over +plain HTTP, this example tests the `AddressBookService` gRPC service end-to-end +over the gRPC transport (HTTP/2). + +It is designed to be pedagogical, highlighting how Pact's plugin system extends +contract testing beyond HTTP to alternative transports such as gRPC. + +## Overview + +- [**Proto definitions**][examples.plugins.proto]: The shared `person.proto` + service definition and its generated stubs, reused from the + [`protobuf`](../protobuf/) example. +- [**Consumer Tests**][examples.plugins.grpc.test_consumer]: Contract + definition and consumer testing against a mock gRPC server. +- [**Provider Tests**][examples.plugins.grpc.test_provider]: Provider + verification of a real gRPC server against the contract. + +Use the above links to view additional documentation within. + +## What This Example Demonstrates + +### Consumer Side + +- Describing a gRPC interaction to the protobuf plugin using a JSON document + that references the `.proto` file, the fully-qualified service method, and + the expected request and response messages. +- Standing up a mock gRPC server with `pact.serve(transport="grpc")`. +- Calling the mock server with a generated gRPC client stub. +- Writing the contract using the mock server (via `srv.write_file()`) so that + the gRPC transport is recorded in the pact. + +### Provider Side + +- Implementing the `AddressBookService` gRPC service using the shared + `person.proto` definition. +- Running the gRPC server in a background thread for verification. +- Verifying the provider with the Pact Verifier over the `grpc` transport. +- Provider state setup and teardown for isolated, repeatable verification. + +## How gRPC Differs from the Protobuf Example + +The [`protobuf`](../protobuf/) example sends protobuf-serialized messages as the +body of regular HTTP requests and responses. This example tests a real gRPC +service, where the protobuf plugin understands the service definition and: + +- Stands up a mock gRPC server for the consumer test, responding to RPC calls + with the expected response message. +- Replays the consumer's recorded RPC calls against a real provider gRPC + server during verification. + +The interaction is described to the plugin using the `application/grpc` content +type and a JSON document of the form: + +```python +{ + "pact:proto": "/path/to/person.proto", + "pact:proto-service": "AddressBookService/GetPerson", + "pact:content-type": "application/grpc", + "request": {"person_id": "matching(number, 1)"}, + "response": {"person": {"name": "matching(type, 'Alice')", ...}}, +} +``` + +The `matching(...)` expressions instruct Pact to verify the structure and types +of the messages rather than their exact values, which is the recommended +approach for contract testing. + +## Prerequisites + +- Python 3.10 or higher +- A dependency manager ([uv](https://docs.astral.sh/uv/) recommended, + [pip](https://pip.pypa.io/en/stable/) also works) +- The [Pact protobuf + plugin](https://github.com/pactflow/pact-protobuf-plugin), which provides + both protobuf content handling and the gRPC transport. Pact downloads the + plugin automatically the first time it is needed, so no manual step is + usually required. To pre-install it (e.g. for offline use), use the [Pact + plugin CLI](https://github.com/pact-foundation/pact-plugins/tree/main/cli): + + ```console + pact-plugin-cli install protobuf + ``` + +## Running the Example + +Run the tests from this directory, exactly like the other examples. Although the +tests reuse the shared proto definitions from the top-level `examples` package, +the example's `pyproject.toml` makes the repository root importable so they run +standalone. + +### Using uv (Recommended) + +We recommend using [uv](https://docs.astral.sh/uv/) to manage the virtual +environment and dependencies. The following command will automatically set up +the virtual environment, install dependencies, and execute the tests: + +```console +uv run --group test pytest +``` + +### Using pip + +If using the [`venv`][venv] module, the required steps are: + +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 25.1 is required + pip install --group test -e . + ``` + +3. Run tests: + + ```console + pytest + ``` + +## Related Documentation + +- [Pact Documentation](https://docs.pact.io/) +- [Pact Protobuf/gRPC Plugin](https://github.com/pactflow/pact-protobuf-plugin) +- [gRPC Python Documentation](https://grpc.io/docs/languages/python/) +- [Protocol Buffers Documentation](https://protobuf.dev/) +- [pytest Documentation](https://docs.pytest.org/) diff --git a/examples/plugins/grpc/__init__.py b/examples/plugins/grpc/__init__.py new file mode 100644 index 000000000..132e7244e --- /dev/null +++ b/examples/plugins/grpc/__init__.py @@ -0,0 +1,61 @@ +""" +gRPC Plugin Example for Pact Python v3. + +This module provides an example of how to use Pact plugins to perform contract +testing over a non-HTTP transport. Specifically, this example demonstrates the +use of the protobuf plugin to test true gRPC interactions, where messages are +exchanged using Protocol Buffers over the gRPC transport (HTTP/2). + +For detailed information about Protocol Buffers, the generated files, and the +domain model used in this example, see the [`proto`][examples.plugins.proto] +module documentation. The same `person.proto` definition (and its generated +`person_pb2` and `person_pb2_grpc` modules) is shared with the +[`protobuf`][examples.plugins.protobuf] example. + +## gRPC and Pact + +The [`protobuf`][examples.plugins.protobuf] example demonstrates how to test +interactions where protobuf messages are sent as the body of regular HTTP +requests and responses. This example goes one step further and tests a real +gRPC service. + +gRPC builds on top of Protocol Buffers and HTTP/2 to provide a high-performance +remote procedure call (RPC) framework. Rather than sending protobuf messages as +opaque HTTP bodies, gRPC defines services with strongly-typed methods, each with +a request and response message. The Pact protobuf plugin understands this and is +able to: + +- Stand up a mock gRPC server for the consumer test, responding to RPC calls + with the expected response message. +- Replay the consumer's RPC calls against a real provider gRPC server during + the provider verification. + +## This Example + +This example demonstrates how to use the Pact protobuf plugin to test a gRPC +service defined by the `AddressBookService` in `person.proto`. It is assumed +that you have a basic understanding of Pact, Protocol Buffers, and gRPC. + +The sample data used throughout this example is shared with the protobuf example +through the [`address_book`][examples.plugins.protobuf.address_book] helper. +""" + +from __future__ import annotations + +from pathlib import Path + +import examples.plugins.proto + +PROTO_DIR = Path(examples.plugins.proto.__file__).parent +""" +Directory containing the shared `person.proto` definition. + +The protobuf plugin requires the path to the `.proto` file in order to +understand the gRPC service and message definitions. This is shared with the +[`protobuf`][examples.plugins.protobuf] example rather than being duplicated. +""" + +PROTO_FILE = PROTO_DIR / "person.proto" +""" +Path to the shared `person.proto` definition. +""" diff --git a/examples/plugins/grpc/conftest.py b/examples/plugins/grpc/conftest.py new file mode 100644 index 000000000..22bb07549 --- /dev/null +++ b/examples/plugins/grpc/conftest.py @@ -0,0 +1,33 @@ +""" +Shared pytest configuration for the gRPC plugin example. + +Defines the `pacts_path` fixture used by both consumer and provider tests to +locate the directory where generated Pact contract files are stored. +""" + +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 providing the path to the generated Pact contract files.""" + 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, this will raise a RuntimeError. + with contextlib.suppress(RuntimeError): + pact_ffi.log_to_stderr("INFO") diff --git a/examples/plugins/grpc/pyproject.toml b/examples/plugins/grpc/pyproject.toml new file mode 100644 index 000000000..6a7e0337c --- /dev/null +++ b/examples/plugins/grpc/pyproject.toml @@ -0,0 +1,32 @@ +#:schema https://www.schemastore.org/pyproject.json + +[project] +name = "example-plugins-grpc" + +description = "Example of gRPC contract testing with the Pact protobuf plugin" + +version = "1.0.0" +requires-python = ">=3.10" +dependencies = ["grpcio~=1.0", "protobuf~=7.34"] + +[dependency-groups] +test = ["pact-python", "pytest~=9.0"] + +[tool] + [tool.pytest.ini_options] + addopts = ["--import-mode=importlib"] + + # The plugin examples import shared code via the `examples.plugins.*` package + # path (e.g. the generated protobuf stubs in `examples.plugins.proto`), so the + # repository root must be importable. + pythonpath = ["../../.."] + + log_date_format = "%H:%M:%S" + log_format = "%(asctime)s.%(msecs)03d [%(levelname)-8s] %(name)s: %(message)s" + log_level = "NOTSET" + + [tool.ruff] + extend = "../../../pyproject.toml" + + [tool.uv.sources] + pact-python = { path = "../../../" } diff --git a/examples/plugins/grpc/test_consumer.py b/examples/plugins/grpc/test_consumer.py new file mode 100644 index 000000000..9dca52954 --- /dev/null +++ b/examples/plugins/grpc/test_consumer.py @@ -0,0 +1,128 @@ +""" +Consumer test using the gRPC transport with Pact Python v3. + +This module demonstrates how to write a consumer test for a true gRPC service +using the Pact protobuf plugin with Pact Python's v3 API. Unlike the +[`protobuf`][examples.plugins.protobuf] example, which sends protobuf messages +over HTTP, this example tests the `AddressBookService` gRPC service end-to-end: +the protobuf plugin stands up a mock gRPC server, and the consumer interacts +with it using a generated gRPC client stub. + +The shared `person.proto` definition (and its generated stubs) from the +[`proto`][examples.plugins.proto] module is reused here, ensuring the consumer +and provider agree on the same service contract. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import grpc +import pytest + +from examples.plugins.grpc import PROTO_FILE +from examples.plugins.proto.person_pb2 import GetPersonRequest, Person +from examples.plugins.proto.person_pb2_grpc import AddressBookServiceStub +from pact import Pact + +if TYPE_CHECKING: + from pathlib import Path + + +@pytest.fixture +def pact() -> Pact: + """ + Set up the Pact fixture with the protobuf plugin. + + This fixture configures a Pact instance for consumer testing with the + protobuf plugin enabled. In addition to handling Protocol Buffer + serialization, the plugin provides support for the gRPC transport, allowing + Pact to stand up a mock gRPC server for the consumer test. + + The fixture uses the V4 specification which provides full support for + plugins and alternative transports. + + Returns: + The configured Pact instance for gRPC consumer tests. + """ + return ( + Pact("grpc_consumer", "grpc_provider") + .with_specification("V4") + .using_plugin("protobuf") + ) + + +def test_get_person_by_id(pact: Pact, pacts_path: Path) -> None: + """ + Test retrieving a Person by ID over gRPC. + + This test defines the expected gRPC interaction for the + `AddressBookService.GetPerson` method. The interaction is described to the + protobuf plugin using a JSON document that references the shared + `person.proto` file, the fully-qualified service method, and the expected + request and response messages. + + The matching rules (e.g. `matching(type, ...)`) instruct Pact to verify the + structure and types of the messages rather than their exact values, which + is the recommended approach for contract testing. + """ + interaction_contents = { + "pact:proto": str(PROTO_FILE), + "pact:proto-service": "AddressBookService/GetPerson", + "pact:content-type": "application/grpc", + "request": { + "person_id": "matching(number, 1)", + }, + "response": { + "person": { + "name": "matching(type, 'Alice')", + "id": "matching(number, 1)", + "email": "matching(type, 'alice@gmail.com')", + "phones": [ + { + "number": "matching(type, '123-456-7890')", + "type": "matching(equalTo, 'PHONE_TYPE_HOME')", + }, + { + "number": "matching(type, '987-654-3210')", + "type": "matching(equalTo, 'PHONE_TYPE_MOBILE')", + }, + ], + } + }, + } + + ( + pact + .upon_receiving("a gRPC request to get person by ID", "Sync") + .given("person with the given ID exists", user_id=1) + .with_plugin_contents(interaction_contents, "application/grpc") + ) + + # NOTE: We bind the mock server to an explicit IPv4 address. The protobuf + # plugin's gRPC mock server does not accept a hostname (such as + # `localhost`), so an IP address must be provided. + with pact.serve(addr="127.0.0.1", transport="grpc") as srv: + # NOTE: We use a raw gRPC channel and the generated stub here to + # demonstrate the principles; however, in a real-world scenario, you + # would be using the actual client code that interacts with the provider + # service. This ensures that you are testing the consumer's behaviour. + with grpc.insecure_channel(f"{srv.host}:{srv.port}") as channel: + stub = AddressBookServiceStub(channel) + response = stub.GetPerson(GetPersonRequest(person_id=1)) + + person = response.person + assert person.id == 1 + assert person.name == "Alice" + assert person.email == "alice@gmail.com" + assert len(person.phones) == 2 + assert person.phones[0].number == "123-456-7890" + assert person.phones[0].type == Person.PhoneType.PHONE_TYPE_HOME + assert person.phones[1].number == "987-654-3210" + assert person.phones[1].type == Person.PhoneType.PHONE_TYPE_MOBILE + + # NOTE: The pact file is written using the mock server (rather than the + # Pact handle) so that the gRPC transport is recorded in the contract. + # This allows the provider verification to replay the interaction over + # gRPC rather than the default HTTP transport. + srv.write_file(pacts_path, overwrite=True) diff --git a/examples/plugins/grpc/test_provider.py b/examples/plugins/grpc/test_provider.py new file mode 100644 index 000000000..5698d6a79 --- /dev/null +++ b/examples/plugins/grpc/test_provider.py @@ -0,0 +1,219 @@ +""" +Provider test using the gRPC transport with Pact Python v3. + +This module demonstrates how to write a provider test for a true gRPC service +using the Pact protobuf plugin with Pact Python's v3 API. Unlike the +[`protobuf`][examples.plugins.protobuf] example, which verifies protobuf +messages exchanged over HTTP, this example stands up a real gRPC server +implementing the `AddressBookService` and verifies it against the consumer's +contract over the gRPC transport. + +The provider test runs the actual gRPC server and uses the Pact Verifier to +replay the consumer's recorded RPC calls against it, verifying that the provider +responds with the expected protobuf messages. + +This example shows how to: + +- Implement a gRPC service using the shared `person.proto` definition. +- Run the gRPC server in a background thread for testing. +- Use the Pact Verifier with the `grpc` transport and the protobuf plugin. +- Handle provider states for setting up test data. +""" + +from __future__ import annotations + +from concurrent import futures +from typing import TYPE_CHECKING, Any, Literal + +import grpc +import pytest + +from examples.plugins.proto import person_pb2_grpc +from examples.plugins.proto.person_pb2 import ( + AddPersonResponse, + AddressBook, + GetPersonResponse, + ListPeopleResponse, +) +from examples.plugins.protobuf import address_book +from pact import Verifier + +if TYPE_CHECKING: + from collections.abc import Generator + from pathlib import Path + +# Global variable to hold our mock address book data +# In a real application, this would be a database or other data store +MOCK_ADDRESS_BOOK: AddressBook | None = None + + +class AddressBookService(person_pb2_grpc.AddressBookServiceServicer): + """ + Concrete implementation of the `AddressBookService` gRPC service. + + This class implements the service methods defined in `person.proto`. It + serves as the provider for the address book gRPC service, backed by the + in-memory `MOCK_ADDRESS_BOOK` data store. + + This code would typically be in a separate module within your application, + but for the sake of this example, it is included directly within the test + module. + """ + + def GetPerson( # noqa: N802 (gRPC method name) + self, + request: Any, # noqa: ANN401 + context: grpc.ServicerContext, + ) -> GetPersonResponse: + """ + Get a person by ID. + + Args: + request: + The `GetPersonRequest` message containing the person ID. + + context: + The gRPC servicer context. + + Returns: + A `GetPersonResponse` containing the requested person. + """ + if MOCK_ADDRESS_BOOK is not None: + for person in MOCK_ADDRESS_BOOK.people: + if person.id == request.person_id: + return GetPersonResponse(person=person) + + context.set_code(grpc.StatusCode.NOT_FOUND) + context.set_details("Person not found") + return GetPersonResponse() + + def ListPeople( # noqa: N802 (gRPC method name) + self, + request: Any, # noqa: ANN401, ARG002 + context: grpc.ServicerContext, # noqa: ARG002 + ) -> ListPeopleResponse: + """ + List all people in the address book. + + Args: + request: + The `ListPeopleRequest` message. + + context: + The gRPC servicer context. + + Returns: + A `ListPeopleResponse` containing all known people. + """ + people = MOCK_ADDRESS_BOOK.people if MOCK_ADDRESS_BOOK else [] + return ListPeopleResponse(people=people) + + def AddPerson( # noqa: N802 (gRPC method name) + self, + request: Any, # noqa: ANN401 + context: grpc.ServicerContext, # noqa: ARG002 + ) -> AddPersonResponse: + """ + Add a new person to the address book. + + Args: + request: + The `AddPersonRequest` message containing the person to add. + + context: + The gRPC servicer context. + + Returns: + An `AddPersonResponse` containing the added person. + """ + return AddPersonResponse(person=request.person) + + +@pytest.fixture(scope="session") +def grpc_server() -> Generator[int, None, None]: + """ + Fixture to start the gRPC server for testing. + + The server is bound to an explicit IPv4 address, as the protobuf plugin's + gRPC verifier does not accept a hostname. + + Yields: + The port on which the gRPC server is listening. + """ + server = grpc.server(futures.ThreadPoolExecutor(max_workers=4)) + person_pb2_grpc.add_AddressBookServiceServicer_to_server( + AddressBookService(), + server, + ) + port = server.add_insecure_port("127.0.0.1:0") + server.start() + try: + yield port + finally: + server.stop(grace=None) + + +def test_provider(grpc_server: int, pacts_path: Path) -> None: + """ + Test the gRPC provider against the consumer contract. + + This test uses the Pact Verifier to replay the consumer's recorded gRPC + interactions against the running provider service. The protobuf plugin is + used to encode and decode the messages over the gRPC transport. + + The test: + + 1. Configures the Verifier with the `grpc` transport. + 2. Points the verifier to the pact file generated by the consumer. + 3. Sets up state handlers to prepare test data. + 4. Verifies all interactions match the contract. + """ + pact_file = pacts_path / "grpc_consumer-grpc_provider.json" + + # NOTE: The verifier requires a base (HTTP) transport in addition to the + # gRPC transport. The first transport registered provides the provider's + # base location, while the gRPC transport is used to replay the recorded + # gRPC interactions (those whose `transport` is `grpc` in the pact file). + verifier = ( + Verifier("grpc_provider", host="127.0.0.1") + .add_transport(protocol="http", port=grpc_server, scheme="http") + .add_transport(protocol="grpc", port=grpc_server) + .add_source(pact_file) + .state_handler( + { + "person with the given ID exists": state_person_exists, + }, + teardown=True, + ) + ) + + verifier.verify() + + +def state_person_exists( + action: Literal["setup", "teardown"], + parameters: dict[str, Any] | None = None, +) -> None: + """ + Handle provider state for when a person with the given ID exists. + + Args: + action: + Either "setup" or "teardown". + + parameters: + Dictionary containing the user_id key. + """ + global MOCK_ADDRESS_BOOK # noqa: PLW0603 + + if action == "setup": + MOCK_ADDRESS_BOOK = address_book() + if user_id := parameters.get("user_id") if parameters else None: + assert any(person.id == user_id for person in MOCK_ADDRESS_BOOK.people), ( + f"Person with ID {user_id} does not exist in address book" + ) + else: + msg = "User ID not provided" + raise AssertionError(msg) + elif action == "teardown": + MOCK_ADDRESS_BOOK = None diff --git a/examples/plugins/proto/person.proto b/examples/plugins/proto/person.proto index 1f6b2fd7c..9ffa7a8bb 100644 --- a/examples/plugins/proto/person.proto +++ b/examples/plugins/proto/person.proto @@ -1,5 +1,5 @@ // examples/v3/plugins/proto/person.proto -edition = "2023"; +syntax = "proto3"; package person; @@ -18,7 +18,7 @@ message Person { message PhoneNumber { string number = 1; - PhoneType type = 2 [default = PHONE_TYPE_HOME]; + PhoneType type = 2; } repeated PhoneNumber phones = 4; diff --git a/examples/plugins/proto/person_pb2.py b/examples/plugins/proto/person_pb2.py index 3d0c20d40..6533dfa7b 100644 --- a/examples/plugins/proto/person_pb2.py +++ b/examples/plugins/proto/person_pb2.py @@ -1,7 +1,7 @@ # ruff: noqa: PGH004 # ruff: noqa # source: person.proto -# Protobuf Python Version: 6.31.0 +# Protobuf Python Version: 6.33.5 """ Protocol buffer message and service definitions for the AddressBook pedagogical example. @@ -21,7 +21,7 @@ from google.protobuf.internal import builder as _builder _runtime_version.ValidateProtobufRuntimeVersion( - _runtime_version.Domain.PUBLIC, 6, 31, 0, "", "person.proto" + _runtime_version.Domain.PUBLIC, 6, 33, 5, "", "person.proto" ) # @@protoc_insertion_point(imports) @@ -29,7 +29,7 @@ DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( - b'\n\x0cperson.proto\x12\x06person"\x9f\x02\n\x06Person\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\n\n\x02id\x18\x02 \x01(\x05\x12\r\n\x05\x65mail\x18\x03 \x01(\t\x12*\n\x06phones\x18\x04 \x03(\x0b\x32\x1a.person.Person.PhoneNumber\x1aV\n\x0bPhoneNumber\x12\x0e\n\x06number\x18\x01 \x01(\t\x12\x37\n\x04type\x18\x02 \x01(\x0e\x32\x18.person.Person.PhoneType:\x0fPHONE_TYPE_HOME"h\n\tPhoneType\x12\x1a\n\x16PHONE_TYPE_UNSPECIFIED\x10\x00\x12\x15\n\x11PHONE_TYPE_MOBILE\x10\x01\x12\x13\n\x0fPHONE_TYPE_HOME\x10\x02\x12\x13\n\x0fPHONE_TYPE_WORK\x10\x03"-\n\x0b\x41\x64\x64ressBook\x12\x1e\n\x06people\x18\x01 \x03(\x0b\x32\x0e.person.Person"%\n\x10GetPersonRequest\x12\x11\n\tperson_id\x18\x01 \x01(\x05"3\n\x11GetPersonResponse\x12\x1e\n\x06person\x18\x01 \x01(\x0b\x32\x0e.person.Person"\x13\n\x11ListPeopleRequest"4\n\x12ListPeopleResponse\x12\x1e\n\x06people\x18\x01 \x03(\x0b\x32\x0e.person.Person"2\n\x10\x41\x64\x64PersonRequest\x12\x1e\n\x06person\x18\x01 \x01(\x0b\x32\x0e.person.Person"3\n\x11\x41\x64\x64PersonResponse\x12\x1e\n\x06person\x18\x01 \x01(\x0b\x32\x0e.person.Person2\xdd\x01\n\x12\x41\x64\x64ressBookService\x12@\n\tGetPerson\x12\x18.person.GetPersonRequest\x1a\x19.person.GetPersonResponse\x12\x43\n\nListPeople\x12\x19.person.ListPeopleRequest\x1a\x1a.person.ListPeopleResponse\x12@\n\tAddPerson\x12\x18.person.AddPersonRequest\x1a\x19.person.AddPersonResponseb\x08\x65\x64itionsp\xe8\x07' + b'\n\x0cperson.proto\x12\x06person"\x8e\x02\n\x06Person\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\n\n\x02id\x18\x02 \x01(\x05\x12\r\n\x05\x65mail\x18\x03 \x01(\t\x12*\n\x06phones\x18\x04 \x03(\x0b\x32\x1a.person.Person.PhoneNumber\x1a\x45\n\x0bPhoneNumber\x12\x0e\n\x06number\x18\x01 \x01(\t\x12&\n\x04type\x18\x02 \x01(\x0e\x32\x18.person.Person.PhoneType"h\n\tPhoneType\x12\x1a\n\x16PHONE_TYPE_UNSPECIFIED\x10\x00\x12\x15\n\x11PHONE_TYPE_MOBILE\x10\x01\x12\x13\n\x0fPHONE_TYPE_HOME\x10\x02\x12\x13\n\x0fPHONE_TYPE_WORK\x10\x03"-\n\x0b\x41\x64\x64ressBook\x12\x1e\n\x06people\x18\x01 \x03(\x0b\x32\x0e.person.Person"%\n\x10GetPersonRequest\x12\x11\n\tperson_id\x18\x01 \x01(\x05"3\n\x11GetPersonResponse\x12\x1e\n\x06person\x18\x01 \x01(\x0b\x32\x0e.person.Person"\x13\n\x11ListPeopleRequest"4\n\x12ListPeopleResponse\x12\x1e\n\x06people\x18\x01 \x03(\x0b\x32\x0e.person.Person"2\n\x10\x41\x64\x64PersonRequest\x12\x1e\n\x06person\x18\x01 \x01(\x0b\x32\x0e.person.Person"3\n\x11\x41\x64\x64PersonResponse\x12\x1e\n\x06person\x18\x01 \x01(\x0b\x32\x0e.person.Person2\xdd\x01\n\x12\x41\x64\x64ressBookService\x12@\n\tGetPerson\x12\x18.person.GetPersonRequest\x1a\x19.person.GetPersonResponse\x12\x43\n\nListPeople\x12\x19.person.ListPeopleRequest\x1a\x1a.person.ListPeopleResponse\x12@\n\tAddPerson\x12\x18.person.AddPersonRequest\x1a\x19.person.AddPersonResponseb\x06proto3' ) _globals = globals() @@ -38,25 +38,25 @@ if not _descriptor._USE_C_DESCRIPTORS: DESCRIPTOR._loaded_options = None _globals["_PERSON"]._serialized_start = 25 - _globals["_PERSON"]._serialized_end = 312 + _globals["_PERSON"]._serialized_end = 295 _globals["_PERSON_PHONENUMBER"]._serialized_start = 120 - _globals["_PERSON_PHONENUMBER"]._serialized_end = 206 - _globals["_PERSON_PHONETYPE"]._serialized_start = 208 - _globals["_PERSON_PHONETYPE"]._serialized_end = 312 - _globals["_ADDRESSBOOK"]._serialized_start = 314 - _globals["_ADDRESSBOOK"]._serialized_end = 359 - _globals["_GETPERSONREQUEST"]._serialized_start = 361 - _globals["_GETPERSONREQUEST"]._serialized_end = 398 - _globals["_GETPERSONRESPONSE"]._serialized_start = 400 - _globals["_GETPERSONRESPONSE"]._serialized_end = 451 - _globals["_LISTPEOPLEREQUEST"]._serialized_start = 453 - _globals["_LISTPEOPLEREQUEST"]._serialized_end = 472 - _globals["_LISTPEOPLERESPONSE"]._serialized_start = 474 - _globals["_LISTPEOPLERESPONSE"]._serialized_end = 526 - _globals["_ADDPERSONREQUEST"]._serialized_start = 528 - _globals["_ADDPERSONREQUEST"]._serialized_end = 578 - _globals["_ADDPERSONRESPONSE"]._serialized_start = 580 - _globals["_ADDPERSONRESPONSE"]._serialized_end = 631 - _globals["_ADDRESSBOOKSERVICE"]._serialized_start = 634 - _globals["_ADDRESSBOOKSERVICE"]._serialized_end = 855 + _globals["_PERSON_PHONENUMBER"]._serialized_end = 189 + _globals["_PERSON_PHONETYPE"]._serialized_start = 191 + _globals["_PERSON_PHONETYPE"]._serialized_end = 295 + _globals["_ADDRESSBOOK"]._serialized_start = 297 + _globals["_ADDRESSBOOK"]._serialized_end = 342 + _globals["_GETPERSONREQUEST"]._serialized_start = 344 + _globals["_GETPERSONREQUEST"]._serialized_end = 381 + _globals["_GETPERSONRESPONSE"]._serialized_start = 383 + _globals["_GETPERSONRESPONSE"]._serialized_end = 434 + _globals["_LISTPEOPLEREQUEST"]._serialized_start = 436 + _globals["_LISTPEOPLEREQUEST"]._serialized_end = 455 + _globals["_LISTPEOPLERESPONSE"]._serialized_start = 457 + _globals["_LISTPEOPLERESPONSE"]._serialized_end = 509 + _globals["_ADDPERSONREQUEST"]._serialized_start = 511 + _globals["_ADDPERSONREQUEST"]._serialized_end = 561 + _globals["_ADDPERSONRESPONSE"]._serialized_start = 563 + _globals["_ADDPERSONRESPONSE"]._serialized_end = 614 + _globals["_ADDRESSBOOKSERVICE"]._serialized_start = 617 + _globals["_ADDRESSBOOKSERVICE"]._serialized_end = 838 # @@protoc_insertion_point(module_scope) diff --git a/examples/plugins/protobuf/README.md b/examples/plugins/protobuf/README.md new file mode 100644 index 000000000..dbcef5f66 --- /dev/null +++ b/examples/plugins/protobuf/README.md @@ -0,0 +1,125 @@ +# Example: Protobuf Contract Testing with the Protobuf Plugin + +This example demonstrates contract testing for [Protocol +Buffers](https://protobuf.dev/) (protobuf) messages exchanged over plain HTTP, +using the [Pact protobuf +plugin](https://github.com/pactflow/pact-protobuf-plugin). The consumer requests +a `Person` from an address book service and receives a protobuf-serialized +response, which Pact verifies against the provider. + +It is designed to be pedagogical, highlighting how Pact's plugin system extends +contract testing beyond text-based (e.g. JSON) payloads to binary content types +such as protobuf. + +For a fully protobuf **and** gRPC example, where the same `person.proto` +definition drives a true gRPC service over the gRPC transport, see the +[`grpc`](../grpc/) example. + +## Overview + +- [**Proto definitions**][examples.plugins.proto]: The shared `person.proto` + message definitions and their generated stubs, also used by the + [`grpc`](../grpc/) example. +- [**Consumer Tests**][examples.plugins.protobuf.test_consumer]: Contract + definition and consumer testing against a mock HTTP server. +- [**Provider Tests**][examples.plugins.protobuf.test_provider]: Provider + verification of a real FastAPI server against the contract. + +Use the above links to view additional documentation within. + +## What This Example Demonstrates + +### Consumer Side + +- Describing the expected protobuf message and registering it as the binary + body of an HTTP response. +- Standing up a mock HTTP server with `pact.serve()`. +- Calling the mock server and deserializing the protobuf response for + validation. + +### Provider Side + +- Implementing a FastAPI provider that responds with protobuf-serialized + messages (`application/x-protobuf`). +- Running the server in a background thread for verification. +- Verifying the provider with the Pact Verifier. +- Provider state setup for isolated, repeatable verification. + +## How Protobuf-over-HTTP Works + +Pact is traditionally focused on text-based payloads (primarily JSON). The +protobuf plugin extends this by teaching Pact how to encode, decode, and match +protobuf messages. In this example, the messages are carried as the binary body +of regular HTTP requests and responses, so the standard HTTP transport is used — +only the _content type_ is handled by the plugin. + +This differs from the [`grpc`](../grpc/) example, which goes one step further: +there the protobuf plugin understands the `.proto` _service_ definition and +exercises a real gRPC service over the gRPC transport (HTTP/2), rather than +sending protobuf messages as opaque HTTP bodies. + +## Prerequisites + +- Python 3.10 or higher +- A dependency manager ([uv](https://docs.astral.sh/uv/) recommended, + [pip](https://pip.pypa.io/en/stable/) also works) +- The [Pact protobuf + plugin](https://github.com/pactflow/pact-protobuf-plugin), which provides + protobuf content handling. Pact downloads the plugin automatically the first + time it is needed, so no manual step is usually required. To pre-install it + (e.g. for offline use), use the [Pact plugin + CLI](https://github.com/pact-foundation/pact-plugins/tree/main/cli): + + ```console + pact-plugin-cli install protobuf + ``` + +## Running the Example + +Run the tests from this directory, exactly like the other examples. Although the +tests reuse the shared proto definitions from the top-level `examples` package, +the example's `pyproject.toml` makes the repository root importable so they run +standalone. + +### Using uv (Recommended) + +We recommend using [uv](https://docs.astral.sh/uv/) to manage the virtual +environment and dependencies. The following command will automatically set up +the virtual environment, install dependencies, and execute the tests: + +```console +uv run --group test pytest +``` + +### Using pip + +If using the [`venv`][venv] module, the required steps are: + +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 25.1 is required + pip install --group test -e . + ``` + +3. Run tests: + + ```console + pytest + ``` + +## Related Documentation + +- [Pact Documentation](https://docs.pact.io/) +- [Pact Protobuf/gRPC Plugin](https://github.com/pactflow/pact-protobuf-plugin) +- [Protocol Buffers Documentation](https://protobuf.dev/) +- [FastAPI Documentation](https://fastapi.tiangolo.com/) +- [pytest Documentation](https://docs.pytest.org/) diff --git a/examples/plugins/protobuf/conftest.py b/examples/plugins/protobuf/conftest.py new file mode 100644 index 000000000..f0c260a82 --- /dev/null +++ b/examples/plugins/protobuf/conftest.py @@ -0,0 +1,33 @@ +""" +Shared pytest configuration for the Protobuf plugin example. + +Defines the `pacts_path` fixture used by both consumer and provider tests to +locate the directory where generated Pact contract files are stored. +""" + +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 providing the path to the generated Pact contract files.""" + 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, this will raise a RuntimeError. + with contextlib.suppress(RuntimeError): + pact_ffi.log_to_stderr("INFO") diff --git a/examples/plugins/protobuf/pyproject.toml b/examples/plugins/protobuf/pyproject.toml new file mode 100644 index 000000000..f53362141 --- /dev/null +++ b/examples/plugins/protobuf/pyproject.toml @@ -0,0 +1,38 @@ +#:schema https://www.schemastore.org/pyproject.json + +[project] +name = "example-plugins-protobuf" + +description = "Example of Protobuf-over-HTTP contract testing with the Pact protobuf plugin" + +version = "1.0.0" +requires-python = ">=3.10" +dependencies = [ + "fastapi~=0.0", + "protobuf~=7.34", + "requests~=2.0", + "uvicorn[standard]~=0.0", + "yarl~=1.0", +] + +[dependency-groups] +test = ["pact-python", "pytest~=9.0"] + +[tool] + [tool.pytest.ini_options] + addopts = ["--import-mode=importlib"] + + # The plugin examples import shared code via the `examples.plugins.*` package + # path (e.g. the generated protobuf stubs in `examples.plugins.proto`), so the + # repository root must be importable. + pythonpath = ["../../.."] + + log_date_format = "%H:%M:%S" + log_format = "%(asctime)s.%(msecs)03d [%(levelname)-8s] %(name)s: %(message)s" + log_level = "NOTSET" + + [tool.ruff] + extend = "../../../pyproject.toml" + + [tool.uv.sources] + pact-python = { path = "../../../" } diff --git a/pyproject.toml b/pyproject.toml index b0248ad58..9f52f5633 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -201,14 +201,6 @@ build-backend = "hatchling.build" [[tool.hatch.envs.test.matrix]] python = ["3.10", "3.11", "3.12", "3.13", "3.14"] - [tool.hatch.envs.example] - installer = "uv" - path = ".venv/example" - dependency-groups = ["example"] - - [[tool.hatch.envs.example.matrix]] - python = ["3.10", "3.11", "3.12", "3.13", "3.14"] - [tool.hatch.envs.v2-test] features = ["compat-v2"] installer = "uv"