Skip to content

Commit c8094d2

Browse files
Merge pull request #3212 from VWS-Python/validate-json-response-shapes
Validate JSON response shapes
2 parents 42a9e31 + 82296d2 commit c8094d2

10 files changed

Lines changed: 276 additions & 34 deletions

File tree

src/vws/_json_utils.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"""Validation helpers for JSON received from remote services."""
2+
3+
import json
4+
from typing import TypeGuard
5+
6+
7+
def _is_json_object(value: object, /) -> TypeGuard[dict[str, object]]:
8+
"""Return whether a decoded JSON value is an object."""
9+
return isinstance(value, dict)
10+
11+
12+
def _is_object_list(value: object, /) -> TypeGuard[list[object]]:
13+
"""Return whether a decoded JSON value is an array."""
14+
return isinstance(value, list)
15+
16+
17+
def _validated_object(*, value: object) -> dict[str, object]:
18+
"""Return a decoded JSON object."""
19+
if not _is_json_object(value):
20+
msg = "Expected a JSON object."
21+
raise TypeError(msg)
22+
return value
23+
24+
25+
def json_object(*, value: str | bytes | bytearray) -> dict[str, object]:
26+
"""Decode and validate a JSON object."""
27+
loaded: object = json.loads(s=value)
28+
return _validated_object(value=loaded)
29+
30+
31+
def string_value(*, value: object, name: str) -> str:
32+
"""Return a JSON value after validating that it is a string."""
33+
if not isinstance(value, str):
34+
msg = f"{name} must be a string."
35+
raise TypeError(msg)
36+
return value
37+
38+
39+
def string_field(*, value: dict[str, object], name: str) -> str:
40+
"""Return a required string field from a JSON object."""
41+
return string_value(value=value[name], name=name)
42+
43+
44+
def string_list_field(
45+
*,
46+
value: dict[str, object],
47+
name: str,
48+
) -> list[str]:
49+
"""Return a required list of strings from a JSON object."""
50+
items = value[name]
51+
if not _is_object_list(items) or not all(
52+
isinstance(item, str) for item in items
53+
):
54+
msg = f"{name} must be a list of strings."
55+
raise TypeError(msg)
56+
return [item for item in items if isinstance(item, str)]
57+
58+
59+
def object_list_field(
60+
*,
61+
value: dict[str, object],
62+
name: str,
63+
) -> list[dict[str, object]]:
64+
"""Return a required list of JSON objects."""
65+
items = value[name]
66+
if not _is_object_list(items):
67+
msg = f"{name} must be a list of JSON objects."
68+
raise TypeError(msg)
69+
return [_validated_object(value=item) for item in items]

