Skip to content

Commit 4873a7b

Browse files
adamtheturtleclaude
andcommitted
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>
1 parent 8f74f54 commit 4873a7b

6 files changed

Lines changed: 264 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: 98 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 = None,
55+
details: list[dict[str, str]] | None = 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

@@ -214,19 +238,17 @@ def _load_request_json(request: RequestData) -> dict[str, Any] | _ResponseType:
214238
content_type = _get_header(request=request, name="Content-Type") or ""
215239
if "application/json" not in content_type:
216240
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",
241+
status_code=HTTPStatus.UNSUPPORTED_MEDIA_TYPE,
242+
code="ERROR",
243+
message="Expecting text/json or application/json body",
221244
)
222245
try:
223246
request_json: dict[str, Any] = json.loads(s=request.body)
224-
except json.JSONDecodeError:
247+
except json.JSONDecodeError as exc:
225248
return _error_response(
226249
status_code=HTTPStatus.BAD_REQUEST,
227-
code="BAD_REQUEST",
228-
message="Request body must be valid JSON.",
229-
target="body",
250+
code="ERROR",
251+
message=f"Invalid Json: {exc}",
230252
)
231253
return request_json
232254

@@ -238,44 +260,55 @@ def _validate_dataset_request(
238260
dataset_type: ModelTargetDatasetType,
239261
) -> _ResponseType | None:
240262
"""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-
)
263+
missing_details = [
264+
{
265+
"code": "VALIDATION_ERROR",
266+
"message": f"/{field}: element is required",
267+
}
268+
for field in ("models", "name", "targetSdk")
269+
if field not in request_json
270+
]
271+
if missing_details:
272+
return _validation_error_response(details=missing_details)
249273

250274
models_value = request_json["models"]
251275
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",
276+
return _validation_error_response(
277+
details=[
278+
{
279+
"code": "VALIDATION_ERROR",
280+
"message": "/models: error.expected.jsarray",
281+
},
282+
],
257283
)
258284

259285
models: list[Any] = [*models_value]
260286
model_count = len(models)
261287

262288
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",
289+
return _validation_error_response(
290+
details=[
291+
{
292+
"code": "VALIDATION_ERROR",
293+
"message": "exactly one model should be provided",
294+
},
295+
],
268296
)
269297

270298
if (
271299
dataset_type == ModelTargetDatasetType.ADVANCED
272300
and not 1 <= model_count <= _MAX_ADVANCED_MODEL_COUNT
273301
):
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",
302+
return _validation_error_response(
303+
details=[
304+
{
305+
"code": "VALIDATION_ERROR",
306+
"message": (
307+
"models must contain between 1 and "
308+
f"{_MAX_ADVANCED_MODEL_COUNT} entries"
309+
),
310+
},
311+
],
279312
)
280313

281314
return None
@@ -333,9 +366,12 @@ def get_model_target_dataset_status(
333366
except KeyError:
334367
return _error_response(
335368
status_code=HTTPStatus.NOT_FOUND,
336-
code="404",
337-
message="The dataset was not found.",
338-
target="uuid",
369+
code="NOT_FOUND",
370+
message=(
371+
"Could not find a model-view database with uuid "
372+
f"{dataset_uuid}"
373+
),
374+
target=_MOCK_USER_TARGET,
339375
)
340376
return _json_response(
341377
status_code=HTTPStatus.OK,
@@ -378,16 +414,22 @@ def download_model_target_dataset(
378414
except KeyError:
379415
return _error_response(
380416
status_code=HTTPStatus.NOT_FOUND,
381-
code="404",
382-
message="The dataset was not found.",
383-
target="uuid",
417+
code="NOT_FOUND",
418+
message=(
419+
"Could not find a model-view database with uuid "
420+
f"{dataset_uuid}"
421+
),
422+
target=_MOCK_USER_TARGET,
384423
)
385424
if dataset.status != "done":
386425
return _error_response(
387426
status_code=HTTPStatus.UNPROCESSABLE_ENTITY,
388-
code="UNPROCESSABLE_ENTITY",
389-
message="The dataset is still processing.",
390-
target="uuid",
427+
code="UNSUPPORTED_STATE",
428+
message=(
429+
f"Training status for dataset {dataset_uuid} is "
430+
"not-started != done"
431+
),
432+
target=dataset_uuid,
391433
)
392434

393435
body = _dataset_zip_bytes(dataset=dataset)
@@ -417,8 +459,11 @@ def delete_model_target_dataset(
417459
except KeyError:
418460
return _error_response(
419461
status_code=HTTPStatus.NOT_FOUND,
420-
code="404",
421-
message="The dataset was not found.",
422-
target="uuid",
462+
code="NOT_FOUND",
463+
message=(
464+
"Could not find a model-view database with uuid "
465+
f"{dataset_uuid}"
466+
),
467+
target=_MOCK_USER_TARGET,
423468
)
424469
return HTTPStatus.OK, {"Content-Length": "0"}, ""

0 commit comments

Comments
 (0)