Skip to content

Commit c2dfd8a

Browse files
committed
docs
1 parent c8f3612 commit c2dfd8a

File tree

4 files changed

+675
-0
lines changed

4 files changed

+675
-0
lines changed

CHANGELOG.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Changelog
2+
3+
All notable changes to this project will be documented in this file.
4+
5+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7+
8+
## [Unreleased]
9+
10+
### Added
11+
- Initial release of grpcvr
12+
- Recording and playback of gRPC interactions via interceptors
13+
- Support for all RPC types: unary, server streaming, client streaming, bidirectional streaming
14+
- Async support with `AsyncRecordingChannel` for `grpc.aio`
15+
- YAML and JSON cassette formats
16+
- Flexible request matching with `MethodMatcher`, `RequestMatcher`, `MetadataMatcher`, and `CustomMatcher`
17+
- Matcher composition with `&` operator
18+
- Four record modes: `NONE`, `NEW_EPISODES`, `ALL`, `ONCE`
19+
- pytest plugin with automatic cassette management
20+
- CLI options: `--grpcvr-record`, `--grpcvr-cassette-dir`
21+
- Automatic `RecordMode.NONE` in CI environments
22+
- `@pytest.mark.grpcvr` marker for per-test configuration
23+
- Context managers: `recorded_channel`, `async_recorded_channel`, `use_cassette`
24+
- Full type annotations (PEP 561 compatible)
25+
- Documentation with MkDocs Material theme