src/vws/async_query.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
from vws._image_utils import ImageType as _ImageType
1414
from vws._image_utils import get_image_data as _get_image_data
15+
from vws._json_utils import json_object, object_list_field, string_field
1516
from vws.exceptions.base_exceptions import CloudRecoError
1617
from vws.exceptions.cloud_reco_exceptions import (
1718
AuthenticationFailureError,
@@ -201,13 +202,16 @@ async def query(
201202
raise CloudRecoError(response=response)
202203

203204
try:
204-
response_body = json.loads(s=response.text)
205+
response_body = json_object(value=response.text)
205206
except json.JSONDecodeError as exc:
206207
if response.status_code >= HTTPStatus.BAD_REQUEST:
207208
raise CloudRecoError(response=response) from exc
208209
raise
209210

210-
result_code = response_body["result_code"] # pyrefly: ignore [unknown-variable-type]
211+
result_code = string_field(
212+
value=response_body,
213+
name="result_code",
214+
)
211215
if result_code != "Success":
212216
exception = {
213217
"AuthenticationFailure": (AuthenticationFailureError),
@@ -217,8 +221,11 @@ async def query(
217221
}[result_code]
218222
raise exception(response=response)
219223

220-
result_list = list(response_body["results"]) # pyrefly: ignore [unknown-argument-type]
224+
result_list = object_list_field(
225+
value=response_body,
226+
name="results",
227+
)
221228
return [
222-
QueryResult.from_response_dict(response_dict=item) # pyrefly: ignore [unknown-argument-type]
229+
QueryResult.from_response_dict(response_dict=item)
223230
for item in result_list
224231
]

src/vws/async_vumark_service.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from beartype import BeartypeConf, beartype
88

99
from vws._async_vws_request import async_target_api_request
10+
from vws._json_utils import json_object, string_field
1011
from vws.exceptions.base_exceptions import VWSError
1112
from vws.exceptions.custom_exceptions import ServerError
1213
from vws.exceptions.vws_exceptions import TooManyRequestsError
@@ -147,8 +148,11 @@ async def generate_vumark_instance(
147148
if response.status_code == HTTPStatus.OK:
148149
return response.content
149150

150-
result_code = json.loads(s=response.text)["result_code"] # pyrefly: ignore [unknown-variable-type]
151+
result_code = string_field(
152+
value=json_object(value=response.text),
153+
name="result_code",
154+
)
151155
raise VWSError.from_result_code(
152-
result_code=result_code, # pyrefly: ignore [unknown-argument-type]
156+
result_code=result_code,
153157
response=response,
154158
)

src/vws/async_vws.py

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from vws._async_vws_request import async_target_api_request
1414
from vws._image_utils import ImageType as _ImageType
1515
from vws._image_utils import get_image_data as _get_image_data
16+
from vws._json_utils import json_object, string_field, string_list_field
1617
from vws._reco_counts import (
1718
reco_counts_report_body,
1819
reco_counts_report_path,
@@ -152,12 +153,15 @@ async def make_request(
152153
): # pragma: no cover
153154
raise ServerError(response=response)
154155

155-
result_code = json.loads(s=response.text)["result_code"] # pyrefly: ignore [unknown-variable-type]
156+
result_code = string_field(
157+
value=json_object(value=response.text),
158+
name="result_code",
159+
)
156160
if result_code == expected_result_code:
157161
return response
158162

159163
raise VWSError.from_result_code(
160-
result_code=result_code, # pyrefly: ignore [unknown-argument-type]
164+
result_code=result_code,
161165
response=response,
162166
)
163167

@@ -238,7 +242,10 @@ async def add_target(
238242
content_type="application/json",
239243
)
240244

241-
return str(object=json.loads(s=response.text)["target_id"]) # pyrefly: ignore [unknown-argument-type]
245+
return string_field(
246+
value=json_object(value=response.text),
247+
name="target_id",
248+
)
242249

243250
async def get_target_record(self, target_id: str) -> TargetStatusAndRecord:
244251
"""Get a given target's target record from the Target
@@ -276,7 +283,7 @@ async def get_target_record(self, target_id: str) -> TargetStatusAndRecord:
276283
content_type="application/json",
277284
)
278285

279-
result_data = json.loads(s=response.text)
286+
result_data = json_object(value=response.text)
280287
return TargetStatusAndRecord.from_response_dict(
281288
response_dict=result_data,
282289
)
@@ -373,7 +380,10 @@ async def list_targets(self) -> list[str]:
373380
content_type="application/json",
374381
)
375382

376-
return list(json.loads(s=response.text)["results"]) # pyrefly: ignore [unknown-argument-type]
383+
return string_list_field(
384+
value=json_object(value=response.text),
385+
name="results",
386+
)
377387

378388
async def get_target_summary_report(
379389
self, target_id: str
@@ -413,7 +423,7 @@ async def get_target_summary_report(
413423
content_type="application/json",
414424
)
415425

416-
result_data = dict(json.loads(s=response.text))
426+
result_data = json_object(value=response.text)
417427
return TargetSummaryReport.from_response_dict(
418428
response_dict=result_data,
419429
)
@@ -450,7 +460,7 @@ async def get_database_summary_report(
450460
content_type="application/json",
451461
)
452462

453-
response_data = dict(json.loads(s=response.text))
463+
response_data = json_object(value=response.text)
454464
return DatabaseSummaryReport.from_response_dict(
455465
response_dict=response_data,
456466
)
@@ -504,7 +514,7 @@ async def request_database_reco_counts_report(
504514
content_type="application/json",
505515
)
506516

507-
response_data = dict(json.loads(s=response.text))
517+
response_data = json_object(value=response.text)
508518
return RecoCountsReportRequest.from_response_dict(
509519
response_dict=response_data,
510520
)
@@ -657,8 +667,9 @@ async def get_duplicate_targets(self, target_id: str) -> list[str]:
657667
content_type="application/json",
658668
)
659669

660-
return list(
661-
json.loads(s=response.text)["similar_targets"], # pyrefly: ignore [unknown-argument-type]
670+
return string_list_field(
671+
value=json_object(value=response.text),
672+
name="similar_targets",
662673
)
663674

664675
async def update_target(

src/vws/exceptions/vws_exceptions.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,11 @@
66
api#result-codes.
77
"""
88

9-
import json
109
from urllib.parse import urlparse
1110

1211
from beartype import beartype
1312

13+
from vws._json_utils import json_object, string_field
1414
from vws.exceptions.base_exceptions import VWSError
1515

1616

@@ -147,8 +147,8 @@ def target_name(self) -> str:
147147
if not isinstance(response_body, str | bytes): # pragma: no cover
148148
msg = "A target-name error response must have a request body."
149149
raise TypeError(msg)
150-
request_json = json.loads(s=response_body)
151-
return str(object=request_json["name"]) # pyrefly: ignore [unknown-argument-type]
150+
request_json = json_object(value=response_body)
151+
return string_field(value=request_json, name="name")
152152

153153

154154
@beartype

src/vws/query.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
from vws._image_utils import ImageType as _ImageType
1212
from vws._image_utils import get_image_data as _get_image_data
13+
from vws._json_utils import json_object, object_list_field, string_field
1314
from vws.exceptions.base_exceptions import CloudRecoError
1415
from vws.exceptions.cloud_reco_exceptions import (
1516
AuthenticationFailureError,
@@ -171,13 +172,16 @@ def query(
171172
raise CloudRecoError(response=response)
172173

173174
try:
174-
response_body = json.loads(s=response.text)
175+
response_body = json_object(value=response.text)
175176
except json.JSONDecodeError as exc:
176177
if response.status_code >= HTTPStatus.BAD_REQUEST:
177178
raise CloudRecoError(response=response) from exc
178179
raise
179180

180-
result_code = response_body["result_code"] # pyrefly: ignore [unknown-variable-type]
181+
result_code = string_field(
182+
value=response_body,
183+
name="result_code",
184+
)
181185
if result_code != "Success":
182186
exception = {
183187
"AuthenticationFailure": AuthenticationFailureError,
@@ -187,8 +191,11 @@ def query(
187191
}[result_code]
188192
raise exception(response=response)
189193

190-
result_list = list(response_body["results"]) # pyrefly: ignore [unknown-argument-type]
194+
result_list = object_list_field(
195+
value=response_body,
196+
name="results",
197+
)
191198
return [
192-
QueryResult.from_response_dict(response_dict=item) # pyrefly: ignore [unknown-argument-type]
199+
QueryResult.from_response_dict(response_dict=item)
193200
for item in result_list
194201
]

src/vws/vumark_service.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from beartype import BeartypeConf, beartype
77

8+
from vws._json_utils import json_object, string_field
89
from vws._vws_request import target_api_request
910
from vws.exceptions.base_exceptions import VWSError
1011
from vws.exceptions.custom_exceptions import ServerError
@@ -130,8 +131,11 @@ def generate_vumark_instance(
130131
if response.status_code == HTTPStatus.OK:
131132
return response.content
132133

133-
result_code = json.loads(s=response.text)["result_code"] # pyrefly: ignore [unknown-variable-type]
134+
result_code = string_field(
135+
value=json_object(value=response.text),
136+
name="result_code",
137+
)
134138
raise VWSError.from_result_code(
135-
result_code=result_code, # pyrefly: ignore [unknown-argument-type]
139+
result_code=result_code,
136140
response=response,
137141
)

0 commit comments

Comments
 (0)