Skip to content

Commit a15e8c2

Browse files
committed
Validate JSON response shapes
1 parent 42a9e31 commit a15e8c2

10 files changed

Lines changed: 287 additions & 34 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
name: Inline suppression guard
3+
4+
on:
5+
pull_request:
6+
7+
permissions: {}
8+
9+
jobs:
10+
check:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- uses: actions/checkout@v7
14+
with:
15+
fetch-depth: 0
16+
persist-credentials: false
17+
18+
- name: Reject new inline suppressions
19+
env:
20+
BASE_SHA: ${{ github.event.pull_request.base.sha }}
21+
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
22+
run: |
23+
added_lines="$(
24+
git diff --unified=0 "$BASE_SHA" "$HEAD_SHA" -- '*.py' |
25+
sed -n '/^+++ /d; s/^+//p'
26+
)"
27+
suppressions="$(
28+
printf '%s\n' "$added_lines" |
29+
grep -Ei '#[[:space:]]*(type:[[:space:]]*ignore|pyright:[[:space:]]*ignore|pyrefly:[[:space:]]*ignore|ty:[[:space:]]*ignore|mypy:[[:space:]]*ignore|pylint:[[:space:]]*disable|noqa($|[[:space:]])|pragma:[[:space:]]*no[[:space:]]+(cover|branch))' ||
30+
true
31+
)"
32+
if [ -n "$suppressions" ]; then
33+
printf '%s\n' "New inline suppressions are not allowed:"
34+
printf '%s\n' "$suppressions"
35+
exit 1
36+
fi
37+

src/vws/_json_utils.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"""Validation helpers for JSON received from remote services."""
2+
3+
import json
4+
from collections.abc import Iterable, Mapping
5+
from typing import TypeGuard
6+
7+
8+
def _is_object_mapping(value: object, /) -> TypeGuard[Mapping[object, object]]:
9+
"""Return whether a value is a mapping with unchecked entries."""
10+
return isinstance(value, Mapping)
11+
12+
13+
def _is_object_iterable(value: object, /) -> TypeGuard[Iterable[object]]:
14+
"""Return whether a value supports iteration over unchecked
15+
entries.
16+
"""
17+
return isinstance(value, Iterable)
18+
19+
20+
def _validated_object(*, value: object) -> dict[str, object]:
21+
"""Return a JSON object after validating its key types."""
22+
if not _is_object_mapping(value):
23+
msg = "Expected a JSON object."
24+
raise TypeError(msg)
25+
if not all(isinstance(key, str) for key in value):
26+
msg = "JSON object keys must be strings."
27+
raise TypeError(msg)
28+
return {key: item for key, item in value.items() if isinstance(key, str)}
29+
30+
31+
def json_object(*, value: str | bytes | bytearray) -> dict[str, object]:
32+
"""Decode and validate a JSON object."""
33+
loaded: object = json.loads(s=value)
34+
return _validated_object(value=loaded)
35+
36+
37+
def string_value(*, value: object, name: str) -> str:
38+
"""Return a JSON value after validating that it is a string."""
39+
if not isinstance(value, str):
40+
msg = f"{name} must be a string."
41+
raise TypeError(msg)
42+
return value
43+
44+
45+
def string_field(*, value: Mapping[str, object], name: str) -> str:
46+
"""Return a required string field from a JSON object."""
47+
return string_value(value=value[name], name=name)
48+
49+
50+
def string_list_field(
51+
*,
52+
value: Mapping[str, object],
53+
name: str,
54+
) -> list[str]:
55+
"""Return a required list of strings from a JSON object."""
56+
items = value[name]
57+
if (
58+
isinstance(items, str | bytes)
59+
or _is_object_mapping(items)
60+
or not _is_object_iterable(items)
61+
):
62+
msg = f"{name} must be a list of strings."
63+
raise TypeError(msg)
64+
item_list = list(items)
65+
if not all(isinstance(item, str) for item in item_list):
66+
msg = f"{name} must be a list of strings."
67+
raise TypeError(msg)
68+
return [item for item in item_list if isinstance(item, str)]
69+
70+
71+
def object_list_field(
72+
*,
73+
value: Mapping[str, object],
74+
name: str,
75+
) -> list[dict[str, object]]:
76+
"""Return a required list of JSON objects."""
77+
items = value[name]
78+
if (
79+
isinstance(items, str | bytes)
80+
or _is_object_mapping(items)
81+
or not _is_object_iterable(items)
82+
):
83+
msg = f"{name} must be a list of JSON objects."
84+
raise TypeError(msg)
85+
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)