docs/examples/advanced.md

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
# Advanced Patterns
2+
3+
This page covers advanced usage patterns for grpcvr.
4+
5+
## Custom Request Matching
6+
7+
By default, grpcvr matches requests by method name and request body. You can customize this behavior with matchers.
8+
9+
### Match by Method Only
10+
11+
Useful when request bodies contain timestamps or random IDs:
12+
13+
```python
14+
from grpcvr import recorded_channel, MethodMatcher
15+
16+
with recorded_channel(
17+
"test.yaml",
18+
target,
19+
match_on=MethodMatcher(),
20+
) as channel:
21+
stub = MyServiceStub(channel)
22+
# Any GetUser request will match the first recorded GetUser response
23+
response = stub.GetUser(GetUserRequest(id=1))
24+
```
25+
26+
### Match by Specific Metadata
27+
28+
Match requests based on specific metadata keys:
29+
30+
```python
31+
from grpcvr import recorded_channel, MetadataMatcher
32+
33+
with recorded_channel(
34+
"test.yaml",
35+
target,
36+
match_on=MetadataMatcher(keys=["authorization"]),
37+
) as channel:
38+
stub = MyServiceStub(channel)
39+
response = stub.GetUser(
40+
GetUserRequest(id=1),
41+
metadata=[("authorization", "Bearer token123")],
42+
)
43+
```
44+
45+
### Ignore Dynamic Metadata
46+
47+
Ignore metadata that changes between requests:
48+
49+
```python
50+
from grpcvr import MetadataMatcher
51+
52+
matcher = MetadataMatcher(ignore_keys=["x-request-id", "x-timestamp"])
53+
```
54+
55+
### Combining Matchers
56+
57+
Combine matchers with the `&` operator:
58+
59+
```python
60+
from grpcvr import MethodMatcher, RequestMatcher, MetadataMatcher
61+
62+
# Match method AND request body
63+
matcher = MethodMatcher() & RequestMatcher()
64+
65+
# Match method AND specific metadata
66+
matcher = MethodMatcher() & MetadataMatcher(keys=["authorization"])
67+
```
68+
69+
### Custom Matcher Functions
70+
71+
For complex matching logic, use `CustomMatcher`:
72+
73+
```python
74+
from grpcvr import CustomMatcher, MethodMatcher
75+
from grpcvr.serialization import InteractionRequest
76+
77+
def match_by_user_id(request: InteractionRequest, recorded: InteractionRequest) -> bool:
78+
"""Match if the user ID in the request body matches."""
79+
# Parse your protobuf and compare specific fields
80+
req_body = YourRequest.FromString(request.get_body_bytes())
81+
rec_body = YourRequest.FromString(recorded.get_body_bytes())
82+
return req_body.user_id == rec_body.user_id
83+
84+
matcher = MethodMatcher() & CustomMatcher(func=match_by_user_id)
85+
```
86+
87+
## Working with Cassette Files
88+
89+
### Cassette Format
90+
91+
Cassettes are stored as YAML (or JSON) files:
92+
93+
```yaml
94+
version: 1
95+
interactions:
96+
- request:
97+
method: /mypackage.MyService/GetUser
98+
body: CAE= # base64-encoded protobuf
99+
metadata:
100+
authorization:
101+
- Bearer token123
102+
response:
103+
body: CAESBUFsaWNl # base64-encoded protobuf
104+
code: OK
105+
details: null
106+
trailing_metadata: {}
107+
rpc_type: unary
108+
```
109+
110+
### Inspecting Cassettes
111+
112+
Load and inspect recorded interactions:
113+
114+
```python
115+
from grpcvr import Cassette
116+
117+
cassette = Cassette("test.yaml")
118+
119+
for interaction in cassette.interactions:
120+
print(f"Method: {interaction.request.method}")
121+
print(f"RPC Type: {interaction.rpc_type}")
122+
print(f"Status: {interaction.response.code}")
123+
```
124+
125+
### JSON Format
126+
127+
Use `.json` extension for JSON format:
128+
129+
```python
130+
with recorded_channel("test.json", target) as channel:
131+
...
132+
```
133+
134+
## Error Handling
135+
136+
### Handling Missing Cassettes
137+
138+
```python
139+
from grpcvr import Cassette, RecordMode
140+
from grpcvr.errors import CassetteNotFoundError
141+
142+
try:
143+
cassette = Cassette("missing.yaml", record_mode=RecordMode.NONE)
144+
except CassetteNotFoundError as e:
145+
print(f"Cassette not found: {e.path}")
146+
```
147+
148+
### Handling Unmatched Requests
149+
150+
```python
151+
from grpcvr import recorded_channel, RecordMode
152+
from grpcvr.errors import RecordingDisabledError
153+
154+
try:
155+
with recorded_channel("test.yaml", target, record_mode=RecordMode.NONE) as channel:
156+
stub = MyServiceStub(channel)
157+
# This will fail if not in cassette
158+
stub.GetUser(GetUserRequest(id=999))
159+
except RecordingDisabledError as e:
160+
print(f"No recorded interaction for: {e.method}")
161+
```
162+
163+
## Secure Channels
164+
165+
Use credentials for TLS connections:
166+
167+
```python
168+
import grpc
169+
from grpcvr import recorded_channel
170+
171+
credentials = grpc.ssl_channel_credentials()
172+
173+
with recorded_channel(
174+
"test.yaml",
175+
"myservice.example.com:443",
176+
credentials=credentials,
177+
) as channel:
178+
stub = MyServiceStub(channel)
179+
response = stub.GetUser(GetUserRequest(id=1))
180+
```
181+
182+
## Channel Options
183+
184+
Pass gRPC channel options:
185+
186+
```python
187+
with recorded_channel(
188+
"test.yaml",
189+
target,
190+
options=[
191+
("grpc.max_receive_message_length", 1024 * 1024 * 10),
192+
("grpc.max_send_message_length", 1024 * 1024 * 10),
193+
],
194+
) as channel:
195+
...
196+
```
197+
198+
## Testing Recorded Errors
199+
200+
grpcvr records and replays gRPC errors:
201+
202+
```python
203+
import grpc
204+
from grpcvr import recorded_channel, RecordMode
205+
206+
# Record an error response
207+
with recorded_channel("error_test.yaml", target) as channel:
208+
stub = MyServiceStub(channel)
209+
try:
210+
stub.GetUser(GetUserRequest(id=999)) # Returns NOT_FOUND
211+
except grpc.RpcError as e:
212+
assert e.code() == grpc.StatusCode.NOT_FOUND
213+
214+
# Replay the error
215+
with recorded_channel("error_test.yaml", target, record_mode=RecordMode.NONE) as channel:
216+
stub = MyServiceStub(channel)
217+
try:
218+
stub.GetUser(GetUserRequest(id=999))
219+
except grpc.RpcError as e:
220+
# Same error is replayed
221+
assert e.code() == grpc.StatusCode.NOT_FOUND
222+
assert "not found" in e.details().lower()
223+
```
224+
225+
## Parallel Test Execution
226+
227+
Each test should use its own cassette file to avoid conflicts:
228+
229+
```python
230+
import pytest
231+
from grpcvr import Cassette, RecordingChannel
232+
233+
@pytest.fixture
234+
def cassette_path(tmp_path, request):
235+
"""Generate unique cassette path per test."""
236+
return tmp_path / f"{request.node.name}.yaml"
237+
238+
def test_one(cassette_path, grpc_target):
239+
cassette = Cassette(cassette_path)
240+
...
241+
242+
def test_two(cassette_path, grpc_target):
243+
cassette = Cassette(cassette_path)
244+
...
245+
```
246+
247+
The built-in `cassette` fixture from the pytest plugin already handles this automatically.

0 commit comments

Comments
 (0)