Skip to content

Commit 0dda3f3

Browse files
committed
docs: add multipart/form-data matching rule example
Add an example which showcases how matching rules can be used to validate multipart/form-data payloads. Signed-off-by: JP-Ellis <josh@jpellis.me>
1 parent 6cec3ad commit 0dda3f3

File tree

9 files changed

+412
-0
lines changed

9 files changed

+412
-0
lines changed

examples/README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,14 @@ The code within the examples is intended to be well-documented and you are encou
88

99
## Available Examples
1010

11+
### Patterns Catalog
12+
13+
#### [Pact Patterns Catalog](./catalog/README.md)
14+
15+
- **Location**: `examples/catalog/`
16+
- **Purpose**: Focused code snippets demonstrating specific Pact patterns
17+
- **Content**: Well-documented use cases and techniques without full application context
18+
1119
### HTTP Examples
1220

1321
#### [aiohttp and Flask](./http/aiohttp_and_flask/README.md)

examples/catalog/README.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Pact Patterns Catalog
2+
3+
This catalog contains focused, well-documented code snippets demonstrating specific Pact patterns and use cases. Unlike the full examples in the parent directory, catalog entries are designed to showcase a single pattern or technique with minimal application context.
4+
5+
## Available
6+
7+
- [Multipart Form Data with Matching Rules](./multipart_matching_rules/README.md)
8+
9+
## Using Catalog Entries
10+
11+
Catalog entries are intended as a reference for learning and adapting Pact patterns. To get the most value:
12+
13+
- **Read the documentation** in each entry's README to understand the pattern and its intended use.
14+
- **Review the code** to see how the pattern is implemented in practice.
15+
- **Explore the tests** to see example usages and edge cases.
16+
17+
If you want to experiment or adapt a pattern, you can run the tests for any entry:
18+
19+
```console
20+
cd examples/catalog
21+
uv run --group test pytest <catalog_entry_directory>
22+
```
23+
24+
## Contributing Patterns
25+
26+
When adding a new catalog entry:
27+
28+
1. Focus on a single pattern or technique
29+
2. Provide minimal but complete code, emphasizing the Pact aspects over application logic
30+
3. Document the pattern thoroughly
31+
4. Include working pytest tests
32+
5. Add it to this README
33+
34+
For complete examples with full application context, consider adding to the main examples directory instead.

