11# (C) 2022 GoodData Corporation
22from __future__ import annotations
33
4- import json
54import os
65import typing
7- from json import JSONDecodeError
86from typing import Any
97from urllib .parse import urlparse
108
9+ import orjson
1110import vcr
1211import yaml
1312from vcr .record_mode import RecordMode
2019# Fields stripped from request bodies before VCR body matching.
2120# These differ between local and staging environments but don't affect
2221# the logical identity of a request.
23- _ENV_SPECIFIC_BODY_FIELDS = {"password" , "token" , "url" , "username" , "privateKey" }
22+ _ENV_SPECIFIC_BODY_FIELDS = {
23+ "password" ,
24+ "token" ,
25+ "url" ,
26+ "username" ,
27+ "privateKey" ,
28+ "client_secret" ,
29+ "private_key_passphrase" ,
30+ }
2431
2532# Canonical (local) values — cassettes always use these.
2633_CANONICAL_HOST = "http://localhost:3000"
3340# Each entry is (source_string, replacement_string).
3441# Ordered longest-first so more specific patterns match before substrings.
3542_normalization_replacements : list [tuple [str , str ]] = []
43+ _normalization_configured : bool = False
3644
3745
3846def configure_normalization (test_config : dict [str , Any ]) -> None :
@@ -49,7 +57,7 @@ def configure_normalization(test_config: dict[str, Any]) -> None:
4957 rm -rf packages/gooddata-sdk/.tox
5058 uv cache clean tests-support --force
5159 """
52- global _normalization_replacements
60+ global _normalization_replacements , _normalization_configured
5361 replacements : list [tuple [str , str ]] = []
5462
5563 parsed = urlparse (test_config .get ("host" , _CANONICAL_HOST ))
@@ -99,6 +107,7 @@ def configure_normalization(test_config: dict[str, Any]) -> None:
99107 replacements .sort (key = lambda pair : len (pair [0 ]), reverse = True )
100108
101109 _normalization_replacements = replacements
110+ _normalization_configured = True
102111
103112
104113def _apply_replacements (text : str ) -> str :
@@ -113,8 +122,8 @@ def _normalize_body(body: str | None) -> str:
113122 if not body :
114123 return body or ""
115124 try :
116- data = json .loads (body )
117- except (JSONDecodeError , TypeError ):
125+ data = orjson .loads (body )
126+ except (orjson . JSONDecodeError , TypeError ):
118127 return body
119128
120129 def _strip (obj : Any ) -> Any :
@@ -124,7 +133,7 @@ def _strip(obj: Any) -> Any:
124133 return [_strip (item ) for item in obj ]
125134 return obj
126135
127- return json .dumps (_strip (data ), sort_keys = True )
136+ return orjson .dumps (_strip (data ), option = orjson . OPT_SORT_KEYS ). decode ( "utf-8" )
128137
129138
130139def _body_matcher (r1 : Any , r2 : Any ) -> None :
@@ -174,13 +183,15 @@ def deserialize(self, cassette_string: str) -> dict[str, Any]:
174183 if isinstance (request_body , str ) and request_body .startswith ("<?xml" ):
175184 interaction ["request" ]["body" ] = request_body
176185 else :
177- interaction ["request" ]["body" ] = json .dumps (request_body )
186+ interaction ["request" ]["body" ] = orjson .dumps (request_body ). decode ( "utf-8" )
178187 if response_body is not None and response_body ["string" ] != "" :
179188 try :
180189 if isinstance (response_body ["string" ], str ) and response_body ["string" ].startswith ("<?xml" ):
181190 interaction ["response" ]["body" ]["string" ] = response_body ["string" ]
182191 else :
183- interaction ["response" ]["body" ]["string" ] = json .dumps (response_body ["string" ])
192+ interaction ["response" ]["body" ]["string" ] = orjson .dumps (response_body ["string" ]).decode (
193+ "utf-8"
194+ )
184195 except TypeError :
185196 # this exception is expected while getting XLSX file content
186197 continue
@@ -192,14 +203,14 @@ def serialize(self, cassette_dict: dict[str, Any]) -> str:
192203 response_body = interaction ["response" ]["body" ]
193204 if request_body is not None :
194205 try :
195- interaction ["request" ]["body" ] = json .loads (request_body )
196- except (JSONDecodeError , UnicodeDecodeError ):
206+ interaction ["request" ]["body" ] = orjson .loads (request_body )
207+ except (orjson . JSONDecodeError , UnicodeDecodeError ):
197208 # The response can be in XML
198209 interaction ["request" ]["body" ] = request_body
199210 if response_body is not None and response_body ["string" ] != "" :
200211 try :
201- interaction ["response" ]["body" ]["string" ] = json .loads (response_body ["string" ])
202- except (JSONDecodeError , UnicodeDecodeError ):
212+ interaction ["response" ]["body" ]["string" ] = orjson .loads (response_body ["string" ])
213+ except (orjson . JSONDecodeError , UnicodeDecodeError ):
203214 # these exceptions are expected while getting file content
204215 continue
205216 return yaml .dump (cassette_dict , Dumper = IndentDumper , sort_keys = True )
@@ -213,6 +224,12 @@ def _normalize_uri(uri: str) -> str:
213224
214225
215226def custom_before_request (request , headers_str : str = HEADERS_STR ):
227+ if not _normalization_configured :
228+ raise RuntimeError (
229+ "VCR normalization not configured. "
230+ "Ensure your test fixture depends on 'test_config' (directly or transitively) "
231+ "so that configure_normalization() runs before cassette recording starts."
232+ )
216233 # Normalize URI to canonical host
217234 request .uri = _normalize_uri (request .uri )
218235
@@ -234,6 +251,13 @@ def custom_before_response(
234251 non_static_headers : list [str ] | None = None ,
235252 placeholder : list [str ] | None = None ,
236253):
254+ if not _normalization_configured :
255+ raise RuntimeError (
256+ "VCR normalization not configured. "
257+ "Ensure your test fixture depends on 'test_config' (directly or transitively) "
258+ "so that configure_normalization() runs before cassette recording starts."
259+ )
260+
237261 if non_static_headers is None :
238262 non_static_headers = NON_STATIC_HEADERS
239263
0 commit comments