Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/source/differences-to-vws.rst
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,13 @@ The generated dataset download is a small valid zip file containing request meta
Model Target API routes require a syntactically JSON Web Token-shaped bearer token, such as the token returned by the mock OAuth2 route.
The mock does not verify token signatures, claims, expiry, or revocation.

For unknown Model Target datasets, the mock returns an error whose ``target`` is ``userId:mock``.
Real Vuforia uses ``userId:<numeric-user-id>`` where the numeric portion is per-account.

Two Model Target Web API error paths remain mock-only in ``tests/mock_vws/test_model_target_web_api.py::TestMockOnlyErrors``.
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.
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.

Header cases
------------

Expand Down
1 change: 1 addition & 0 deletions newsfragments/3193.change
Original file line number Diff line number Diff line change
@@ -0,0 +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).
1 change: 1 addition & 0 deletions newsfragments/3194.change
Original file line number Diff line number Diff line change
@@ -0,0 +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).
1 change: 1 addition & 0 deletions newsfragments/3197.change
Original file line number Diff line number Diff line change
@@ -0,0 +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.
162 changes: 109 additions & 53 deletions src/mock_vws/_model_target_web_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import base64
import io
import json
import uuid
import zipfile
from http import HTTPStatus
from typing import Any
Expand All @@ -19,6 +20,11 @@
_JWT_DOT_COUNT = 2
_MOCK_MODEL_TARGET_CLIENT_ID = "client-id"
_MOCK_MODEL_TARGET_CLIENT_SECRET = "client-secret" # noqa: S105
# A stable mock value standing in for the user-id segment that real
# Vuforia embeds in some Model Target error targets such as
# ``userId:7635391``. The numeric portion is per-account in real Vuforia;
# the mock uses a fixed placeholder.
_MOCK_USER_TARGET = "userId:mock"


@beartype
Expand All @@ -45,18 +51,36 @@ def _error_response(
status_code: HTTPStatus,
code: str,
message: str,
target: str,
target: str | None,
details: list[dict[str, str]] | None,
) -> _ResponseType:
"""Return an error response shaped like the Model Target Web API."""
return _json_response(
status_code=status_code,
body={
"error": {
"code": code,
"message": message,
"target": target,
},
},
error: dict[str, Any] = {"code": code, "message": message}
if target is not None:
error["target"] = target
if details is not None:
error["details"] = details
return _json_response(status_code=status_code, body={"error": error})


@beartype
def _validation_error_response(
*,
details: list[dict[str, str]],
) -> _ResponseType:
"""Return a Vuforia-style validation error.

Real Vuforia tags each validation error with a per-request UUID that
appears in both ``message`` and ``target``. The mock generates a fresh
UUID so the shape matches.
"""
request_uuid = uuid.uuid4().hex
return _error_response(
status_code=HTTPStatus.BAD_REQUEST,
code="BAD_REQUEST",
message=f"Validation error for request {request_uuid}",
target=request_uuid,
details=details,
)


Expand Down Expand Up @@ -112,6 +136,7 @@ def _require_bearer_token(request: RequestData) -> _ResponseType | None:
code="401",
message="no Bearer token",
target="jwt",
details=None,
)
bearer_token = auth_header.removeprefix("Bearer ").strip()
if not bearer_token:
Expand All @@ -120,13 +145,15 @@ def _require_bearer_token(request: RequestData) -> _ResponseType | None:
code="401",
message="no Bearer token",
target="jwt",
details=None,
)
if bearer_token.count(".") != _JWT_DOT_COUNT:
return _error_response(
status_code=HTTPStatus.UNAUTHORIZED,
code="401",
message="Invalid JWT serialization: Missing dot delimiter(s)",
target="jwt",
details=None,
)
return None

Expand Down Expand Up @@ -214,19 +241,21 @@ def _load_request_json(request: RequestData) -> dict[str, Any] | _ResponseType:
content_type = _get_header(request=request, name="Content-Type") or ""
if "application/json" not in content_type:
return _error_response(
status_code=HTTPStatus.BAD_REQUEST,
code="BAD_REQUEST",
message="Content-Type must be application/json.",
target="Content-Type",
status_code=HTTPStatus.UNSUPPORTED_MEDIA_TYPE,
code="ERROR",
message="Expecting text/json or application/json body",
target=None,
details=None,
)
try:
request_json: dict[str, Any] = json.loads(s=request.body)
except json.JSONDecodeError:
except json.JSONDecodeError as exc:
return _error_response(
status_code=HTTPStatus.BAD_REQUEST,
code="BAD_REQUEST",
message="Request body must be valid JSON.",
target="body",
code="ERROR",
message=f"Invalid Json: {exc}",
target=None,
details=None,
)
return request_json

