Skip to content

Commit da4d2c3

Browse files
Match real Vuforia Model Target error responses (#3203)
* Match real Vuforia Model Target error responses Probed real Vuforia to discover actual error response shapes, updated the mock to match, and converted previously mock-only error-path tests into verified-fake tests that run against real Vuforia + both mock backends. Closes #3197, #3193, #3194. Partial progress on #3192, #3195. The advanced-dataset model-count case remains mock-only (tracked by #3202, blocked on Enterprise scope entitlement). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Fix pylint spelling: Unparseable -> Malformed Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Require target and details kwargs in Model Target _error_response Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4341908 commit da4d2c3

6 files changed

Lines changed: 275 additions & 166 deletions

File tree

docs/source/differences-to-vws.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,13 @@ The generated dataset download is a small valid zip file containing request meta
118118
Model Target API routes require a syntactically JSON Web Token-shaped bearer token, such as the token returned by the mock OAuth2 route.
119119
The mock does not verify token signatures, claims, expiry, or revocation.
120120

121+
For unknown Model Target datasets, the mock returns an error whose ``target`` is ``userId:mock``.
122+
Real Vuforia uses ``userId:<numeric-user-id>`` where the numeric portion is per-account.
123+
124+
Two Model Target Web API error paths remain mock-only in ``tests/mock_vws/test_model_target_web_api.py::TestMockOnlyErrors``.
125+
Downloads of still-processing datasets are mock-only because exercising the path against real Vuforia would require creating a dataset on every test run; the mock drives the processing window deterministically.
126+
Advanced-dataset creation with more than 20 models is mock-only because the available test account lacks the advanced-dataset scope and real Vuforia rejects the request with a 403 before validating model counts.
127+
121128
Header cases
122129
------------
123130

newsfragments/3193.change

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Match real Vuforia Model Target dataset creation validation error shape, including per-request UUID, details list, and status codes (415 for unsupported media type, 400 with ``BAD_REQUEST`` validation details).

newsfragments/3194.change

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Match real Vuforia Model Target unknown-dataset response shape (``NOT_FOUND`` code, ``Could not find a model-view database with uuid <uuid>`` message, ``userId:`` target).

newsfragments/3197.change

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Match real Vuforia Model Target Web API error responses for invalid request bodies, invalid dataset creation payloads, unknown datasets, and downloads of still-processing datasets.

src/mock_vws/_model_target_web_api.py

Lines changed: 109 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import base64
44
import io
55
import json
6+
import uuid
67
import zipfile
78
from http import HTTPStatus
89
from typing import Any
@@ -19,6 +20,11 @@
1920
_JWT_DOT_COUNT = 2
2021
_MOCK_MODEL_TARGET_CLIENT_ID = "client-id"
2122
_MOCK_MODEL_TARGET_CLIENT_SECRET = "client-secret" # noqa: S105
23+
# A stable mock value standing in for the user-id segment that real
24+
# Vuforia embeds in some Model Target error targets such as
25+
# ``userId:7635391``. The numeric portion is per-account in real Vuforia;
26+
# the mock uses a fixed placeholder.
27+
_MOCK_USER_TARGET = "userId:mock"
2228

2329

2430
@beartype
@@ -45,18 +51,36 @@ def _error_response(
4551
status_code: HTTPStatus,
4652
code: str,
4753
message: str,
48-
target: str,
54+
target: str | None,
55+
details: list[dict[str, str]] | None,
4956
) -> _ResponseType:
5057
"""Return an error response shaped like the Model Target Web API."""
51-
return _json_response(
52-
status_code=status_code,
53-
body={
54-
"error": {
55-
"code": code,
56-
"message": message,
57-
"target": target,
58-
},
59-
},
58+
error: dict[str, Any] = {"code": code, "message": message}
59+
if target is not None:
60+
error["target"] = target
61+
if details is not None:
62+
error["details"] = details
63+
return _json_response(status_code=status_code, body={"error": error})
64+
65+
66+
@beartype
67+
def _validation_error_response(
68+
*,
69+
details: list[dict[str, str]],
70+
) -> _ResponseType:
71+
"""Return a Vuforia-style validation error.
72+
73+
Real Vuforia tags each validation error with a per-request UUID that
74+
appears in both ``message`` and ``target``. The mock generates a fresh
75+
UUID so the shape matches.
76+
"""
77+
request_uuid = uuid.uuid4().hex
78+
return _error_response(
79+
status_code=HTTPStatus.BAD_REQUEST,
80+
code="BAD_REQUEST",
81+
message=f"Validation error for request {request_uuid}",
82+
target=request_uuid,
83+
details=details,
6084
)
6185

6286

@@ -112,6 +136,7 @@ def _require_bearer_token(request: RequestData) -> _ResponseType | None:
112136
code="401",
113137
message="no Bearer token",
114138
target="jwt",
139+
details=None,
115140
)
116141
bearer_token = auth_header.removeprefix("Bearer ").strip()
117142
if not bearer_token:
@@ -120,13 +145,15 @@ def _require_bearer_token(request: RequestData) -> _ResponseType | None:
120145
code="401",
121146
message="no Bearer token",
122147
target="jwt",
148+
details=None,
123149
)
124150
if bearer_token.count(".") != _JWT_DOT_COUNT:
125151
return _error_response(
126152
status_code=HTTPStatus.UNAUTHORIZED,
127153
code="401",
128154
message="Invalid JWT serialization: Missing dot delimiter(s)",
129155
target="jwt",
156+
details=None,
130157
)
131158
return None
132159

