From a9bc66384f3ffdbbd76b78a9922db84545786e8e Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Thu, 13 Aug 2026 00:58:42 +0100 Subject: [PATCH] Validate documented Model Target model enum fields Towards #3193: validate the model fields which the Model Target OpenAPI specification documents as enumerations - automaticColoring, motionHint, optimizeTrackingFor, simplify and trackingMode, plus realisticAppearance for advanced datasets only - following the existing cadDataFormat validation pattern. Co-Authored-By: Claude Fable 5 --- docs/source/differences-to-vws.rst | 13 ++- newsfragments/model-target-enum-fields.change | 1 + src/mock_vws/_model_target_web_api.py | 75 ++++++++----- tests/mock_vws/test_model_target_web_api.py | 106 ++++++++++++++++++ 4 files changed, 167 insertions(+), 28 deletions(-) create mode 100644 newsfragments/model-target-enum-fields.change diff --git a/docs/source/differences-to-vws.rst b/docs/source/differences-to-vws.rst index c4d52c47d..6dfd32eb8 100644 --- a/docs/source/differences-to-vws.rst +++ b/docs/source/differences-to-vws.rst @@ -253,10 +253,17 @@ Dataset creation requests are validated for the required top-level ``models``, entry being a JSON object, and for the number of models. Each model is validated for the required ``name`` field, for exactly one of ``cadDataUrl`` and ``cadDataBlob`` being given, for the types of the -``cadDataBlob``, ``cadDataFormat``, ``cadDataUrl`` and ``name`` fields, for -``cadDataFormat`` being one of ``DAE``, ``FBX``, ``GLB``, ``IGES``, ``OBJ``, -``PVZ``, ``STL``, ``VRML`` and ``ZIP`` when it is given, and for ``views`` +``automaticColoring``, ``cadDataBlob``, ``cadDataFormat``, ``cadDataUrl``, +``motionHint``, ``name``, ``optimizeTrackingFor``, ``simplify`` and +``trackingMode`` fields, for each of the ``automaticColoring``, +``cadDataFormat``, ``motionHint``, ``optimizeTrackingFor``, ``simplify`` and +``trackingMode`` fields being one of the values which the Model Target OpenAPI +specification documents for it when the field is given, and for ``views`` being a JSON array when it is given. +The ``realisticAppearance`` model field is validated in the same way for +advanced datasets; the OpenAPI specification does not document it as a +standard dataset model field, so standard dataset creation does not validate +it. Each ``views`` entry is validated for being a JSON object, for the required ``guideViewPosition`` and ``name`` fields, and for those fields' types. Each ``guideViewPosition`` object is validated for the required ``rotation`` diff --git a/newsfragments/model-target-enum-fields.change b/newsfragments/model-target-enum-fields.change new file mode 100644 index 000000000..ddbb0780f --- /dev/null +++ b/newsfragments/model-target-enum-fields.change @@ -0,0 +1 @@ +Reject Model Target dataset creation requests with values outside the documented enumerations for the ``automaticColoring``, ``motionHint``, ``optimizeTrackingFor``, ``realisticAppearance``, ``simplify`` and ``trackingMode`` model fields. diff --git a/src/mock_vws/_model_target_web_api.py b/src/mock_vws/_model_target_web_api.py index bcd4bceca..e6c20cbd9 100644 --- a/src/mock_vws/_model_target_web_api.py +++ b/src/mock_vws/_model_target_web_api.py @@ -59,21 +59,36 @@ def remove_model_target_dataset(self, dataset_uuid: str) -> None: # ``userId:7635391``. The numeric portion is per-account in real Vuforia; # the mock uses a fixed placeholder. _MOCK_USER_TARGET = "userId:mock" -# The CAD data formats documented by the Model Target Web API OpenAPI -# specification. -_CAD_DATA_FORMATS = frozenset( - { - "DAE", - "FBX", - "GLB", - "IGES", - "OBJ", - "PVZ", - "STL", - "VRML", - "ZIP", - }, -) +# The enumerated model field values documented by the Model Target Web API +# OpenAPI specification. +_MODEL_ENUM_FIELD_VALUES: dict[str, frozenset[str]] = { + "automaticColoring": frozenset({"always", "auto", "never"}), + "cadDataFormat": frozenset( + { + "DAE", + "FBX", + "GLB", + "IGES", + "OBJ", + "PVZ", + "STL", + "VRML", + "ZIP", + }, + ), + "motionHint": frozenset({"adaptive", "dynamic", "static"}), + "optimizeTrackingFor": frozenset( + {"ar_controller", "default", "low_feature_objects"}, + ), + "simplify": frozenset({"always", "auto", "never"}), + "trackingMode": frozenset({"car", "default", "scan"}), +} +# ``realisticAppearance`` is documented as an enumerated model field for +# advanced datasets only. +_ADVANCED_MODEL_ENUM_FIELD_VALUES: dict[str, frozenset[str]] = { + **_MODEL_ENUM_FIELD_VALUES, + "realisticAppearance": frozenset({"auto", "false", "true"}), +} @beartype @@ -422,8 +437,17 @@ def _cad_data_source_details(*, models: list[Any]) -> list[dict[str, str]]: @beartype -def _model_field_details(*, models: list[Any]) -> list[dict[str, str]]: +def _model_field_details( + *, + models: list[Any], + dataset_type: ModelTargetDatasetType, +) -> list[dict[str, str]]: """Return validation details for the fields of each model.""" + enum_field_values = ( + _ADVANCED_MODEL_ENUM_FIELD_VALUES + if dataset_type == ModelTargetDatasetType.ADVANCED + else _MODEL_ENUM_FIELD_VALUES + ) missing_details = [ { "code": "VALIDATION_ERROR", @@ -436,28 +460,29 @@ def _model_field_details(*, models: list[Any]) -> list[dict[str, str]]: if missing_details or cad_data_source_details: return missing_details + cad_data_source_details + string_fields = sorted( + {"cadDataBlob", "cadDataUrl", "name", *enum_field_values}, + ) string_details = [ { "code": "VALIDATION_ERROR", "message": f"/models({index})/{field}: error.expected.jsstring", } for index, model in enumerate(iterable=models) - for field in ("cadDataBlob", "cadDataFormat", "cadDataUrl", "name") + for field in string_fields if field in model and not isinstance(model[field], str) ] if string_details: return string_details - format_details = [ + enum_details = [ { "code": "VALIDATION_ERROR", - "message": ( - f"/models({index})/cadDataFormat: error.expected.validenum" - ), + "message": f"/models({index})/{field}: error.expected.validenum", } for index, model in enumerate(iterable=models) - if "cadDataFormat" in model - and model["cadDataFormat"] not in _CAD_DATA_FORMATS + for field, allowed_values in sorted(enum_field_values.items()) + if field in model and model[field] not in allowed_values ] views_details = [ { @@ -467,7 +492,7 @@ def _model_field_details(*, models: list[Any]) -> list[dict[str, str]]: for index, model in enumerate(iterable=models) if "views" in model and not isinstance(model["views"], list) ] - return format_details + views_details + return enum_details + views_details @beartype @@ -694,7 +719,7 @@ def _validate_dataset_request( if not details: models: list[Any] = [*request_json["models"]] details = ( - _model_field_details(models=models) + _model_field_details(models=models, dataset_type=dataset_type) or _view_details(models=models) or _guide_view_position_details(models=models) or _model_count_details( diff --git a/tests/mock_vws/test_model_target_web_api.py b/tests/mock_vws/test_model_target_web_api.py index 1272942db..6961c9196 100644 --- a/tests/mock_vws/test_model_target_web_api.py +++ b/tests/mock_vws/test_model_target_web_api.py @@ -677,6 +677,71 @@ def test_invalid_basic_auth_header(*, authorization: str) -> None: {"/models(0)/name: error.expected.jsstring"}, id="model-name-not-string", ), + pytest.param( + { + **_UNAUTHENTICATED_DATASET_REQUEST, + "models": [{**_MODEL, "simplify": 1}], + }, + {"/models(0)/simplify: error.expected.jsstring"}, + id="model-simplify-not-string", + ), + pytest.param( + { + **_UNAUTHENTICATED_DATASET_REQUEST, + "models": [{**_MODEL, "simplify": "sometimes"}], + }, + {"/models(0)/simplify: error.expected.validenum"}, + id="model-simplify-not-in-enum", + ), + pytest.param( + { + **_UNAUTHENTICATED_DATASET_REQUEST, + "models": [{**_MODEL, "automaticColoring": "sometimes"}], + }, + {"/models(0)/automaticColoring: error.expected.validenum"}, + id="model-automatic-coloring-not-in-enum", + ), + pytest.param( + { + **_UNAUTHENTICATED_DATASET_REQUEST, + "models": [{**_MODEL, "motionHint": "still"}], + }, + {"/models(0)/motionHint: error.expected.validenum"}, + id="model-motion-hint-not-in-enum", + ), + pytest.param( + { + **_UNAUTHENTICATED_DATASET_REQUEST, + "models": [{**_MODEL, "optimizeTrackingFor": "cars"}], + }, + {"/models(0)/optimizeTrackingFor: error.expected.validenum"}, + id="model-optimize-tracking-for-not-in-enum", + ), + pytest.param( + { + **_UNAUTHENTICATED_DATASET_REQUEST, + "models": [{**_MODEL, "trackingMode": "boat"}], + }, + {"/models(0)/trackingMode: error.expected.validenum"}, + id="model-tracking-mode-not-in-enum", + ), + pytest.param( + { + **_UNAUTHENTICATED_DATASET_REQUEST, + "models": [ + { + **_MODEL, + "motionHint": "still", + "simplify": "sometimes", + }, + ], + }, + { + "/models(0)/motionHint: error.expected.validenum", + "/models(0)/simplify: error.expected.validenum", + }, + id="model-multiple-enum-errors", + ), pytest.param( { **_UNAUTHENTICATED_DATASET_REQUEST, @@ -998,6 +1063,47 @@ def test_advanced_model_count_exceeds_limit() -> None: assert error["code"] == "BAD_REQUEST" assert error["details"][0]["code"] == "VALIDATION_ERROR" + @staticmethod + def test_advanced_realistic_appearance_not_in_enum() -> None: + """Advanced dataset requests with a ``realisticAppearance`` value + outside the documented enumeration are rejected. + + The Model Target OpenAPI specification documents + ``realisticAppearance`` as a model field for advanced datasets + only, so standard dataset creation does not validate it. This is + mock-only because the available test account lacks the + advanced-dataset scope, so real Vuforia rejects the request with a + 403 before validating the body. + """ + body = { + **_UNAUTHENTICATED_DATASET_REQUEST, + "models": [{**_MODEL, "realisticAppearance": "yes"}], + } + headers = {"Authorization": f"Bearer {_MOCK_BEARER_TOKEN}"} + with MockVWS(): + advanced_response = requests.post( + url=f"{_VWS_HOST}/modeltargets/advancedDatasets", + headers=headers, + json=body, + timeout=30, + ) + standard_response = requests.post( + url=f"{_VWS_HOST}/modeltargets/datasets", + headers=headers, + json=body, + timeout=30, + ) + + assert advanced_response.status_code == HTTPStatus.BAD_REQUEST + error = advanced_response.json()["error"] + assert error["code"] == "BAD_REQUEST" + assert [detail["message"] for detail in error["details"]] == [ + "/models(0)/realisticAppearance: error.expected.validenum", + ] + assert error["details"][0]["code"] == "VALIDATION_ERROR" + + assert standard_response.status_code == HTTPStatus.CREATED + @staticmethod def test_processing_dataset_cannot_be_downloaded() -> None: """A dataset cannot be downloaded while it is still processing.