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
1 change: 1 addition & 0 deletions examples/catalog/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions examples/catalog/binary_payloads/README.md
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions examples/catalog/binary_payloads/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# noqa: D104
139 changes: 139 additions & 0 deletions examples/catalog/binary_payloads/test_consumer.py
Original file line number Diff line number Diff line change
@@ -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
181 changes: 181 additions & 0 deletions examples/catalog/binary_payloads/test_provider.py
Original file line number Diff line number Diff line change
@@ -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)