From 4f4f000c3e9adb801a8e3ca9e09d25a21d9e7f16 Mon Sep 17 00:00:00 2001 From: Yauheni Papovich Date: Wed, 28 Oct 2026 18:48:47 +0000 Subject: [PATCH] docs(examples): add a binary payloads example with matching rules Binary bodies cannot carry matching rules inside them, so the contract has to constrain the payload as a whole. The example models a blob store and shows the `contentType` matcher on the body root for both directions: - `test_consumer.py` declares a binary request and a binary response with `with_binary_body()`, sends a payload whose length and contents differ from the contract example, and combines the binary request with a JSON response using `match.int()`. - `test_provider.py` verifies a FastAPI implementation whose provider state seeds a payload that also differs from the contract example, so verification depends on the matching rule rather than on the example bytes. Registered in `examples/catalog/README.md`. Closes #377. --- examples/catalog/README.md | 1 + examples/catalog/binary_payloads/README.md | 51 +++++ examples/catalog/binary_payloads/__init__.py | 1 + .../catalog/binary_payloads/test_consumer.py | 139 ++++++++++++++ .../catalog/binary_payloads/test_provider.py | 181 ++++++++++++++++++ 5 files changed, 373 insertions(+) create mode 100644 examples/catalog/binary_payloads/README.md create mode 100644 examples/catalog/binary_payloads/__init__.py create mode 100644 examples/catalog/binary_payloads/test_consumer.py create mode 100644 examples/catalog/binary_payloads/test_provider.py diff --git a/examples/catalog/README.md b/examples/catalog/README.md index 304549a71..8e3a4338c 100644 --- a/examples/catalog/README.md +++ b/examples/catalog/README.md @@ -4,6 +4,7 @@ This catalog contains focused, well-documented code snippets demonstrating speci ## Available +- [Binary Payloads with Matching Rules](./binary_payloads/README.md) - [Multipart Form Data with Matching Rules](./multipart_matching_rules/README.md) ## Using Catalog Entries diff --git a/examples/catalog/binary_payloads/README.md b/examples/catalog/binary_payloads/README.md new file mode 100644 index 000000000..db7f104b6 --- /dev/null +++ b/examples/catalog/binary_payloads/README.md @@ -0,0 +1,51 @@ +# Binary Payloads with Matching Rules + +This entry demonstrates how to write a Pact contract for binary payloads, both +on the consumer and on the provider side. + +Binary payloads (images, archives, compiled artefacts, opaque blobs, etc.) +cannot be described as JSON or XML, so Pact stores them as raw bytes and +base64-encodes them within the generated contract. As a consequence, matching +rules cannot be embedded _inside_ the payload, the way they can for JSON or XML +fields. Instead, matching rules apply to the payload as a whole, and the most +useful of these is the `contentType` matcher: + +```python +from pact import match + +{"body": {"$": match.content_type("application/octet-stream")}} +``` + +Without a matching rule, binary bodies are compared byte-for-byte, which makes +contracts brittle: any change to the payload size or contents breaks the +contract even when the consumer does not care about the exact bytes. With a +`contentType` matching rule, the contract only requires the payload to be of +the declared type. + +For full implementation details, see the code and docstrings in this directory. +For general catalog usage, prerequisites, and test-running instructions, see +the [main catalog README](../README.md). + +## What This Example Demonstrates + +- Declaring a binary request and response body with `with_binary_body()` +- Applying a `contentType` matching rule to the body root (path `$`) +- Sending a payload whose length and contents differ from the example in the + contract, and having the mock server accept it +- Combining a binary request with a JSON response carrying `match.int()` + based metadata +- Provider verification where the provider returns its own payload, which + differs from the contract example but is still `application/octet-stream` + +## Choosing the Content Type + +The `contentType` matching rule constrains the payload type rather than its +bytes, so the provider must return a payload of the declared type. The payloads +here are arbitrary bytes with no meaning of their own, which is why the example +uses `application/octet-stream` throughout; a payload that is known to be, say, +a PNG should declare `image/png` instead. + +## Related Documentation + +- [Pact Matching Rules](https://docs.pact.io/getting_started/matching) +- [Pact Specification v4](https://github.com/pact-foundation/pact-specification/tree/version-4) diff --git a/examples/catalog/binary_payloads/__init__.py b/examples/catalog/binary_payloads/__init__.py new file mode 100644 index 000000000..6e031999e --- /dev/null +++ b/examples/catalog/binary_payloads/__init__.py @@ -0,0 +1 @@ +# noqa: D104 diff --git a/examples/catalog/binary_payloads/test_consumer.py b/examples/catalog/binary_payloads/test_consumer.py new file mode 100644 index 000000000..4d43191d9 --- /dev/null +++ b/examples/catalog/binary_payloads/test_consumer.py @@ -0,0 +1,139 @@ +""" +Consumer tests demonstrating binary payloads with matching rules. + +Binary payloads (images, archives, compiled artefacts, etc.) cannot be +represented as JSON or XML, so Pact stores them as raw bytes and encodes them +as base64 within the generated contract. This means the contract cannot express +matching rules _inside_ the payload. Instead, matching rules are applied to the +payload as a whole, and the most useful of these is the `contentType` matcher: + +```python +from pact import match + +{"body": {"$": match.content_type("application/octet-stream")}} +``` + +The contract then requires that the payload be of the declared content type, +rather than an exact byte-for-byte match, so the provider may return (or accept) +any payload of that type. Exact byte matching is the default when no matching +rule is defined, which in practice makes binary contracts extremely brittle. + +The example below models a small blob store: the consumer uploads an opaque +binary payload and later downloads it again. Both directions use +`with_binary_body` to declare the payload, with a `contentType` matching rule +on the body root (`$`). + +The payloads here are arbitrary bytes with no meaning of their own, so the +declared type is `application/octet-stream`. A payload known to be a specific +format should declare that format (for example `image/png`) instead: the +matching rule constrains the type the provider must return. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +import httpx +import pytest + +from pact import Pact, match + +if TYPE_CHECKING: + from collections.abc import Generator + + +#: Binary payload used as the example value within the contract. These bytes +#: are neither valid UTF-8 nor a well-known file signature, so Pact detects +#: them as `application/octet-stream`. +CONTRACT_BLOB = bytes([0x00, 0x01, 0x02, 0x03, 0xFF, 0xFE, 0x10, 0x20]) + +#: Payload actually sent by the consumer during the test. It deliberately +#: differs in both length and content from `CONTRACT_BLOB` to show that the +#: matching rule, not the example bytes, defines the contract. +ACTUAL_BLOB = bytes([0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55]) + + +@pytest.fixture +def pact() -> Generator[Pact, None, None]: + """ + Set up Pact for consumer contract testing. + + The generated contract is written to the `pacts` directory shared by all + catalog entries, where the provider test picks it up for verification. + """ + pact = Pact("binary-consumer", "binary-provider") + yield pact + pact.write_file(Path(__file__).parents[1] / "pacts") + + +def test_upload_binary_blob(pact: Pact) -> None: + """ + Test uploading a binary payload with a matching rule. + + The request body is declared with `with_binary_body()` and a + `contentType` matching rule on `$`. The test then sends a payload + with different contents and a different length, which the mock server + accepts because the rule only constrains the content type. + + The response is JSON and is matched field-by-field in the usual way, which + shows that a binary request can be combined with a structured response. + The `size` field uses `match.int()` so the provider is free to report the + real size of whatever payload it received. + """ + ( + pact + .upon_receiving("a binary blob upload") + .with_request("POST", "/blobs") + .with_binary_body(CONTRACT_BLOB, "application/octet-stream") + .with_matching_rules({ + "body": {"$": match.content_type("application/octet-stream")}, + }) + .will_respond_with(201) + .with_body({ + "id": "blob-1", + "size": match.int(len(CONTRACT_BLOB)), + }) + ) + + with pact.serve() as srv: + response = httpx.post( + f"{srv.url}/blobs", + content=ACTUAL_BLOB, + headers={"Content-Type": "application/octet-stream"}, + timeout=5, + ) + + assert response.status_code == 201 + result = response.json() + assert result["id"] == "blob-1" + assert result["size"] == len(CONTRACT_BLOB) + + +def test_download_binary_blob(pact: Pact) -> None: + """ + Test downloading a binary payload with a matching rule. + + The response body is declared in the same way as the request body above: + raw bytes via `with_binary_body`, plus a `contentType` matching rule on + `$`. The mock server returns the example bytes from the contract, so the + consumer can assert on the exact payload it receives. + """ + ( + pact + .upon_receiving("a request for a binary blob") + .given("a binary blob exists", id="blob-1") + .with_request("GET", "/blobs/blob-1") + .will_respond_with(200) + .with_binary_body(CONTRACT_BLOB, "application/octet-stream") + .with_matching_rules({ + "body": {"$": match.content_type("application/octet-stream")}, + }) + ) + + with pact.serve() as srv: + response = httpx.get(f"{srv.url}/blobs/blob-1", timeout=5) + + assert response.status_code == 200 + assert response.headers["Content-Type"] == "application/octet-stream" + assert response.content == CONTRACT_BLOB diff --git a/examples/catalog/binary_payloads/test_provider.py b/examples/catalog/binary_payloads/test_provider.py new file mode 100644 index 000000000..9b00c2646 --- /dev/null +++ b/examples/catalog/binary_payloads/test_provider.py @@ -0,0 +1,181 @@ +""" +Provider verification for binary payloads with matching rules. + +This test runs a small [FastAPI](https://fastapi.tiangolo.com/) provider which +stores opaque binary blobs, and verifies it against the contract generated by +[`test_consumer.py`][examples.catalog.binary_payloads.test_consumer]. + +The interesting part of provider verification for binary payloads is that the +contract does not contain an exact byte sequence: the `contentType` matching +rule only requires the payload to be of the declared type. The provider state +handler below therefore seeds the store with a payload which differs from the +example bytes in the contract, and verification still passes. + +The provider must still return the correct `Content-Type`; the matching rule +constrains the content type of the payload, so returning `application/json` or +omitting the header will cause a mismatch. +""" + +from __future__ import annotations + +import time +from pathlib import Path +from threading import Thread +from typing import Any, Literal + +import pytest +import uvicorn +from fastapi import FastAPI, HTTPException, Request, Response, status + +import pact._util +from pact import Verifier + +#: Payload seeded by the provider state handler. It differs from the example +#: payload in the consumer contract, both in length and in content. +PROVIDER_BLOB = bytes([0x00, 0x42, 0x13, 0x37, 0xFF, 0x7F, 0x01]) + +#: In-memory store of binary blobs, keyed by blob ID. +BLOBS: dict[str, bytes] = {} + +#: Sizes of the payloads received and served by the provider. These are recorded +#: so that the provider test can assert that the provider was actually exercised +#: while still allowing the blob store to be cleaned up after verification. +RECEIVED_SIZES: list[int] = [] +SERVED_SIZES: list[int] = [] + +app = FastAPI() +""" +FastAPI application serving a binary blob store. +""" + + +@app.post("/blobs", status_code=status.HTTP_201_CREATED) +async def upload_blob(request: Request) -> dict[str, str | int]: + """ + Store an uploaded binary payload and return its metadata. + + Args: + request: + The incoming request. Its raw body is read without any parsing, as + the payload is opaque binary data. + + Returns: + The ID of the stored blob and the size of the payload in bytes. + """ + content = await request.body() + RECEIVED_SIZES.append(len(content)) + blob_id = f"blob-{len(BLOBS) + 1}" + BLOBS[blob_id] = content + return {"id": blob_id, "size": len(content)} + + +@app.get("/blobs/{blob_id}") +async def download_blob(blob_id: str) -> Response: + """ + Return a stored binary payload. + + Args: + blob_id: + The ID of the blob to return. + + Returns: + The raw payload, served as `application/octet-stream`. + + Raises: + HTTPException: + If no blob exists with the given ID. + """ + if blob_id not in BLOBS: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Blob not found", + ) + payload = BLOBS[blob_id] + SERVED_SIZES.append(len(payload)) + return Response(content=payload, media_type="application/octet-stream") + + +@pytest.fixture(scope="session") +def app_server() -> str: + """ + Run the FastAPI server for provider verification. + + Returns: + The base URL of the running FastAPI server. + """ + hostname = "localhost" + port = pact._util.find_free_port() # noqa: SLF001 + Thread( + target=uvicorn.run, + args=(app,), + kwargs={"host": hostname, "port": port}, + daemon=True, + ).start() + time.sleep(0.1) # Allow server time to start + return f"http://{hostname}:{port}" + + +def test_provider(app_server: str) -> None: + """ + Verify the provider against the binary payload contract. + + The verifier replays the interactions from the contract generated by the + consumer tests. Binary request bodies are compared using the matching rules + from the contract, and binary response bodies are verified the same way, so + the provider may return any payload of the declared content type. + + The `"a binary blob exists"` provider state seeds the store with + `PROVIDER_BLOB`, whose bytes differ from the example in the contract. + Verification passes because the contract only requires + `application/octet-stream`. + """ + verifier = ( + Verifier("binary-provider") + .add_source(Path(__file__).parents[1] / "pacts") + .add_transport(url=app_server) + .state_handler( + {"a binary blob exists": seed_blob}, + teardown=True, + ) + ) + + verifier.verify() + + assert RECEIVED_SIZES, "The provider did not receive any binary payload" + assert SERVED_SIZES, "The provider did not serve any binary payload" + assert SERVED_SIZES[0] == len(PROVIDER_BLOB), ( + "Expected the provider to serve its own payload rather than the " + "contract example" + ) + + +def seed_blob( + action: Literal["setup", "teardown"], + parameters: dict[str, Any], +) -> None: + """ + Populate the blob store before an interaction which needs a stored blob. + + Args: + action: + Either `"setup"` (before the interaction) or `"teardown"` (after). + parameters: + State parameters from the consumer contract. Must contain the + `"id"` of the blob to create. + """ + if "id" not in parameters: + msg = "State must contain an 'id' field to seed a binary blob" + raise ValueError(msg) + + blob_id = str(parameters["id"]) + + if action == "setup": + BLOBS[blob_id] = PROVIDER_BLOB + return + + if action == "teardown": + BLOBS.pop(blob_id, None) + return + + msg = f"Unknown action: {action}" + raise ValueError(msg)