Expand All @@ -238,44 +267,55 @@ def _validate_dataset_request(
dataset_type: ModelTargetDatasetType,
) -> _ResponseType | None:
"""Validate the dataset request enough for useful mock feedback."""
for field in ("name", "models", "targetSdk"):
if field not in request_json:
return _error_response(
status_code=HTTPStatus.BAD_REQUEST,
code="BAD_REQUEST",
message=f"Missing required field: {field}.",
target=field,
)
missing_details = [
{
"code": "VALIDATION_ERROR",
"message": f"/{field}: element is required",
}
for field in ("models", "name", "targetSdk")
if field not in request_json
]
if missing_details:
return _validation_error_response(details=missing_details)

models_value = request_json["models"]
if not isinstance(models_value, list):
return _error_response(
status_code=HTTPStatus.BAD_REQUEST,
code="BAD_REQUEST",
message="models must be a list.",
target="models",
return _validation_error_response(
details=[
{
"code": "VALIDATION_ERROR",
"message": "/models: error.expected.jsarray",
},
],
)

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

if dataset_type == ModelTargetDatasetType.STANDARD and model_count != 1:
return _error_response(
status_code=HTTPStatus.BAD_REQUEST,
code="BAD_REQUEST",
message="Standard Model Target datasets must have one model.",
target="models",
return _validation_error_response(
details=[
{
"code": "VALIDATION_ERROR",
"message": "exactly one model should be provided",
},
],
)

if (
dataset_type == ModelTargetDatasetType.ADVANCED
and not 1 <= model_count <= _MAX_ADVANCED_MODEL_COUNT
):
return _error_response(
status_code=HTTPStatus.BAD_REQUEST,
code="BAD_REQUEST",
message="Advanced Model Target datasets must have 1 to 20 models.",
target="models",
return _validation_error_response(
details=[
{
"code": "VALIDATION_ERROR",
"message": (
"models must contain between 1 and "
f"{_MAX_ADVANCED_MODEL_COUNT} entries"
),
},
],
)

return None
Expand Down Expand Up @@ -333,9 +373,13 @@ def get_model_target_dataset_status(
except KeyError:
return _error_response(
status_code=HTTPStatus.NOT_FOUND,
code="404",
message="The dataset was not found.",
target="uuid",
code="NOT_FOUND",
message=(
"Could not find a model-view database with uuid "
f"{dataset_uuid}"
),
target=_MOCK_USER_TARGET,
details=None,
)
return _json_response(
status_code=HTTPStatus.OK,
Expand Down Expand Up @@ -378,16 +422,24 @@ def download_model_target_dataset(
except KeyError:
return _error_response(
status_code=HTTPStatus.NOT_FOUND,
code="404",
message="The dataset was not found.",
target="uuid",
code="NOT_FOUND",
message=(
"Could not find a model-view database with uuid "
f"{dataset_uuid}"
),
target=_MOCK_USER_TARGET,
details=None,
)
if dataset.status != "done":
return _error_response(
status_code=HTTPStatus.UNPROCESSABLE_ENTITY,
code="UNPROCESSABLE_ENTITY",
message="The dataset is still processing.",
target="uuid",
code="UNSUPPORTED_STATE",
message=(
f"Training status for dataset {dataset_uuid} is "
"not-started != done"
),
target=dataset_uuid,
details=None,
)

body = _dataset_zip_bytes(dataset=dataset)
Expand Down Expand Up @@ -417,8 +469,12 @@ def delete_model_target_dataset(
except KeyError:
return _error_response(
status_code=HTTPStatus.NOT_FOUND,
code="404",
message="The dataset was not found.",
target="uuid",
code="NOT_FOUND",
message=(
"Could not find a model-view database with uuid "
f"{dataset_uuid}"
),
target=_MOCK_USER_TARGET,
details=None,
)
return HTTPStatus.OK, {"Content-Length": "0"}, ""
Loading
Loading