Skip to content

Commit aad45fe

Browse files
fix(client): attempt to parse unknown json content types (#191)
1 parent 0fb6bff commit aad45fe

File tree

4 files changed

+90
-16
lines changed

4 files changed

+90
-16
lines changed

src/finch/_base_client.py

+14-6
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,12 @@
7474
RAW_RESPONSE_HEADER,
7575
)
7676
from ._streaming import Stream, AsyncStream
77-
from ._exceptions import APIStatusError, APITimeoutError, APIConnectionError
77+
from ._exceptions import (
78+
APIStatusError,
79+
APITimeoutError,
80+
APIConnectionError,
81+
APIResponseValidationError,
82+
)
7883

7984
log: logging.Logger = logging.getLogger(__name__)
8085

@@ -518,13 +523,16 @@ def _process_response_data(
518523
if cast_to is UnknownResponse:
519524
return cast(ResponseT, data)
520525

521-
if inspect.isclass(cast_to) and issubclass(cast_to, ModelBuilderProtocol):
522-
return cast(ResponseT, cast_to.build(response=response, data=data))
526+
try:
527+
if inspect.isclass(cast_to) and issubclass(cast_to, ModelBuilderProtocol):
528+
return cast(ResponseT, cast_to.build(response=response, data=data))
523529

524-
if self._strict_response_validation:
525-
return cast(ResponseT, validate_type(type_=cast_to, value=data))
530+
if self._strict_response_validation:
531+
return cast(ResponseT, validate_type(type_=cast_to, value=data))
526532

527-
return cast(ResponseT, construct_type(type_=cast_to, value=data))
533+
return cast(ResponseT, construct_type(type_=cast_to, value=data))
534+
except pydantic.ValidationError as err:
535+
raise APIResponseValidationError(response=response, body=data) from err
528536

529537
@property
530538
def qs(self) -> Querystring:

src/finch/_models.py

+13
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,19 @@ def _construct_field(value: object, field: FieldInfo, key: str) -> object:
263263
return construct_type(value=value, type_=type_)
264264

265265

266+
def is_basemodel(type_: type) -> bool:
267+
"""Returns whether or not the given type is either a `BaseModel` or a union of `BaseModel`"""
268+
origin = get_origin(type_) or type_
269+
if is_union(type_):
270+
for variant in get_args(type_):
271+
if is_basemodel(variant):
272+
return True
273+
274+
return False
275+
276+
return issubclass(origin, BaseModel) or issubclass(origin, GenericModel)
277+
278+
266279
def construct_type(*, value: object, type_: type) -> object:
267280
"""Loose coercion to the expected type with construction of nested values.
268281

src/finch/_response.py

+21-10
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
from __future__ import annotations
22

33
import inspect
4+
import logging
45
import datetime
56
import functools
67
from typing import TYPE_CHECKING, Any, Union, Generic, TypeVar, Callable, cast
78
from typing_extensions import Awaitable, ParamSpec, get_args, override, get_origin
89

910
import httpx
10-
import pydantic
1111

1212
from ._types import NoneType, UnknownResponse, BinaryResponseContent
1313
from ._utils import is_given
14-
from ._models import BaseModel
14+
from ._models import BaseModel, is_basemodel
1515
from ._constants import RAW_RESPONSE_HEADER
1616
from ._exceptions import APIResponseValidationError
1717

@@ -23,6 +23,8 @@
2323
P = ParamSpec("P")
2424
R = TypeVar("R")
2525

26+
log: logging.Logger = logging.getLogger(__name__)
27+
2628

2729
class APIResponse(Generic[R]):
2830
_cast_to: type[R]
@@ -174,6 +176,18 @@ def _parse(self) -> R:
174176
# in the response, e.g. application/json; charset=utf-8
175177
content_type, *_ = response.headers.get("content-type").split(";")
176178
if content_type != "application/json":
179+
if is_basemodel(cast_to):
180+
try:
181+
data = response.json()
182+
except Exception as exc:
183+
log.debug("Could not read JSON from response data due to %s - %s", type(exc), exc)
184+
else:
185+
return self._client._process_response_data(
186+
data=data,
187+
cast_to=cast_to, # type: ignore
188+
response=response,
189+
)
190+
177191
if self._client._strict_response_validation:
178192
raise APIResponseValidationError(
179193
response=response,
@@ -188,14 +202,11 @@ def _parse(self) -> R:
188202

189203
data = response.json()
190204

191-
try:
192-
return self._client._process_response_data(
193-
data=data,
194-
cast_to=cast_to, # type: ignore
195-
response=response,
196-
)
197-
except pydantic.ValidationError as err:
198-
raise APIResponseValidationError(response=response, body=data) from err
205+
return self._client._process_response_data(
206+
data=data,
207+
cast_to=cast_to, # type: ignore
208+
response=response,
209+
)
199210

200211
@override
201212
def __repr__(self) -> str:

tests/test_client.py

+42
Original file line numberDiff line numberDiff line change
@@ -420,6 +420,27 @@ class Model2(BaseModel):
420420
assert isinstance(response, Model1)
421421
assert response.foo == 1
422422

423+
@pytest.mark.respx(base_url=base_url)
424+
def test_non_application_json_content_type_for_json_data(self, respx_mock: MockRouter) -> None:
425+
"""
426+
Response that sets Content-Type to something other than application/json but returns json data
427+
"""
428+
429+
class Model(BaseModel):
430+
foo: int
431+
432+
respx_mock.get("/foo").mock(
433+
return_value=httpx.Response(
434+
200,
435+
content=json.dumps({"foo": 2}),
436+
headers={"Content-Type": "application/text"},
437+
)
438+
)
439+
440+
response = self.client.get("/foo", cast_to=Model)
441+
assert isinstance(response, Model)
442+
assert response.foo == 2
443+
423444
def test_base_url_env(self) -> None:
424445
with update_env(FINCH_BASE_URL="http://localhost:5000/from/env"):
425446
client = Finch(access_token=access_token, _strict_response_validation=True)
@@ -1068,6 +1089,27 @@ class Model2(BaseModel):
10681089
assert isinstance(response, Model1)
10691090
assert response.foo == 1
10701091

1092+
@pytest.mark.respx(base_url=base_url)
1093+
async def test_non_application_json_content_type_for_json_data(self, respx_mock: MockRouter) -> None:
1094+
"""
1095+
Response that sets Content-Type to something other than application/json but returns json data
1096+
"""
1097+
1098+
class Model(BaseModel):
1099+
foo: int
1100+
1101+
respx_mock.get("/foo").mock(
1102+
return_value=httpx.Response(
1103+
200,
1104+
content=json.dumps({"foo": 2}),
1105+
headers={"Content-Type": "application/text"},
1106+
)
1107+
)
1108+
1109+
response = await self.client.get("/foo", cast_to=Model)
1110+
assert isinstance(response, Model)
1111+
assert response.foo == 2
1112+
10711113
def test_base_url_env(self) -> None:
10721114
with update_env(FINCH_BASE_URL="http://localhost:5000/from/env"):
10731115
client = AsyncFinch(access_token=access_token, _strict_response_validation=True)

0 commit comments

Comments
 (0)