Skip to content

Commit 98bc124

Browse files
authored
feat: parse request/response for logging (#1696)
* feat: parse request/response for logging * add test case for list * address PR comments * address PR feedback * fix typo * add test coverage * add code coverage
1 parent ce124b0 commit 98bc124

File tree

2 files changed

+221
-12
lines changed

2 files changed

+221
-12
lines changed

google/auth/_helpers.py

Lines changed: 80 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import datetime
2020
from email.message import Message
2121
import hashlib
22+
import json
2223
import logging
2324
import sys
2425
from typing import Any, Mapping, Optional
@@ -344,7 +345,7 @@ def request_log(
344345
logger: logging.Logger,
345346
method: str,
346347
url: str,
347-
body: Optional[Any],
348+
body: Optional[bytes],
348349
headers: Optional[Mapping[str, str]],
349350
) -> None:
350351
"""
@@ -357,10 +358,84 @@ def request_log(
357358
body: The request body (can be None).
358359
headers: The request headers (can be None).
359360
"""
360-
# TODO(https://github.com/googleapis/google-auth-library-python/issues/1682): Add httpRequest extra to log event.
361361
# TODO(https://github.com/googleapis/google-auth-library-python/issues/1681): Hash sensitive information.
362362
if is_logging_enabled(logger):
363-
logger.debug("Making request: %s %s", method, url)
363+
content_type = (
364+
headers["Content-Type"] if headers and "Content-Type" in headers else ""
365+
)
366+
json_body = _parse_request_body(body, content_type=content_type)
367+
logger.debug(
368+
"Making request...",
369+
extra={
370+
"httpRequest": {
371+
"method": method,
372+
"url": url,
373+
"body": json_body,
374+
"headers": headers,
375+
}
376+
},
377+
)
378+
379+
380+
def _parse_request_body(body: Optional[bytes], content_type: str = "") -> Any:
381+
"""
382+
Parses a request body, handling bytes and string types, and different content types.
383+
384+
Args:
385+
body (Optional[bytes]): The request body.
386+
content_type (str): The content type of the request body, e.g., "application/json",
387+
"application/x-www-form-urlencoded", or "text/plain". If empty, attempts
388+
to parse as JSON.
389+
390+
Returns:
391+
Parsed body (dict, str, or None).
392+
- JSON: Decodes if content_type is "application/json" or None (fallback).
393+
- URL-encoded: Parses if content_type is "application/x-www-form-urlencoded".
394+
- Plain text: Returns string if content_type is "text/plain".
395+
- None: Returns if body is None, UTF-8 decode fails, or content_type is unknown.
396+
"""
397+
if body is None:
398+
return None
399+
try:
400+
body_str = body.decode("utf-8")
401+
except (UnicodeDecodeError, AttributeError):
402+
return None
403+
content_type = content_type.lower()
404+
if not content_type or "application/json" in content_type:
405+
try:
406+
return json.loads(body_str)
407+
except (json.JSONDecodeError, TypeError):
408+
return body_str
409+
if "application/x-www-form-urlencoded" in content_type:
410+
parsed_query = urllib.parse.parse_qs(body_str)
411+
result = {k: v[0] for k, v in parsed_query.items()}
412+
return result
413+
if "text/plain" in content_type:
414+
return body_str
415+
return None
416+
417+
418+
def _parse_response(response: Any) -> Any:
419+
"""
420+
Parses a response, attempting to decode JSON.
421+
422+
Args:
423+
response: The response object to parse. This can be any type, but
424+
it is expected to have a `json()` method if it contains JSON.
425+
426+
Returns:
427+
The parsed response. If the response contains valid JSON, the
428+
decoded JSON object (e.g., a dictionary or list) is returned.
429+
If the response does not have a `json()` method or if the JSON
430+
decoding fails, the original response object is returned.
431+
"""
432+
try:
433+
json_response = response.json()
434+
return json_response
435+
except AttributeError:
436+
return response
437+
except json.JSONDecodeError:
438+
return response
364439

365440

366441
def response_log(logger: logging.Logger, response: Any) -> None:
@@ -371,7 +446,7 @@ def response_log(logger: logging.Logger, response: Any) -> None:
371446
logger: The logging.Logger instance to use.
372447
response: The HTTP response object to log.
373448
"""
374-
# TODO(https://github.com/googleapis/google-auth-library-python/issues/1683): Add httpResponse extra to log event.
375449
# TODO(https://github.com/googleapis/google-auth-library-python/issues/1681): Hash sensitive information.
376450
if is_logging_enabled(logger):
377-
logger.debug("Response received...")
451+
json_response = _parse_response(response)
452+
logger.debug("Response received...", extra={"httpResponse": json_response})

tests/test__helpers.py

Lines changed: 141 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
# limitations under the License.
1414

1515
import datetime
16+
import json
1617
import logging
1718
from unittest import mock
1819
import urllib
@@ -100,7 +101,7 @@ def test_to_bytes_with_bytes():
100101

101102

102103
def test_to_bytes_with_unicode():
103-
value = u"string-val"
104+
value = "string-val"
104105
encoded_value = b"string-val"
105106
assert _helpers.to_bytes(value) == encoded_value
106107

@@ -111,13 +112,13 @@ def test_to_bytes_with_nonstring_type():
111112

112113

113114
def test_from_bytes_with_unicode():
114-
value = u"bytes-val"
115+
value = "bytes-val"
115116
assert _helpers.from_bytes(value) == value
116117

117118

118119
def test_from_bytes_with_bytes():
119120
value = b"string-val"
120-
decoded_value = u"string-val"
121+
decoded_value = "string-val"
121122
assert _helpers.from_bytes(value) == decoded_value
122123

123124

@@ -306,10 +307,18 @@ def test_request_log_debug_enabled(logger, caplog):
306307
logger,
307308
"GET",
308309
"http://example.com",
309-
{"key": "value"},
310+
b'{"key": "value"}',
310311
{"Authorization": "Bearer token"},
311312
)
312-
assert "Making request: GET http://example.com" in caplog.text
313+
assert len(caplog.records) == 1
314+
record = caplog.records[0]
315+
assert record.message == "Making request..."
316+
assert record.httpRequest == {
317+
"method": "GET",
318+
"url": "http://example.com",
319+
"body": {"key": "value"},
320+
"headers": {"Authorization": "Bearer token"},
321+
}
313322

314323

315324
def test_request_log_debug_disabled(logger, caplog):
@@ -328,12 +337,137 @@ def test_request_log_debug_disabled(logger, caplog):
328337
def test_response_log_debug_enabled(logger, caplog):
329338
logger.setLevel(logging.DEBUG)
330339
with mock.patch("google.auth._helpers.CLIENT_LOGGING_SUPPORTED", True):
331-
_helpers.response_log(logger, "response_object")
332-
assert "Response received..." in caplog.text
340+
_helpers.response_log(logger, {"payload": None})
341+
assert len(caplog.records) == 1
342+
record = caplog.records[0]
343+
assert record.message == "Response received..."
344+
assert record.httpResponse == {"payload": None}
333345

334346

335347
def test_response_log_debug_disabled(logger, caplog):
336348
logger.setLevel(logging.INFO)
337349
with mock.patch("google.auth._helpers.CLIENT_LOGGING_SUPPORTED", True):
338350
_helpers.response_log(logger, "another_response")
339351
assert "Response received..." not in caplog.text
352+
353+
354+
def test_response_log_debug_enabled_response_list(logger, caplog):
355+
# NOTE: test the response log when response.json() returns a list as per
356+
# https://requests.readthedocs.io/en/latest/api/#requests.Response.json.
357+
class MockResponse:
358+
def json(self):
359+
return ["item1", "item2", "item3"]
360+
361+
response = MockResponse()
362+
logger.setLevel(logging.DEBUG)
363+
with mock.patch("google.auth._helpers.CLIENT_LOGGING_SUPPORTED", True):
364+
_helpers.response_log(logger, response)
365+
assert len(caplog.records) == 1
366+
record = caplog.records[0]
367+
assert record.message == "Response received..."
368+
assert record.httpResponse == ["item1", "item2", "item3"]
369+
370+
371+
def test_parse_request_body_bytes_valid():
372+
body = b"key1=value1&key2=value2"
373+
expected = {"key1": "value1", "key2": "value2"}
374+
assert (
375+
_helpers._parse_request_body(
376+
body, content_type="application/x-www-form-urlencoded"
377+
)
378+
== expected
379+
)
380+
381+
382+
def test_parse_request_body_bytes_empty():
383+
body = b""
384+
assert _helpers._parse_request_body(body) == ""
385+
386+
387+
def test_parse_request_body_bytes_invalid_encoding():
388+
body = b"\xff\xfe\xfd" # Invalid UTF-8 sequence
389+
assert _helpers._parse_request_body(body) is None
390+
391+
392+
def test_parse_request_body_bytes_malformed_query():
393+
body = b"key1=value1&key2=value2" # missing equals
394+
expected = {"key1": "value1", "key2": "value2"}
395+
assert (
396+
_helpers._parse_request_body(
397+
body, content_type="application/x-www-form-urlencoded"
398+
)
399+
== expected
400+
)
401+
402+
403+
def test_parse_request_body_none():
404+
assert _helpers._parse_request_body(None) is None
405+
406+
407+
def test_parse_request_body_bytes_no_content_type():
408+
body = b'{"key": "value"}'
409+
expected = {"key": "value"}
410+
assert _helpers._parse_request_body(body) == expected
411+
412+
413+
def test_parse_request_body_bytes_content_type_json():
414+
body = b'{"key": "value"}'
415+
expected = {"key": "value"}
416+
assert (
417+
_helpers._parse_request_body(body, content_type="application/json") == expected
418+
)
419+
420+
421+
def test_parse_request_body_content_type_urlencoded():
422+
body = b"key=value"
423+
expected = {"key": "value"}
424+
assert (
425+
_helpers._parse_request_body(
426+
body, content_type="application/x-www-form-urlencoded"
427+
)
428+
== expected
429+
)
430+
431+
432+
def test_parse_request_body_bytes_content_type_text():
433+
body = b"This is plain text."
434+
expected = "This is plain text."
435+
assert _helpers._parse_request_body(body, content_type="text/plain") == expected
436+
437+
438+
def test_parse_request_body_content_type_invalid():
439+
body = b'{"key": "value"}'
440+
assert _helpers._parse_request_body(body, content_type="invalid") is None
441+
442+
443+
def test_parse_request_body_other_type():
444+
assert _helpers._parse_request_body(123) is None
445+
assert _helpers._parse_request_body("string") is None
446+
447+
448+
def test_parse_response_json_valid():
449+
class MockResponse:
450+
def json(self):
451+
return {"data": "test"}
452+
453+
response = MockResponse()
454+
expected = {"data": "test"}
455+
assert _helpers._parse_response(response) == expected
456+
457+
458+
def test_parse_response_json_invalid():
459+
class MockResponse:
460+
def json(self):
461+
raise json.JSONDecodeError("msg", "doc", 0)
462+
463+
response = MockResponse()
464+
assert _helpers._parse_response(response) == response
465+
466+
467+
def test_parse_response_no_json_method():
468+
response = "plain text"
469+
assert _helpers._parse_response(response) == "plain text"
470+
471+
472+
def test_parse_response_none():
473+
assert _helpers._parse_response(None) is None

0 commit comments

Comments
 (0)