Skip to content
This repository was archived by the owner on Jun 13, 2023. It is now read-only.

Commit 66c589b

Browse files
authored
fix(fastapi): collecting all responses using fast json encode (#333)
1 parent b0c5e5d commit 66c589b

File tree

2 files changed

+99
-30
lines changed

2 files changed

+99
-30
lines changed

epsagon/runners/fastapi.py

Lines changed: 56 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,13 @@
1414
UJSONResponse,
1515
RedirectResponse,
1616
)
17+
from fastapi.encoders import jsonable_encoder
1718
from epsagon.common import EpsagonWarning
1819
from ..event import BaseEvent
19-
from ..utils import add_data_if_needed, normalize_http_url
20+
from ..utils import add_data_if_needed, normalize_http_url, print_debug
2021
from ..constants import EPSAGON_HEADER
2122

22-
SUPPORTED_RESPONSE_TYPES = (
23+
SUPPORTED_RAW_RESPONSE_TYPES = (
2324
JSONResponse,
2425
HTMLResponse,
2526
PlainTextResponse,
@@ -85,39 +86,65 @@ def update_request_body(self, body):
8586
body
8687
)
8788

89+
def _update_raw_response_body(self, response, response_type):
90+
"""
91+
Updates the response body by given `raw` response and its type
92+
"""
93+
body = response.body
94+
if response_type == JSONResponse:
95+
try:
96+
body = json.loads(body)
97+
except Exception: # pylint: disable=W0703
98+
warnings.warn(
99+
'Could not load response json',
100+
EpsagonWarning
101+
)
102+
body = body.decode('utf-8')
103+
else:
104+
body = body.decode('utf-8')
105+
add_data_if_needed(
106+
self.resource['metadata'],
107+
'Response Data',
108+
body
109+
)
110+
111+
112+
def _update_raw_response(self, response):
113+
"""
114+
Updates the event with data by given raw response.
115+
Raw response is an instance of Response.
116+
"""
117+
for response_type in SUPPORTED_RAW_RESPONSE_TYPES:
118+
if isinstance(response, response_type):
119+
self._update_raw_response_body(response, response_type)
120+
break
121+
122+
response_headers = dict(response.headers.items())
123+
if response.headers:
124+
add_data_if_needed(
125+
self.resource['metadata'],
126+
'Response Headers',
127+
response_headers
128+
)
129+
130+
self.resource['metadata']['status_code'] = response.status_code
131+
if response.status_code >= 500:
132+
self.set_error()
133+
88134
def update_response(self, response):
89135
"""
90136
Adds response data to event.
91137
"""
92-
for response_type in SUPPORTED_RESPONSE_TYPES:
93-
if isinstance(response, response_type):
94-
body = response.body
95-
if response_type == JSONResponse:
96-
try:
97-
body = json.loads(body)
98-
except Exception: # pylint: disable=W0703
99-
warnings.warn(
100-
'Could not load response json',
101-
EpsagonWarning
102-
)
103-
body = body.decode('utf-8')
104-
else:
105-
body = body.decode('utf-8')
138+
if isinstance(response, Response):
139+
self._update_raw_response(response)
140+
else:
141+
try:
106142
add_data_if_needed(
107143
self.resource['metadata'],
108144
'Response Data',
109-
body
145+
jsonable_encoder(response)
110146
)
111-
if isinstance(response, Response):
112-
response_headers = dict(response.headers.items())
113-
if response.headers:
114-
add_data_if_needed(
115-
self.resource['metadata'],
116-
'Response Headers',
117-
response_headers
147+
except Exception: # pylint: disable=W0703
148+
print_debug(
149+
'Could not json encode fastapi handler response data'
118150
)
119-
120-
self.resource['metadata']['status_code'] = response.status_code
121-
122-
if response.status_code >= 500:
123-
self.set_error()

tests/wrappers/test_fastapi_wrapper.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@
77
import asyncio
88
from typing import List
99
from httpx import AsyncClient
10+
from pydantic import BaseModel
1011
from fastapi import FastAPI, APIRouter, Request
1112
from fastapi.responses import JSONResponse
13+
from fastapi.encoders import jsonable_encoder
1214
from epsagon import trace_factory
1315
from epsagon.common import ErrorCode
1416
from epsagon.runners.fastapi import FastapiRunner
@@ -25,6 +27,10 @@
2527
TEST_POST_DATA = {'post_test': '123'}
2628
CUSTOM_RESPONSE = ["A"]
2729
CUSTOM_RESPONSE_PATH = "/custom_response"
30+
BASE_MODEL_RESPONSE_PATH = "/base_model_response"
31+
32+
class CustomBaseModel(BaseModel):
33+
data: List[str]
2834

2935
def _get_response_data(key):
3036
return {key: key}
@@ -39,6 +45,9 @@ def handle():
3945
def handle_custom_response(response_model=List[str]):
4046
return CUSTOM_RESPONSE
4147

48+
def handle_base_model_response(response_model=CustomBaseModel):
49+
return CustomBaseModel(data=CUSTOM_RESPONSE)
50+
4251
def handle_given_request(request: Request):
4352
assert request.method == 'POST'
4453
loop = None
@@ -76,7 +85,16 @@ def handle_error():
7685
def fastapi_app():
7786
app = FastAPI()
7887
app.add_api_route("/", handle, methods=["GET"])
79-
app.add_api_route(CUSTOM_RESPONSE_PATH, handle_custom_response, methods=["GET"])
88+
app.add_api_route(
89+
CUSTOM_RESPONSE_PATH,
90+
handle_custom_response,
91+
methods=["GET"]
92+
)
93+
app.add_api_route(
94+
BASE_MODEL_RESPONSE_PATH,
95+
handle_base_model_response,
96+
methods=["GET"]
97+
)
8098
app.add_api_route(REQUEST_OBJ_PATH, handle_given_request, methods=["POST"])
8199
app.add_api_route("/a", handle_a, methods=["GET"])
82100
app.add_api_route("/b", handle_b, methods=["GET"])
@@ -119,11 +137,35 @@ async def test_fastapi_custom_response(trace_transport, fastapi_app):
119137
assert runner.resource['name'].startswith('127.0.0.1')
120138
assert runner.resource['metadata']['Path'] == CUSTOM_RESPONSE_PATH
121139
assert runner.resource['metadata']['Query Params'] == { 'x': 'testval'}
140+
assert runner.resource['metadata']['Response Data'] == (
141+
jsonable_encoder(CUSTOM_RESPONSE)
142+
)
122143
assert response_data == CUSTOM_RESPONSE
123144
# validating no `zombie` traces exist
124145
assert not trace_factory.traces
125146

126147

148+
@pytest.mark.asyncio
149+
async def test_fastapi_base_model_response(trace_transport, fastapi_app):
150+
"""Sanity test."""
151+
request_path = f'{BASE_MODEL_RESPONSE_PATH}?x=testval'
152+
async with AsyncClient(app=fastapi_app, base_url="http://test") as ac:
153+
response = await ac.get(request_path)
154+
response_data = response.json()
155+
runner = trace_transport.last_trace.events[0]
156+
expected_response_data = CustomBaseModel(data=CUSTOM_RESPONSE)
157+
assert isinstance(runner, FastapiRunner)
158+
assert runner.resource['name'].startswith('127.0.0.1')
159+
assert runner.resource['metadata']['Path'] == BASE_MODEL_RESPONSE_PATH
160+
assert runner.resource['metadata']['Query Params'] == { 'x': 'testval'}
161+
assert runner.resource['metadata']['Response Data'] == (
162+
jsonable_encoder(expected_response_data)
163+
)
164+
assert response_data == expected_response_data
165+
# validating no `zombie` traces exist
166+
assert not trace_factory.traces
167+
168+
127169
@pytest.mark.asyncio
128170
async def test_fastapi_given_request(trace_transport, fastapi_app):
129171
"""Sanity test."""

0 commit comments

Comments
 (0)