Skip to content

Commit dd43092

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 4dc54b3 commit dd43092

File tree

9 files changed

+410
-0
lines changed

9 files changed

+410
-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: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
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+
from pathlib import Path
15+
from threading import Thread
16+
from typing import TYPE_CHECKING, Annotated
17+
18+
import pytest
19+
import uvicorn
20+
from fastapi import FastAPI, Form, HTTPException, UploadFile
21+
from pydantic import BaseModel
22+
23+
import pact._util
24+
from pact import Verifier
25+
26+
if TYPE_CHECKING:
27+
from typing import Any
28+
29+
30+
# Simple FastAPI provider for handling multipart uploads
31+
app = FastAPI()
32+
"""
33+
FastAPI application to handle multipart/form-data uploads.
34+
"""
35+
36+
uploads: dict[str, dict[str, Any]] = {}
37+
"""
38+
In-memory store for uploaded files and metadata.
39+
"""
40+
41+
42+
class UploadMetadata(BaseModel):
43+
"""
44+
Model for the JSON metadata part of the upload.
45+
"""
46+
47+
name: str
48+
size: int
49+
50+
51+
class UploadResponse(BaseModel):
52+
"""
53+
Model for the response returned after a successful upload.
54+
"""
55+
56+
id: str
57+
message: str
58+
metadata: UploadMetadata
59+
image_size: int
60+
61+
62+
@app.post("/upload", status_code=201)
63+
async def upload_file(
64+
metadata: Annotated[str, Form()],
65+
image: Annotated[UploadFile, Form()],
66+
) -> UploadResponse:
67+
"""
68+
Handle multipart upload with JSON metadata and image.
69+
70+
This endpoint processes a multipart/form-data request containing a JSON
71+
metadata part and an image file part. It validates the metadata structure
72+
and the image content type, then stores the upload in memory.
73+
"""
74+
metadata_dict = UploadMetadata.model_validate_json(metadata)
75+
if image.content_type != "image/jpeg":
76+
msg = f"Expected image/jpeg, got {image.content_type}"
77+
raise HTTPException(status_code=400, detail=msg)
78+
79+
content = await image.read()
80+
if not content.startswith((b"\xff\xd8\xff\xdb", b"\xff\xd8\xff\xe0")):
81+
msg = "Invalid/malformed JPEG file"
82+
raise HTTPException(status_code=400, detail=msg)
83+
84+
upload_id = f"upload-{len(uploads) + 1}"
85+
uploads[upload_id] = {
86+
"id": upload_id,
87+
"metadata": metadata_dict,
88+
"filename": image.filename,
89+
"content_type": image.content_type,
90+
"size": len(content),
91+
}
92+
93+
return UploadResponse(
94+
id=upload_id,
95+
message="Upload successful",
96+
metadata=metadata_dict,
97+
image_size=len(content),
98+
)
99+
100+
101+
@pytest.fixture
102+
def app_server() -> str:
103+
"""
104+
Start FastAPI server for provider verification.
105+
"""
106+
hostname = "localhost"
107+
port = pact._util.find_free_port() # noqa: SLF001
108+
Thread(
109+
target=uvicorn.run,
110+
args=(app,),
111+
kwargs={"host": hostname, "port": port},
112+
daemon=True,
113+
).start()
114+
return f"http://{hostname}:{port}"
115+
116+
117+
def test_provider_multipart(app_server: str) -> None:
118+
"""
119+
Verify the provider against the multipart upload contract.
120+
121+
In general, there are no special considerations for verifying providers with
122+
multipart requests. The Pact verifier will read the contract file generated
123+
by the consumer tests and ensure that the provider can handle requests that
124+
conform to the specified matching rules.
125+
126+
As with any provider verification, the test needs to ensure that provider
127+
states are set up correctly. This example does not include any provider
128+
states to ensure simplicity.
129+
"""
130+
verifier = (
131+
Verifier("multipart-provider")
132+
.add_source(Path(__file__).parents[1] / "pacts")
133+
.add_transport(url=app_server)
134+
)
135+
136+
verifier.verify()
137+
138+
assert len(uploads) > 0, "No uploads were processed by the provider"

0 commit comments

Comments
 (0)