@@ -214,19 +241,21 @@ def _load_request_json(request: RequestData) -> dict[str, Any] | _ResponseType:
214241
content_type = _get_header(request=request, name="Content-Type") or ""
215242
if "application/json" not in content_type:
216243
return _error_response(
217-
status_code=HTTPStatus.BAD_REQUEST,
218-
code="BAD_REQUEST",
219-
message="Content-Type must be application/json.",
220-
target="Content-Type",
244+
status_code=HTTPStatus.UNSUPPORTED_MEDIA_TYPE,
245+
code="ERROR",
246+
message="Expecting text/json or application/json body",
247+
target=None,
248+
details=None,
221249
)
222250
try:
223251
request_json: dict[str, Any] = json.loads(s=request.body)
224-
except json.JSONDecodeError:
252+
except json.JSONDecodeError as exc:
225253
return _error_response(
226254
status_code=HTTPStatus.BAD_REQUEST,
227-
code="BAD_REQUEST",
228-
message="Request body must be valid JSON.",
229-
target="body",
255+
code="ERROR",
256+
message=f"Invalid Json: {exc}",
257+
target=None,
258+
details=None,
230259
)
231260
return request_json
232261

@@ -238,44 +267,55 @@ def _validate_dataset_request(
238267
dataset_type: ModelTargetDatasetType,
239268
) -> _ResponseType | None:
240269
"""Validate the dataset request enough for useful mock feedback."""
241-
for field in ("name", "models", "targetSdk"):
242-
if field not in request_json:
243-
return _error_response(
244-
status_code=HTTPStatus.BAD_REQUEST,
245-
code="BAD_REQUEST",
246-
message=f"Missing required field: {field}.",
247-
target=field,
248-
)
270+
missing_details = [
271+
{
272+
"code": "VALIDATION_ERROR",
273+
"message": f"/{field}: element is required",
274+
}
275+
for field in ("models", "name", "targetSdk")
276+
if field not in request_json
277+
]
278+
if missing_details:
279+
return _validation_error_response(details=missing_details)
249280