examples/catalog/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# noqa: D104
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
2+
# Multipart Form Data with Matching Rules
3+
4+
This entry demonstrates how to use Pact matching rules with multipart/form-data requests. Specifically, it demonstrates how multipart data is specified on the consumer side, and how matching rules can be applied to different parts of the multipart body.
5+
6+
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).
7+
8+
Related documentation:
9+
10+
* [Pact Matching Rules](https://docs.pact.io/getting_started/matching)
11+
* [Multipart Form Data (RFC 7578)](https://tools.ietf.org/html/rfc7578)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# noqa: D104
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
"""
2+
Consumer test demonstrating multipart/form-data with matching rules.
3+
4+
This test shows how to use Pact matching rules with multipart requests. The
5+
examples illustrates this with a request containing both JSON metadata and
6+
binary data (an image). The contract uses matching rules to validate
7+
structure and types rather than exact values, allowing flexibility in the data
8+
sent by the consumer and accepted by the provider.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import json
14+
from pathlib import Path
15+
from typing import TYPE_CHECKING
16+
17+
import httpx
18+
import pytest
19+
20+
from pact import Pact, match
21+
22+
if TYPE_CHECKING:
23+
from collections.abc import Generator
24+
25+
26+
# Minimal JPEG for testing. The important part is the magic bytes. The rest is
27+
# not strictly valid JPEG data.
28+
# fmt: off
29+
JPEG_BYTES = bytes([
30+
0xFF, 0xD8, # Start of Image (SOI) marker
31+
0xFF, 0xE0, # JFIF APP0 Marker
32+
0x00, 0x10, # Length of APP0 segment
33+
0x4A, 0x46, 0x49, 0x46, 0x00, # "JFIF\0"
34+
0x01, 0x02, # Major and minor versions
35+
])
36+
# fmt: on
37+
"""
38+
Some minimal JPEG bytes for testing multipart uploads.
39+
40+
In this example, we only need the JPEG magic bytes to validate the file type.
41+
This is not a complete JPEG file, but is sufficient for testing purposes.
42+
"""
43+
44+
45+
@pytest.fixture
46+
def pact() -> Generator[Pact, None, None]:
47+
"""
48+
Set up Pact for consumer contract testing.
49+
50+
This fixture initializes a Pact instance for the consumer tests, specifying
51+
the consumer and provider names, and ensuring that the generated Pact files
52+
are written to the appropriate directory after the tests run.
53+
"""
54+
pact = Pact(
55+
"multipart-consumer",
56+
"multipart-provider",
57+
)
58+
yield pact
59+
pact.write_file(Path(__file__).parents[1] / "pacts")
60+
61+
62+
def test_multipart_upload_with_matching_rules(pact: Pact) -> None:
63+
"""
64+
Test multipart upload with matching of the contents.
65+
66+
This test builds a `multipart/form-data` request by hand, and then uses a
67+
library (`httpx`) to send the request to the mock server started by Pact.
68+
Unlike simpler payloads, the matching rules _cannot_ be embedded within the
69+
body itself. Instead, the body and matching rules are defined in separate
70+
calls.
71+
72+
Some key points about this example:
73+
74+
- We use a matching rule for the `Content-Type` header to allow any valid
75+
multipart boundary. This is important because many HTTP libraries
76+
generate random boundaries automatically without user control.
77+
- The body includes arbitrary binary data (a JPEG image) which cannot be
78+
represented as a string. Therefore, it is critical that
79+
`with_binary_body` is used to define the payload.
80+
- Matching rules are defined for both the JSON metadata and the image part
81+
to allow flexibility in the values sent by the consumer. The general
82+
form to match a part within the multipart body is `$.<part name>`. So
83+
to match a field in the `metadata` part, we use `$.metadata.<field>`; or
84+
to match the content type of the `image` part, we use `$.image`:
85+
86+
```python
87+
from pact import match
88+
89+
{
90+
"body": {
91+
"$.image": match.content_type("image/jpeg"),
92+
"$.metadata.name": match.regex(regex=r"^[a-zA-Z]+$"),
93+
},
94+
}
95+
```
96+
97+
/// warning
98+
99+
Proper content types are essential when working with multipart data. This
100+
ensures that Pact can correctly identify and apply matching rules to each
101+
part of the multipart body. If content types are missing or incorrect, the
102+
matching rules may not be applied as expected, leading to test failures or
103+
incorrect behavior.
104+
105+
///
106+
107+
To view the implementation, expand the source code below.
108+
"""
109+
# It is recommended to use a fixed boundary for testing, this ensures that
110+
# the generated Pact is consistent across test runs.
111+
boundary = "test-boundary-12345"
112+
113+
metadata = {
114+
"name": "test",
115+
"size": 100,
116+
}
117+
118+
# Build multipart body with both JSON and binary parts. Note that since we
119+
# are combining text and binary data, the strings must be encoded to bytes.
120+
expected_body = (
121+
f"--{boundary}\r\n"
122+
f'Content-Disposition: form-data; name="metadata"\r\n'
123+
f"Content-Type: application/json\r\n"
124+
f"\r\n"
125+
f"{json.dumps(metadata)}\r\n"
126+
f"--{boundary}\r\n"
127+
f'Content-Disposition: form-data; name="image"; filename="test.jpg"\r\n'
128+
f"Content-Type: image/jpeg\r\n"
129+
f"\r\n"
130+
).encode()
131+
expected_body += JPEG_BYTES
132+
expected_body += f"\r\n--{boundary}--\r\n".encode()
133+
134+
# Define the interaction with matching rules
135+
(
136+
pact.upon_receiving("a multipart upload with JSON metadata and image")
137+
.with_request("POST", "/upload")
138+
.with_header(
139+
"Content-Type",
140+
# The matcher here is important if you don't have the ability to fix
141+
# the boundary in the actual request (e.g., when using a library
142+
# that generates it automatically).
143+
match.regex(
144+
f"multipart/form-data; boundary={boundary}",
145+
regex=r"multipart/form-data;\s*boundary=.*",
146+
),
147+
)
148+
.with_binary_body(
149+
expected_body,
150+
f"multipart/form-data; boundary={boundary}",
151+
)
152+
# Matching rules make the contract flexible
153+
.with_matching_rules({
154+
"body": {
155+
"$.image": match.content_type("image/jpeg"),
156+
"$.metadata": match.type({}),
157+
"$.metadata.name": match.regex(regex=r"^[a-zA-Z]+$"),
158+
"$.metadata.size": match.int(),
159+
},
160+
})
161+
.will_respond_with(201)
162+
.with_body({
163+
"id": "upload-1",
164+
"message": "Upload successful",
165+
"metadata": {"name": "test", "size": 100},
166+
"image_size": len(JPEG_BYTES),
167+
})
168+
)
169+
170+
# Execute the test. Note that the matching rules take effect here, so we can
171+
# send data that differs from the example in the contract.
172+
with pact.serve() as srv:
173+
# Simple inline consumer: just make the multipart request
174+
files = {
175+
"metadata": (
176+
None,
177+
json.dumps({"name": "different", "size": 200}).encode(),
178+
"application/json",
179+
),
180+
"image": ("test.jpg", JPEG_BYTES, "image/jpeg"),
181+
}
182+
response = httpx.post(f"{srv.url}/upload", files=files, timeout=5)
183+
184+
assert response.status_code == 201
185+
result = response.json()
186+
assert result["message"] == "Upload successful"
187+
assert result["id"] == "upload-1"
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
"""
2+
Provider verification for multipart/form-data contract with matching rules.
3+
4+
This test demonstrates provider verification for contracts with multipart
5+
requests and matching rules. It uses FastAPI to create a simple server that can
6+
process multipart uploads containing JSON metadata and a JPEG image. The test
7+
verifies that the provider complies with the contract generated by the consumer
8+
tests, ensuring that it can handle variations in the data while maintaining
9+
compatibility.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import time
15+
from pathlib import Path
16+
from threading import Thread
17+
from typing import TYPE_CHECKING, Annotated
18+
19+
import pytest
20+
import uvicorn
21+
from fastapi import FastAPI, Form, HTTPException, UploadFile
22+
from pydantic import BaseModel
23+
24+
import pact._util
25+
from pact import Verifier
26+
27+
if TYPE_CHECKING:
28+
from typing import Any
29+
30+
31+
# Simple FastAPI provider for handling multipart uploads
32+
app = FastAPI()
33+
"""
34+
FastAPI application to handle multipart/form-data uploads.
35+
"""
36+
37+
uploads: dict[str, dict[str, Any]] = {}
38+
"""
39+
In-memory store for uploaded files and metadata.
40+
"""
41+
42+
43+
class UploadMetadata(BaseModel):
44+
"""
45+
Model for the JSON metadata part of the upload.
46+
"""
47+
48+
name: str
49+
size: int
50+
51+
52+
class UploadResponse(BaseModel):
53+
"""
54+
Model for the response returned after a successful upload.
55+
"""
56+
57+
id: str
58+
message: str
59+
metadata: UploadMetadata
60+
image_size: int
61+
62+
63+
@app.post("/upload", status_code=201)
64+
async def upload_file(
65+
metadata: Annotated[str, Form()],
66+
image: Annotated[UploadFile, Form()],
67+
) -> UploadResponse:
68+
"""
69+
Handle multipart upload with JSON metadata and image.
70+
71+
This endpoint processes a multipart/form-data request containing a JSON
72+
metadata part and an image file part. It validates the metadata structure
73+
and the image content type, then stores the upload in memory.
74+
"""
75+
metadata_dict = UploadMetadata.model_validate_json(metadata)
76+
if image.content_type != "image/jpeg":
77+
msg = f"Expected image/jpeg, got {image.content_type}"
78+
raise HTTPException(status_code=400, detail=msg)
79+
80+
content = await image.read()
81+
if not content.startswith((b"\xff\xd8\xff\xdb", b"\xff\xd8\xff\xe0")):
82+
msg = "Invalid/malformed JPEG file"
83+
raise HTTPException(status_code=400, detail=msg)
84+
85+
upload_id = f"upload-{len(uploads) + 1}"
86+
uploads[upload_id] = {
87+
"id": upload_id,
88+
"metadata": metadata_dict,
89+
"filename": image.filename,
90+
"content_type": image.content_type,
91+
"size": len(content),
92+
}
93+
94+
return UploadResponse(
95+
id=upload_id,
96+
message="Upload successful",
97+
metadata=metadata_dict,
98+
image_size=len(content),
99+
)
100+
101+
102+
@pytest.fixture
103+
def app_server() -> str:
104+
"""
105+
Start FastAPI server for provider verification.
106+
"""
107+
hostname = "localhost"
108+
port = pact._util.find_free_port() # noqa: SLF001
109+
Thread(
110+
target=uvicorn.run,
111+
args=(app,),
112+
kwargs={"host": hostname, "port": port},
113+
daemon=True,
114+
).start()
115+
time.sleep(0.1) # Allow server time to start
116+
return f"http://{hostname}:{port}"
117+
118+
119+
def test_provider_multipart(app_server: str) -> None:
120+
"""
121+
Verify the provider against the multipart upload contract.
122+
123+
In general, there are no special considerations for verifying providers with
124+
multipart requests. The Pact verifier will read the contract file generated
125+
by the consumer tests and ensure that the provider can handle requests that
126+
conform to the specified matching rules.
127+
128+
As with any provider verification, the test needs to ensure that provider
129+
states are set up correctly. This example does not include any provider
130+
states to ensure simplicity.
131+
"""
132+
verifier = (
133+
Verifier("multipart-provider")
134+
.add_source(Path(__file__).parents[1] / "pacts")
135+
.add_transport(url=app_server)
136+
)
137+
138+
verifier.verify()
139+
140+
assert len(uploads) > 0, "No uploads were processed by the provider"

0 commit comments

Comments
 (0)