|
| 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