250281
models_value = request_json["models"]
251282
if not isinstance(models_value, list):
252-
return _error_response(
253-
status_code=HTTPStatus.BAD_REQUEST,
254-
code="BAD_REQUEST",
255-
message="models must be a list.",
256-
target="models",
283+
return _validation_error_response(
284+
details=[
285+
{
286+
"code": "VALIDATION_ERROR",
287+
"message": "/models: error.expected.jsarray",
288+
},
289+
],
257290
)
258291

259292
models: list[Any] = [*models_value]
260293
model_count = len(models)
261294

262295
if dataset_type == ModelTargetDatasetType.STANDARD and model_count != 1:
263-
return _error_response(
264-
status_code=HTTPStatus.BAD_REQUEST,
265-
code="BAD_REQUEST",
266-
message="Standard Model Target datasets must have one model.",
267-
target="models",
296+
return _validation_error_response(
297+
details=[
298+
{
299+
"code": "VALIDATION_ERROR",
300+
"message": "exactly one model should be provided",
301+
},
302+
],
268303
)
269304

270305
if (
271306
dataset_type == ModelTargetDatasetType.ADVANCED
272307
and not 1 <= model_count <= _MAX_ADVANCED_MODEL_COUNT
273308
):
274-
return _error_response(
275-
status_code=HTTPStatus.BAD_REQUEST,
276-
code="BAD_REQUEST",
277-
message="Advanced Model Target datasets must have 1 to 20 models.",
278-
target="models",
309+
return _validation_error_response(
310+
details=[
311+
{
312+
"code": "VALIDATION_ERROR",
313+
"message": (
314+
"models must contain between 1 and "
315+
f"{_MAX_ADVANCED_MODEL_COUNT} entries"
316+
),
317+
},
318+
],
279319
)
280320

281321
return None
@@ -333,9 +373,13 @@ def get_model_target_dataset_status(
333373
except KeyError:
334374
return _error_response(
335375
status_code=HTTPStatus.NOT_FOUND,
336-
code="404",
337-
message="The dataset was not found.",
338-
target="uuid",
376+
code="NOT_FOUND",
377+
message=(
378+
"Could not find a model-view database with uuid "
379+
f"{dataset_uuid}"
380+
),
381+
target=_MOCK_USER_TARGET,
382+
details=None,
339383
)
340384
return _json_response(
341385
status_code=HTTPStatus.OK,
@@ -378,16 +422,24 @@ def download_model_target_dataset(
378422
except KeyError:
379423
return _error_response(
380424
status_code=HTTPStatus.NOT_FOUND,
381-
code="404",
382-
message="The dataset was not found.",
383-
target="uuid",
425+
code="NOT_FOUND",
426+
message=(
427+
"Could not find a model-view database with uuid "
428+
f"{dataset_uuid}"
429+
),
430+
target=_MOCK_USER_TARGET,
431+
details=None,
384432
)
385433
if dataset.status != "done":
386434
return _error_response(
387435
status_code=HTTPStatus.UNPROCESSABLE_ENTITY,
388-
code="UNPROCESSABLE_ENTITY",
389-
message="The dataset is still processing.",
390-
target="uuid",
436+
code="UNSUPPORTED_STATE",
437+
message=(
438+
f"Training status for dataset {dataset_uuid} is "
439+
"not-started != done"
440+
),
441+
target=dataset_uuid,
442+
details=None,
391443
)
392444

393445
body = _dataset_zip_bytes(dataset=dataset)
@@ -417,8 +469,12 @@ def delete_model_target_dataset(
417469
except KeyError:
418470
return _error_response(
419471
status_code=HTTPStatus.NOT_FOUND,
420-
code="404",
421-
message="The dataset was not found.",
422-
target="uuid",
472+
code="NOT_FOUND",
473+
message=(
474+
"Could not find a model-view database with uuid "
475+
f"{dataset_uuid}"
476+
),
477+
target=_MOCK_USER_TARGET,
478+
details=None,
423479
)
424480
return HTTPStatus.OK, {"Content-Length": "0"}, ""

0 commit comments

Comments
 (0)