From 4413c62c8800c7253a74d190bf58e5126b6f699e Mon Sep 17 00:00:00 2001 From: Tom Hammel Date: Mon, 31 Aug 2026 15:47:53 +0200 Subject: [PATCH 01/22] server: Implement the read-only `$value` routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All `$value` routes previously responded with `501 Not Implemented`. The specification leaves some points open, which are resolved as follows: - Property values are mapped to the JSON type of their `valueType`, so booleans and numbers are not quoted. Values without a JSON literal are returned as their XSD representation. `xs:integer` is unbounded in both Python and JSON and is therefore passed on as it is. - Elements without a value are omitted from Submodels, collections and Entity statements, but kept as `null` within a SubmodelElementList, as omitting them would shift the indices of the remaining elements. - Operations and Capabilities have no ValueOnly representation. They are skipped in containers and result in `400 Bad Request` when requested directly, which is how the `$metadata` route handles them. - `level=core` returns the direct children of the requested resource and their nested containers as empty, matching the examples of the specification. - The entries of `GET /submodels/{id}/submodel-elements/$value` are mapped by idShort, as a bare `SubmodelElementValue` would lose the association between a value and its element. Submodels are returned as-is, since their entries are already named. - The languages of a MultiLanguageProperty are sorted, to keep the order of the response deterministic. The ValueOnly serialization is only defined for JSON, so the routes respond with `406 Not Acceptable` if XML is requested. `extent` remains unimplemented, as on all other routes. `PATCH …/$value`, the `$path` routes and the operation invocation routes are not part of this change. Fixes #446 --- server/README.md | 4 +- server/app/adapter/__init__.py | 1 + server/app/adapter/value_only.py | 223 ++++++++++++++++++ server/app/interfaces/repository.py | 85 ++++++- server/test/interfaces/test_value_only.py | 264 ++++++++++++++++++++++ 5 files changed, 570 insertions(+), 7 deletions(-) create mode 100644 server/app/adapter/value_only.py create mode 100644 server/test/interfaces/test_value_only.py diff --git a/server/README.md b/server/README.md index 1a3e42173..d3d52c34d 100644 --- a/server/README.md +++ b/server/README.md @@ -121,8 +121,8 @@ Several features and routes are currently not supported: - `/description` These routes are not implemented at this time. -5. Value, Path, and PATCH Routes: - - All `/…/value$`, `/…/path$`, and `PATCH` routes are currently not implemented. +5. Path and PATCH Routes: + - All `/…/$path` and `PATCH` routes are currently not implemented. 6. Operation Invocation Routes: The following routes are not implemented because operation invocation is not yet supported by the `basyx-python-sdk`: diff --git a/server/app/adapter/__init__.py b/server/app/adapter/__init__.py index 10c99aa69..8a3e2af17 100644 --- a/server/app/adapter/__init__.py +++ b/server/app/adapter/__init__.py @@ -1 +1,2 @@ from .jsonization import * +from .value_only import * diff --git a/server/app/adapter/value_only.py b/server/app/adapter/value_only.py new file mode 100644 index 000000000..8eeb61a7a --- /dev/null +++ b/server/app/adapter/value_only.py @@ -0,0 +1,223 @@ +# Copyright (c) 2026 the Eclipse BaSyx Authors +# +# This program and the accompanying materials are made available under the terms of the MIT License, available in +# the LICENSE file of this project. +# +# SPDX-License-Identifier: MIT +""" +ValueOnly serialization of Submodels and SubmodelElements, as defined in "Specification of the Asset Administration +Shell Part 1", section "Value-Only Serialization in JSON". + +The ValueOnly serialization cannot be derived from the JSON schema of the metamodel, since it defines individual rules +per class. Therefore, it is implemented by hand here, on top of the object model of the ``basyx-python-sdk``. + +The returned structures are JSON-serializable with :class:`~basyx.aas.adapter.json.AASToJsonEncoder` (or any subclass, +e.g. the ``ResultToJsonEncoder`` of this server): :class:`~basyx.aas.model.base.Reference` and +:class:`~basyx.aas.model.base.SpecificAssetId` objects are passed through as-is instead of being converted here, to +avoid duplicating the serialization rules of the SDK. +""" + +import base64 +import decimal +import math +from typing import Any, Dict, Iterable, List, Optional + +from basyx.aas import model +from basyx.aas.adapter._generic import ENTITY_TYPES + +__all__ = [ + "has_value_only_representation", + "submodel_element_to_named_value_only", + "submodel_element_to_value_only", + "submodel_to_value_only", +] + +# SubmodelElement types that don't have a ValueOnly representation. Operations only have one in the context of their +# invocation, which is not supported by this server. +_TYPES_WITHOUT_VALUE_ONLY = (model.Operation, model.Capability) + + +def has_value_only_representation(element: model.SubmodelElement) -> bool: + """ + Whether a SubmodelElement has a ValueOnly representation at all. + + :param element: The SubmodelElement to check + :return: ``False`` for Operations and Capabilities, ``True`` otherwise + """ + return not isinstance(element, _TYPES_WITHOUT_VALUE_ONLY) + + +def submodel_to_value_only(submodel: model.Submodel, deep: bool = True) -> Dict[str, Any]: + """ + Serialize a Submodel in its ValueOnly representation. + + :param submodel: The Submodel to serialize + :param deep: If ``False`` (i.e. ``level=core``), only the first level of SubmodelElements is serialized and nested + containers are returned empty + :return: A dictionary, mapping the idShort of each SubmodelElement to its value + """ + return _namespace_to_value_only(submodel.submodel_element, _initial_depth(deep)) + + +def submodel_element_to_value_only(element: model.SubmodelElement, deep: bool = True) -> Any: + """ + Serialize a SubmodelElement in its ValueOnly representation. + + :param element: The SubmodelElement to serialize + :param deep: If ``False`` (i.e. ``level=core``), only the first level of child elements is serialized and nested + containers are returned empty + :raises TypeError: If the given element doesn't have a ValueOnly representation, see + :func:`has_value_only_representation` + :return: The value of the element, ``None`` if it doesn't have one + """ + return _element_to_value_only(element, _initial_depth(deep)) + + +def _initial_depth(deep: bool) -> float: + # The number of container levels that are serialized below the requested resource. level=core includes the direct + # children of the requested resource, but returns their children as empty containers. + return math.inf if deep else 1 + + +def _namespace_to_value_only(elements: Iterable[model.SubmodelElement], depth: float) -> Dict[str, Any]: + data: Dict[str, Any] = {} + if depth <= 0: + return data + for element in elements: + if not has_value_only_representation(element): + continue + value = _element_to_value_only(element, depth - 1) + # Elements without a value are omitted + if value is None: + continue + # The idShort is always set for elements that aren't contained in a SubmodelElementList + assert element.id_short is not None + data[element.id_short] = value + return data + + +def _element_to_value_only(element: model.SubmodelElement, depth: float) -> Any: + if isinstance(element, model.Property): + return _typed_value_to_value_only(element.value, element.value_type) + if isinstance(element, model.MultiLanguageProperty): + return _lang_string_set_to_value_only(element.value) + if isinstance(element, model.Range): + return _range_to_value_only(element) + if isinstance(element, model.Blob): + return { + "contentType": element.content_type, + "value": None if element.value is None else base64.b64encode(element.value).decode(), + } + if isinstance(element, model.File): + return {"contentType": element.content_type, "value": element.value} + if isinstance(element, model.ReferenceElement): + return element.value + # AnnotatedRelationshipElement is a subclass of RelationshipElement and must therefore be checked first + if isinstance(element, model.AnnotatedRelationshipElement): + return { + "first": element.first, + "second": element.second, + "annotations": _annotations_to_value_only(element.annotation, depth), + } + if isinstance(element, model.RelationshipElement): + return {"first": element.first, "second": element.second} + if isinstance(element, model.Entity): + return _entity_to_value_only(element, depth) + if isinstance(element, model.BasicEventElement): + return {"observed": element.observed} + if isinstance(element, model.SubmodelElementList): + return _submodel_element_list_to_value_only(element, depth) + if isinstance(element, model.SubmodelElementCollection): + return _namespace_to_value_only(element.value, depth) + raise TypeError(f"{element!r} doesn't have a ValueOnly representation!") + + +def _typed_value_to_value_only(value: Optional[model.ValueDataType], value_type: model.DataTypeDefXsd) -> Any: + """ + Map a typed value to its JSON representation: booleans become JSON booleans, numeric types become JSON numbers and + everything else becomes a JSON string containing the XSD representation of the value. + """ + if value is None: + return None + if value_type is model.datatypes.Boolean or isinstance(value, bool): + return bool(value) + if isinstance(value, int): + # int subclasses are serialized as JSON numbers as-is. xs:integer is unbounded, so no conversion to float may + # happen here, as that would raise an OverflowError for large values. + return value + if isinstance(value, (float, decimal.Decimal)): + # JSON doesn't support NaN, INF and -INF. The specification acknowledges this gap without resolving it, so the + # XSD representation is returned as a string instead of emitting invalid JSON. This also catches values of + # decimal.Decimal that are finite, but too large to be represented as a float. + if not math.isfinite(float(value)): + return model.datatypes.xsd_repr(value) + # decimal.Decimal cannot be serialized as JSON and may lose precision here + return float(value) if isinstance(value, decimal.Decimal) else value + return model.datatypes.xsd_repr(value) + + +def _lang_string_set_to_value_only(value: Optional[model.MultiLanguageTextType]) -> List[Dict[str, str]]: + if value is None: + return [] + # Sorted by language, to keep the order of the languages deterministic across requests + return [{language: text} for language, text in sorted(value.items())] + + +def _range_to_value_only(element: model.Range) -> Dict[str, Any]: + data: Dict[str, Any] = {} + if element.min is not None: + data["min"] = _typed_value_to_value_only(element.min, element.value_type) + if element.max is not None: + data["max"] = _typed_value_to_value_only(element.max, element.value_type) + return data + + +def _annotations_to_value_only(annotations: Iterable[model.DataElement], depth: float) -> List[Dict[str, Any]]: + if depth <= 0: + return [] + return [ + {annotation.id_short: _element_to_value_only(annotation, depth - 1)} + for annotation in annotations + if annotation.id_short is not None + ] + + +def _entity_to_value_only(element: model.Entity, depth: float) -> Dict[str, Any]: + data: Dict[str, Any] = {"statements": _namespace_to_value_only(element.statement, depth)} + # entityType is mandatory in the specification, but optional in the object model of the SDK + if element.entity_type is not None: + data["entityType"] = ENTITY_TYPES[element.entity_type] + if element.global_asset_id is not None: + data["globalAssetId"] = element.global_asset_id + if len(element.specific_asset_id) > 0: + data["specificAssetIds"] = list(element.specific_asset_id) + return data + + +def _submodel_element_list_to_value_only(element: model.SubmodelElementList, depth: float) -> List[Any]: + if depth <= 0: + return [] + # In contrast to the other containers, elements without a value are not omitted here, to keep the indices of the + # remaining elements intact + return [ + _element_to_value_only(child, depth - 1) if has_value_only_representation(child) else None + for child in element.value + ] + + +def submodel_element_to_named_value_only(element: model.SubmodelElement, deep: bool = True) -> Dict[str, Any]: + """ + Serialize a SubmodelElement as a single-entry dictionary, mapping its idShort to its ValueOnly representation. + + This is the representation used for the entries of ``GET /submodels/{submodelIdentifier}/submodel-elements/$value``. + The specification leaves the shape of these entries open: a bare ``SubmodelElementValue`` would lose the + association between a value and the element it belongs to, hence the elements are named here, just like they are + within the ValueOnly representation of their parent. + + :param element: The SubmodelElement to serialize + :param deep: If ``False`` (i.e. ``level=core``), only the first level of child elements is serialized + :return: A dictionary with a single entry, mapping the idShort of the given element to its value + """ + # The idShort is always set for elements that aren't contained in a SubmodelElementList + assert element.id_short is not None + return {element.id_short: submodel_element_to_value_only(element, deep)} diff --git a/server/app/interfaces/repository.py b/server/app/interfaces/repository.py index 8f931c786..e5f327794 100644 --- a/server/app/interfaces/repository.py +++ b/server/app/interfaces/repository.py @@ -22,11 +22,17 @@ from werkzeug.exceptions import BadRequest, Conflict, NotFound from werkzeug.routing import MapAdapter, Rule, Submount +from app.adapter import ( + has_value_only_representation, + submodel_element_to_named_value_only, + submodel_element_to_value_only, + submodel_to_value_only, +) from app.interfaces.base import PagingMetadata from app.model import ServiceDescription, ServiceSpecificationProfileEnum from app.util.converters import IdentifierToBase64URLConverter, IdShortPathConverter, base64url_decode -from .base import APIResponse, HTTPApiDecoder, ObjectStoreWSGIApp, T, is_stripped_request +from .base import APIResponse, HTTPApiDecoder, JsonResponse, ObjectStoreWSGIApp, T, is_stripped_request SUPPORTED_PROFILES: ServiceDescription = ServiceDescription( [ @@ -122,7 +128,7 @@ def __init__( [ Rule("/$metadata", methods=["GET"], endpoint=self.get_submodel_all_metadata), Rule("/$reference", methods=["GET"], endpoint=self.get_submodel_all_reference), - Rule("/$value", methods=["GET"], endpoint=self.not_implemented), + Rule("/$value", methods=["GET"], endpoint=self.get_submodel_all_value), Rule("/$path", methods=["GET"], endpoint=self.not_implemented), Rule("/", methods=["GET"], endpoint=self.get_submodel), Rule("/", methods=["PUT"], endpoint=self.put_submodel), @@ -133,7 +139,7 @@ def __init__( [ Rule("/$metadata", methods=["GET"], endpoint=self.get_submodels_metadata), Rule("/$metadata", methods=["PATCH"], endpoint=self.not_implemented), - Rule("/$value", methods=["GET"], endpoint=self.not_implemented), + Rule("/$value", methods=["GET"], endpoint=self.get_submodels_value), Rule("/$value", methods=["PATCH"], endpoint=self.not_implemented), Rule("/$reference", methods=["GET"], endpoint=self.get_submodels_reference), Rule("/$path", methods=["GET"], endpoint=self.not_implemented), @@ -160,7 +166,11 @@ def __init__( methods=["GET"], endpoint=self.get_submodel_submodel_elements_reference, ), - Rule("/$value", methods=["GET"], endpoint=self.not_implemented), + Rule( + "/$value", + methods=["GET"], + endpoint=self.get_submodel_submodel_elements_value, + ), Rule("/$path", methods=["GET"], endpoint=self.not_implemented), Rule( "/", @@ -205,7 +215,11 @@ def __init__( methods=["GET"], endpoint=self.get_submodel_submodel_elements_id_short_path_reference, ), - Rule("/$value", methods=["GET"], endpoint=self.not_implemented), + Rule( + "/$value", + methods=["GET"], + endpoint=self.get_submodel_submodel_elements_id_short_path_value, + ), Rule( "/$value", methods=["PATCH"], endpoint=self.not_implemented ), @@ -519,6 +533,29 @@ def _get_submodel_submodel_elements_id_short_path(self, url_args: Dict) -> model def _get_concept_description(self, url_args): return self._get_obj_ts(url_args["concept_id"], model.ConceptDescription) + @classmethod + def _assert_json_response(cls, response_t: Type[APIResponse]) -> None: + """ + The ValueOnly serialization is only defined for JSON, thus the ``$value`` routes cannot serve XML. + """ + if not issubclass(response_t, JsonResponse): + raise werkzeug.exceptions.NotAcceptable( + "The ValueOnly serialization is only available as application/json!" + ) + + @classmethod + def _assert_value_only_representation(cls, element: model.SubmodelElement) -> None: + if not has_value_only_representation(element): + raise BadRequest(f"{element.id_short} does not allow the content modifier value!") + + @classmethod + def _value_only_response(cls, response_t: Type[APIResponse], value: object) -> Response: + if value is None: + # An element without a value has `null` as its ValueOnly representation. This cannot be passed to the + # response classes, as they interpret `None` as an empty body and respond with `204 No Content`. + return Response(json.dumps(None), content_type="application/json") + return response_t(value) + # ------ all not implemented ROUTES ------- def not_implemented(self, request: Request, url_args: Dict, **_kwargs) -> Response: raise werkzeug.exceptions.NotImplemented("This route is not implemented!") @@ -687,6 +724,15 @@ def get_submodel_all_metadata( submodels, paging_metadata = self._get_submodels(request) return response_t(list(submodels), paging_metadata=paging_metadata, stripped=True) + def get_submodel_all_value( + self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs + ) -> Response: + self._assert_json_response(response_t) + deep = not is_stripped_request(request) + submodels, paging_metadata = self._get_submodels(request) + values = [submodel_to_value_only(submodel, deep) for submodel in submodels] + return response_t(values, paging_metadata=paging_metadata) + def get_submodel_all_reference( self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs ) -> Response: @@ -714,6 +760,13 @@ def get_submodels_metadata( submodel = self._get_submodel(url_args) return response_t(submodel, stripped=True) + def get_submodels_value( + self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs + ) -> Response: + self._assert_json_response(response_t) + submodel = self._get_submodel(url_args) + return response_t(submodel_to_value_only(submodel, not is_stripped_request(request))) + def get_submodels_reference( self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs ) -> Response: @@ -743,6 +796,19 @@ def get_submodel_submodel_elements_metadata( submodel_elements, paging_metadata = self._get_submodel_submodel_elements(request, url_args) return response_t(list(submodel_elements), paging_metadata=paging_metadata, stripped=True) + def get_submodel_submodel_elements_value( + self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs + ) -> Response: + self._assert_json_response(response_t) + deep = not is_stripped_request(request) + submodel_elements, paging_metadata = self._get_submodel_submodel_elements(request, url_args) + values = [ + submodel_element_to_named_value_only(submodel_element, deep) + for submodel_element in submodel_elements + if has_value_only_representation(submodel_element) + ] + return response_t(values, paging_metadata=paging_metadata) + def get_submodel_submodel_elements_reference( self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs ) -> Response: @@ -768,6 +834,15 @@ def get_submodel_submodel_elements_id_short_path_metadata( raise BadRequest(f"{submodel_element.id_short} does not allow the content modifier metadata!") return response_t(submodel_element, stripped=True) + def get_submodel_submodel_elements_id_short_path_value( + self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs + ) -> Response: + self._assert_json_response(response_t) + submodel_element = self._get_submodel_submodel_elements_id_short_path(url_args) + self._assert_value_only_representation(submodel_element) + value = submodel_element_to_value_only(submodel_element, not is_stripped_request(request)) + return self._value_only_response(response_t, value) + def get_submodel_submodel_elements_id_short_path_reference( self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs ) -> Response: diff --git a/server/test/interfaces/test_value_only.py b/server/test/interfaces/test_value_only.py new file mode 100644 index 000000000..391df86bd --- /dev/null +++ b/server/test/interfaces/test_value_only.py @@ -0,0 +1,264 @@ +# Copyright (c) 2026 the Eclipse BaSyx Authors +# +# This program and the accompanying materials are made available under the terms of the MIT License, available in +# the LICENSE file of this project. +# +# SPDX-License-Identifier: MIT + +import base64 +import json +import math +import unittest +from typing import Any + +from app.interfaces.repository import WSGIApp +from basyx.aas import model +from basyx.aas.adapter.aasx import DictSupplementaryFileContainer +from basyx.aas.examples.data.example_aas import create_full_example +from basyx.aas.model import DictIdentifiableStore +from werkzeug.test import Client + +BASE_PATH = "/api/v3.1" +TEST_SUBMODEL_ID = "https://example.org/Test_Submodel" +IDENTIFICATION_SUBMODEL_ID = "http://example.org/Submodels/Assets/TestAsset/Identification" +TYPED_SUBMODEL_ID = "http://example.org/Typed_Submodel" + + +def _encode(identifier: str) -> str: + return base64.urlsafe_b64encode(identifier.encode()).decode() + + +def _create_typed_submodel() -> model.Submodel: + """ + A Submodel containing the value types and edge cases that aren't part of the example data. + """ + return model.Submodel( + id_=TYPED_SUBMODEL_ID, + submodel_element=( + model.Property(id_short="IntProperty", value_type=model.datatypes.Int, value=42), + model.Property(id_short="BoolProperty", value_type=model.datatypes.Boolean, value=False), + model.Property(id_short="DoubleProperty", value_type=model.datatypes.Double, value=1.5), + model.Property(id_short="NanProperty", value_type=model.datatypes.Double, value=math.nan), + # xs:integer is unbounded, this value cannot be represented as a float + model.Property(id_short="HugeIntProperty", value_type=model.datatypes.Integer, value=10**400), + model.Property( + id_short="DateProperty", value_type=model.datatypes.Date, value=model.datatypes.Date(2026, 8, 31) + ), + model.Property(id_short="EmptyProperty", value_type=model.datatypes.String, value=None), + model.SubmodelElementList( + id_short="ListWithEmptyElement", + type_value_list_element=model.Property, + value_type_list_element=model.datatypes.String, + value=( + model.Property(id_short=None, value_type=model.datatypes.String, value="first"), + model.Property(id_short=None, value_type=model.datatypes.String, value=None), + model.Property(id_short=None, value_type=model.datatypes.String, value="third"), + ), + ), + ), + ) + + +class ValueOnlyTest(unittest.TestCase): + def setUp(self) -> None: + self.example_data = create_full_example() + self.example_data.add(_create_typed_submodel()) + self.client = Client(WSGIApp(self.example_data, DictSupplementaryFileContainer())) + + def _get_json(self, url: str, status_code: int = 200, **kwargs: Any) -> Any: + response = self.client.get(f"{BASE_PATH}{url}", **kwargs) + self.assertEqual(status_code, response.status_code, response.data) + return json.loads(response.data) + + # --------- GET /submodels/{submodelIdentifier}/$value --------- + + def test_submodel_value(self) -> None: + value = self._get_json(f"/submodels/{_encode(IDENTIFICATION_SUBMODEL_ID)}/$value") + self.assertEqual({"ManufacturerName": "ACPLT", "InstanceId": "978-8234-234-342"}, value) + + def test_submodel_value_omits_operations_and_capabilities(self) -> None: + value = self._get_json(f"/submodels/{_encode(TEST_SUBMODEL_ID)}/$value") + self.assertNotIn("ExampleOperation", value) + self.assertNotIn("ExampleCapability", value) + + def test_submodel_value_level_core_empties_nested_containers(self) -> None: + value = self._get_json(f"/submodels/{_encode(TEST_SUBMODEL_ID)}/$value?level=core") + # the direct children are present, their children are not + self.assertIn("ExampleSubmodelCollection", value) + self.assertEqual({}, value["ExampleSubmodelCollection"]) + self.assertEqual([], value["ExampleAnnotatedRelationshipElement"]["annotations"]) + + def test_submodel_value_unknown_submodel(self) -> None: + self._get_json(f"/submodels/{_encode('http://example.org/does_not_exist')}/$value", status_code=404) + + # --------- GET /submodels/{submodelIdentifier}/submodel-elements/{idShortPath}/$value --------- + + def test_property_value_types(self) -> None: + base_url = f"/submodels/{_encode(TYPED_SUBMODEL_ID)}/submodel-elements" + self.assertEqual(42, self._get_json(f"{base_url}/IntProperty/$value")) + self.assertEqual(False, self._get_json(f"{base_url}/BoolProperty/$value")) + self.assertEqual(1.5, self._get_json(f"{base_url}/DoubleProperty/$value")) + # JSON has no representation for NaN, INF and -INF, so the XSD representation is returned instead + self.assertEqual("NaN", self._get_json(f"{base_url}/NanProperty/$value")) + # integers are not converted to float, as that would raise an OverflowError for large values + self.assertEqual(10**400, self._get_json(f"{base_url}/HugeIntProperty/$value")) + self.assertEqual("2026-08-31", self._get_json(f"{base_url}/DateProperty/$value")) + self.assertIsNone(self._get_json(f"{base_url}/EmptyProperty/$value")) + + def test_submodel_value_with_huge_integer(self) -> None: + # integers that exceed the range of a float are also serialized when the whole Submodel is requested + value = self._get_json(f"/submodels/{_encode(TYPED_SUBMODEL_ID)}/$value") + self.assertEqual(10**400, value["HugeIntProperty"]) + self.assertEqual(42, value["IntProperty"]) + + def test_property_without_value_is_omitted_in_containers(self) -> None: + value = self._get_json(f"/submodels/{_encode(TYPED_SUBMODEL_ID)}/$value") + self.assertNotIn("EmptyProperty", value) + + def test_submodel_element_list_keeps_indices(self) -> None: + value = self._get_json(f"/submodels/{_encode(TYPED_SUBMODEL_ID)}/submodel-elements/ListWithEmptyElement/$value") + self.assertEqual(["first", None, "third"], value) + + def test_multi_language_property_value(self) -> None: + value = self._get_json( + f"/submodels/{_encode(TEST_SUBMODEL_ID)}" + "/submodel-elements/ExampleSubmodelCollection.ExampleMultiLanguageProperty/$value" + ) + self.assertEqual(2, len(value)) + self.assertEqual([1, 1], [len(entry) for entry in value]) + self.assertEqual(["de", "en-US"], [next(iter(entry)) for entry in value]) + + def test_range_value(self) -> None: + value = self._get_json( + f"/submodels/{_encode(TEST_SUBMODEL_ID)}/submodel-elements/ExampleSubmodelCollection.ExampleRange/$value" + ) + self.assertEqual({"min": 0, "max": 100}, value) + + def test_blob_value(self) -> None: + value = self._get_json( + f"/submodels/{_encode(TEST_SUBMODEL_ID)}/submodel-elements/ExampleSubmodelCollection.ExampleBlob/$value" + ) + self.assertEqual( + {"contentType": "application/pdf", "value": base64.b64encode(b"\x01\x02\x03\x04\x05").decode()}, value + ) + + def test_file_value(self) -> None: + value = self._get_json( + f"/submodels/{_encode(TEST_SUBMODEL_ID)}/submodel-elements/ExampleSubmodelCollection.ExampleFile/$value" + ) + self.assertEqual({"contentType": "application/pdf", "value": "/TestFile.pdf"}, value) + + def test_reference_element_value(self) -> None: + value = self._get_json( + f"/submodels/{_encode(TEST_SUBMODEL_ID)}" + "/submodel-elements/ExampleSubmodelCollection.ExampleReferenceElement/$value" + ) + self.assertEqual("ModelReference", value["type"]) + self.assertEqual("Submodel", value["keys"][0]["type"]) + + def test_relationship_element_value(self) -> None: + value = self._get_json( + f"/submodels/{_encode(TEST_SUBMODEL_ID)}/submodel-elements/ExampleRelationshipElement/$value" + ) + self.assertEqual({"first", "second"}, set(value)) + self.assertEqual("ModelReference", value["first"]["type"]) + + def test_annotated_relationship_element_value(self) -> None: + value = self._get_json( + f"/submodels/{_encode(TEST_SUBMODEL_ID)}/submodel-elements/ExampleAnnotatedRelationshipElement/$value" + ) + self.assertEqual({"first", "second", "annotations"}, set(value)) + self.assertIn({"ExampleAnnotatedProperty": "exampleValue"}, value["annotations"]) + + def test_basic_event_element_value(self) -> None: + value = self._get_json( + f"/submodels/{_encode(TEST_SUBMODEL_ID)}/submodel-elements/ExampleBasicEventElement/$value" + ) + self.assertEqual(["observed"], list(value)) + self.assertEqual("ModelReference", value["observed"]["type"]) + + def test_entity_value(self) -> None: + value = self._get_json( + f"/submodels/{_encode('http://example.org/Submodels/Assets/TestAsset/BillOfMaterial')}" + "/submodel-elements/ExampleEntity/$value" + ) + self.assertEqual("SelfManagedEntity", value["entityType"]) + self.assertEqual("http://example.org/TestAsset/", value["globalAssetId"]) + self.assertEqual("exampleValue", value["statements"]["ExampleProperty"]) + self.assertEqual("TestKey", value["specificAssetIds"][0]["name"]) + + def test_submodel_element_collection_value(self) -> None: + value = self._get_json( + f"/submodels/{_encode(TEST_SUBMODEL_ID)}/submodel-elements/ExampleSubmodelCollection/$value" + ) + self.assertIn("ExampleBlob", value) + self.assertIn("ExampleSubmodelList", value) + + def test_operation_and_capability_are_rejected(self) -> None: + base_url = f"/submodels/{_encode(TEST_SUBMODEL_ID)}/submodel-elements" + self._get_json(f"{base_url}/ExampleOperation/$value", status_code=400) + self._get_json(f"{base_url}/ExampleCapability/$value", status_code=400) + + def test_unknown_submodel_element(self) -> None: + self._get_json(f"/submodels/{_encode(TEST_SUBMODEL_ID)}/submodel-elements/DoesNotExist/$value", status_code=404) + + # --------- GET /submodels/{submodelIdentifier}/submodel-elements/$value --------- + + def test_submodel_elements_value(self) -> None: + result = self._get_json(f"/submodels/{_encode(IDENTIFICATION_SUBMODEL_ID)}/submodel-elements/$value") + self.assertEqual( + [{"ManufacturerName": "ACPLT"}, {"InstanceId": "978-8234-234-342"}], + result["result"], + ) + + def test_submodel_elements_value_omits_operations_and_capabilities(self) -> None: + result = self._get_json(f"/submodels/{_encode(TEST_SUBMODEL_ID)}/submodel-elements/$value") + id_shorts = [next(iter(entry)) for entry in result["result"]] + self.assertNotIn("ExampleOperation", id_shorts) + self.assertNotIn("ExampleCapability", id_shorts) + + def test_submodel_elements_value_pagination(self) -> None: + result = self._get_json(f"/submodels/{_encode(IDENTIFICATION_SUBMODEL_ID)}/submodel-elements/$value?limit=1") + self.assertEqual([{"ManufacturerName": "ACPLT"}], result["result"]) + self.assertEqual("2", result["paging_metadata"]["cursor"]) + + # --------- GET /submodels/$value --------- + + def test_submodel_all_value(self) -> None: + result = self._get_json("/submodels/$value") + self.assertIn({"ManufacturerName": "ACPLT", "InstanceId": "978-8234-234-342"}, result["result"]) + + def test_submodel_all_value_pagination(self) -> None: + result = self._get_json("/submodels/$value?limit=1") + self.assertEqual(1, len(result["result"])) + self.assertEqual("2", result["paging_metadata"]["cursor"]) + + # --------- serialization modifiers and content negotiation --------- + + def test_xml_is_not_acceptable(self) -> None: + response = self.client.get( + f"{BASE_PATH}/submodels/{_encode(TEST_SUBMODEL_ID)}/$value", headers={"Accept": "application/xml"} + ) + self.assertEqual(406, response.status_code) + + def test_extent_is_not_implemented(self) -> None: + response = self.client.get(f"{BASE_PATH}/submodels/{_encode(TEST_SUBMODEL_ID)}/$value?extent=withBlobValue") + self.assertEqual(501, response.status_code) + + def test_invalid_level(self) -> None: + response = self.client.get(f"{BASE_PATH}/submodels/{_encode(TEST_SUBMODEL_ID)}/$value?level=invalid") + self.assertEqual(400, response.status_code) + + +class ValueOnlyEmptyStoreTest(unittest.TestCase): + def test_empty_submodel(self) -> None: + object_store: DictIdentifiableStore = DictIdentifiableStore() + object_store.add(model.Submodel(id_=TYPED_SUBMODEL_ID)) + client = Client(WSGIApp(object_store, DictSupplementaryFileContainer())) + response = client.get(f"{BASE_PATH}/submodels/{_encode(TYPED_SUBMODEL_ID)}/$value") + self.assertEqual(200, response.status_code) + self.assertEqual({}, json.loads(response.data)) + + +if __name__ == "__main__": + unittest.main() From a1480a0d18501bf6401023d35bba5cab63c43ace Mon Sep 17 00:00:00 2001 From: hpoeche Date: Fri, 9 Oct 2026 17:27:40 +0200 Subject: [PATCH 02/22] Integrate /$value endpoint tests with existing tests --- .../interfaces/repository/test_submodels.py | 329 ++++++++++++++++++ server/test/interfaces/test_value_only.py | 264 -------------- 2 files changed, 329 insertions(+), 264 deletions(-) delete mode 100644 server/test/interfaces/test_value_only.py diff --git a/server/test/interfaces/repository/test_submodels.py b/server/test/interfaces/repository/test_submodels.py index 622b80600..4d425d5a8 100644 --- a/server/test/interfaces/repository/test_submodels.py +++ b/server/test/interfaces/repository/test_submodels.py @@ -5,13 +5,17 @@ # # SPDX-License-Identifier: MIT +import base64 import io import json +import math +from typing import Any from unittest import mock from app.util.converters import base64url_encode from basyx.aas import model from basyx.aas.adapter.json import AASToJsonEncoder +from basyx.aas.examples.data.example_aas import create_example_bill_of_material_submodel from basyx.aas.examples.data.example_aas_missing_attributes import create_example_submodel from ..format_utils import ( @@ -32,6 +36,49 @@ def _encode_reference(reference: model.Reference) -> str: (model.Key(model.KeyTypes.GLOBAL_REFERENCE, "http://example.org/SubmodelTemplates/ExampleSubmodel"),) ) +# Top-level submodel elements of ``create_example_submodel()`` that have a ValueOnly representation, in document +# order. ExampleOperation and ExampleCapability have none and are omitted by the ``$value`` routes. +VALUE_ONLY_ID_SHORTS = ( + "ExampleRelationshipElement", + "ExampleAnnotatedRelationshipElement", + "ExampleBasicEventElement", + "ExampleSubmodelCollection", +) + +TYPED_SUBMODEL_ID = "http://example.org/Typed_Submodel" + + +def create_typed_submodel() -> model.Submodel: + """ + A Submodel containing the value types and edge cases of the ValueOnly serialization that aren't part of the + example data. + """ + return model.Submodel( + id_=TYPED_SUBMODEL_ID, + submodel_element=( + model.Property(id_short="IntProperty", value_type=model.datatypes.Int, value=42), + model.Property(id_short="BoolProperty", value_type=model.datatypes.Boolean, value=False), + model.Property(id_short="DoubleProperty", value_type=model.datatypes.Double, value=1.5), + model.Property(id_short="NanProperty", value_type=model.datatypes.Double, value=math.nan), + # xs:integer is unbounded, this value cannot be represented as a float + model.Property(id_short="HugeIntProperty", value_type=model.datatypes.Integer, value=10**400), + model.Property( + id_short="DateProperty", value_type=model.datatypes.Date, value=model.datatypes.Date(2026, 8, 31) + ), + model.Property(id_short="EmptyProperty", value_type=model.datatypes.String, value=None), + model.SubmodelElementList( + id_short="ListWithEmptyElement", + type_value_list_element=model.Property, + value_type_list_element=model.datatypes.String, + value=( + model.Property(id_short=None, value_type=model.datatypes.String, value="first"), + model.Property(id_short=None, value_type=model.datatypes.String, value=None), + model.Property(id_short=None, value_type=model.datatypes.String, value="third"), + ), + ), + ), + ) + @inject_format_clients class SubmodelsEndpointsTest(RepositoryEndpointTestBase): @@ -214,6 +261,28 @@ def test_submodels_reference_get_supports_pagination(self, format_client: Format self.assertEqual([1, 1], [len(page) for page in pages]) + # ------------------------------------------------------------------ GET /submodels/$value + + @with_json_client + def test_submodels_value_get(self, format_client: FormatClient): + self.object_store.update(self.two_submodels_store()) + + response = format_client.get("/submodels/$value") + + self.assert_ok(response) + values = format_client.parse_collection(response) + self.assertEqual(2, len(values)) + for value in values: + self.assertEqual(set(VALUE_ONLY_ID_SHORTS), set(value)) + + @with_json_client + def test_submodels_value_get_supports_pagination(self, format_client: FormatClient): + self.object_store.update(self.two_submodels_store()) + + pages = format_client.get_paginated("/submodels/$value", limit=1, max_pages=2) + + self.assertEqual([1, 1], [len(page) for page in pages]) + # ------------------------------------------------------------------ GET /submodels/ @with_json_client @@ -356,6 +425,99 @@ def test_submodel_metadata_get_not_found(self): self.assert_error(response, 404) + # ------------------------------------------------------------------ GET /submodels//$value + + @with_json_client + def test_submodel_value_get(self, format_client: FormatClient): + example_submodel = create_example_submodel() + self.object_store.add(example_submodel) + + response = format_client.get(f"/submodels/{base64url_encode(example_submodel.id)}/$value") + + self.assert_ok(response) + value = format_client.parse_object(response) + # ExampleOperation and ExampleCapability have no ValueOnly representation and are omitted + self.assertEqual(set(VALUE_ONLY_ID_SHORTS), set(value)) + self.assertEqual("exampleValue", value["ExampleSubmodelCollection"]["ExampleProperty"]) + + @with_json_client + def test_submodel_value_get_level_core_empties_nested_containers(self, format_client: FormatClient): + example_submodel = create_example_submodel() + self.object_store.add(example_submodel) + + response = format_client.get(f"/submodels/{base64url_encode(example_submodel.id)}/$value?level=core") + + self.assert_ok(response) + value = format_client.parse_object(response) + # the direct children are present, their children are not + self.assertEqual({}, value["ExampleSubmodelCollection"]) + self.assertEqual([], value["ExampleAnnotatedRelationshipElement"]["annotations"]) + + @with_json_client + def test_submodel_value_get_empty_submodel(self, format_client: FormatClient): + self.object_store.add(model.Submodel(id_=TYPED_SUBMODEL_ID)) + + response = format_client.get(f"/submodels/{base64url_encode(TYPED_SUBMODEL_ID)}/$value") + + self.assert_ok(response) + self.assertEqual({}, format_client.parse_object(response)) + + @with_json_client + def test_submodel_value_get_huge_integer(self, format_client: FormatClient): + self.object_store.add(create_typed_submodel()) + + response = format_client.get(f"/submodels/{base64url_encode(TYPED_SUBMODEL_ID)}/$value") + + self.assert_ok(response) + value = format_client.parse_object(response) + # integers that exceed the range of a float are also serialized when the whole Submodel is requested + self.assertEqual(10**400, value["HugeIntProperty"]) + self.assertEqual(42, value["IntProperty"]) + + @with_json_client + def test_submodel_value_get_omits_property_without_value(self, format_client: FormatClient): + self.object_store.add(create_typed_submodel()) + + response = format_client.get(f"/submodels/{base64url_encode(TYPED_SUBMODEL_ID)}/$value") + + self.assert_ok(response) + self.assertNotIn("EmptyProperty", format_client.parse_object(response)) + + @with_json_client + def test_submodel_value_get_not_found(self, format_client: FormatClient): + response = format_client.get(f"/submodels/{base64url_encode('https://example.org/unknown')}/$value") + + self.assert_error(response, 404) + + @with_xml_client + def test_submodel_value_get_xml_not_acceptable(self, format_client: FormatClient): + example_submodel = create_example_submodel() + self.object_store.add(example_submodel) + + response = format_client.get(f"/submodels/{base64url_encode(example_submodel.id)}/$value") + + self.assert_error(response, 406) + + @with_json_client + def test_submodel_value_get_invalid_level_returns_400(self, format_client: FormatClient): + example_submodel = create_example_submodel() + self.object_store.add(example_submodel) + + response = format_client.get(f"/submodels/{base64url_encode(example_submodel.id)}/$value?level=invalid") + + self.assert_error(response, 400) + + @with_json_client + def test_submodel_value_get_extent_not_implemented(self, format_client: FormatClient): + example_submodel = create_example_submodel() + self.object_store.add(example_submodel) + + response = format_client.get( + f"/submodels/{base64url_encode(example_submodel.id)}/$value?extent=withBlobValue" + ) + + self.assert_error(response, 501) + # ------------------------------------------------------------------ GET /submodels//$reference @with_json_client @@ -443,6 +605,11 @@ def _nested_blob(self, submodel: model.Submodel, id_short_path: str) -> model.Bl assert isinstance(element, model.Blob) return element + def _get_element_value(self, format_client: FormatClient, submodel_id: str, id_short_path: str) -> Any: + response = format_client.get(f"{self.elements_path(submodel_id, id_short_path)}/$value") + self.assert_ok(response) + return format_client.parse_object(response) + # ------------------------------------------------------------------ GET .../submodel-elements @with_json_client @@ -570,6 +737,29 @@ def test_submodel_elements_reference_get_supports_pagination(self, format_client self.assertEqual([3, 3], [len(page) for page in pages]) + # ------------------------------------------------------------------ GET .../submodel-elements/$value + + @with_json_client + def test_submodel_elements_value_get(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + response = format_client.get(f"{self.elements_path(submodel.id)}/$value") + + self.assert_ok(response) + values = format_client.parse_collection(response) + # each element is an {idShort: value} object, ExampleOperation and ExampleCapability are omitted + self.assertEqual(list(VALUE_ONLY_ID_SHORTS), [id_short for value in values for id_short in value]) + + @with_json_client + def test_submodel_elements_value_get_supports_pagination(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + pages = format_client.get_paginated(f"{self.elements_path(submodel.id)}/$value", limit=3, max_pages=2) + + self.assertEqual( + list(VALUE_ONLY_ID_SHORTS), [id_short for page in pages for value in page for id_short in value] + ) + # ------------------------------------------------------------------ GET .../submodel-elements/ @with_json_client @@ -781,6 +971,145 @@ def test_submodel_element_reference_get_not_found(self, format_client: FormatCli self.assert_error(response, 404) + # ------------------------------------------------------------------ GET ...//$value + + @with_json_client + def test_submodel_element_value_get_property_value_types(self, format_client: FormatClient): + self.object_store.add(create_typed_submodel()) + + def get_value(id_short_path: str) -> Any: + return self._get_element_value(format_client, TYPED_SUBMODEL_ID, id_short_path) + + self.assertEqual(42, get_value("IntProperty")) + self.assertEqual(False, get_value("BoolProperty")) + self.assertEqual(1.5, get_value("DoubleProperty")) + # JSON has no representation for NaN, INF and -INF, so the XSD representation is returned instead + self.assertEqual("NaN", get_value("NanProperty")) + # integers are not converted to float, as that would raise an OverflowError for large values + self.assertEqual(10**400, get_value("HugeIntProperty")) + self.assertEqual("2026-08-31", get_value("DateProperty")) + self.assertIsNone(get_value("EmptyProperty")) + + @with_json_client + def test_submodel_element_value_get_list_keeps_indices(self, format_client: FormatClient): + self.object_store.add(create_typed_submodel()) + + value = self._get_element_value(format_client, TYPED_SUBMODEL_ID, "ListWithEmptyElement") + + self.assertEqual(["first", None, "third"], value) + + @with_json_client + def test_submodel_element_value_get_multi_language_property(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + value = self._get_element_value( + format_client, submodel.id, f"{NESTED_COLLECTION}.ExampleMultiLanguageProperty" + ) + + self.assertEqual(2, len(value)) + self.assertEqual([1, 1], [len(entry) for entry in value]) + self.assertEqual({"de", "en-US"}, {next(iter(entry)) for entry in value}) + + @with_json_client + def test_submodel_element_value_get_range(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + value = self._get_element_value(format_client, submodel.id, f"{NESTED_COLLECTION}.ExampleRange") + + self.assertEqual({"min": 0, "max": 100}, value) + + @with_json_client + def test_submodel_element_value_get_blob(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + value = self._get_element_value(format_client, submodel.id, NESTED_BLOB) + + self.assertEqual( + {"contentType": "application/pdf", "value": base64.b64encode(b"\x01\x02\x03\x04\x05").decode()}, value + ) + + @with_json_client + def test_submodel_element_value_get_file(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + value = self._get_element_value(format_client, submodel.id, NESTED_FILE) + + self.assertEqual({"contentType": "application/pdf", "value": "/TestFile.pdf"}, value) + + @with_json_client + def test_submodel_element_value_get_reference_element(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + value = self._get_element_value(format_client, submodel.id, f"{NESTED_COLLECTION}.ExampleReferenceElement") + + self.assertEqual("ModelReference", value["type"]) + self.assertEqual("Submodel", value["keys"][0]["type"]) + + @with_json_client + def test_submodel_element_value_get_relationship_element(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + value = self._get_element_value(format_client, submodel.id, "ExampleRelationshipElement") + + self.assertEqual({"first", "second"}, set(value)) + self.assertEqual("ModelReference", value["first"]["type"]) + + @with_json_client + def test_submodel_element_value_get_annotated_relationship_element(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + value = self._get_element_value(format_client, submodel.id, "ExampleAnnotatedRelationshipElement") + + self.assertEqual({"first", "second", "annotations"}, set(value)) + self.assertIn({"ExampleAnnotatedProperty": "exampleValue"}, value["annotations"]) + + @with_json_client + def test_submodel_element_value_get_basic_event_element(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + value = self._get_element_value(format_client, submodel.id, "ExampleBasicEventElement") + + self.assertEqual(["observed"], list(value)) + self.assertEqual("ModelReference", value["observed"]["type"]) + + @with_json_client + def test_submodel_element_value_get_entity(self, format_client: FormatClient): + submodel = create_example_bill_of_material_submodel() + self.object_store.add(submodel) + + value = self._get_element_value(format_client, submodel.id, "ExampleEntity") + + self.assertEqual("SelfManagedEntity", value["entityType"]) + self.assertEqual("http://example.org/TestAsset/", value["globalAssetId"]) + self.assertEqual("exampleValue", value["statements"]["ExampleProperty"]) + self.assertEqual("TestKey", value["specificAssetIds"][0]["name"]) + + @with_json_client + def test_submodel_element_value_get_collection(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + value = self._get_element_value(format_client, submodel.id, NESTED_COLLECTION) + + self.assertIn("ExampleBlob", value) + self.assertEqual("exampleValue", value["ExampleProperty"]) + + @with_json_client + def test_submodel_element_value_get_rejects_operation_and_capability(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + for id_short in ("ExampleOperation", "ExampleCapability"): + with self.subTest(id_short=id_short): + response = format_client.get(f"{self.elements_path(submodel.id, id_short)}/$value") + self.assert_error(response, 400) + + @with_json_client + def test_submodel_element_value_get_not_found(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + response = format_client.get(f"{self.elements_path(submodel.id, 'DoesNotExist')}/$value") + + self.assert_error(response, 404) + # ------------------------------------------------------------------ ...//attachment def test_submodel_element_attachment_get_blob(self): diff --git a/server/test/interfaces/test_value_only.py b/server/test/interfaces/test_value_only.py deleted file mode 100644 index 391df86bd..000000000 --- a/server/test/interfaces/test_value_only.py +++ /dev/null @@ -1,264 +0,0 @@ -# Copyright (c) 2026 the Eclipse BaSyx Authors -# -# This program and the accompanying materials are made available under the terms of the MIT License, available in -# the LICENSE file of this project. -# -# SPDX-License-Identifier: MIT - -import base64 -import json -import math -import unittest -from typing import Any - -from app.interfaces.repository import WSGIApp -from basyx.aas import model -from basyx.aas.adapter.aasx import DictSupplementaryFileContainer -from basyx.aas.examples.data.example_aas import create_full_example -from basyx.aas.model import DictIdentifiableStore -from werkzeug.test import Client - -BASE_PATH = "/api/v3.1" -TEST_SUBMODEL_ID = "https://example.org/Test_Submodel" -IDENTIFICATION_SUBMODEL_ID = "http://example.org/Submodels/Assets/TestAsset/Identification" -TYPED_SUBMODEL_ID = "http://example.org/Typed_Submodel" - - -def _encode(identifier: str) -> str: - return base64.urlsafe_b64encode(identifier.encode()).decode() - - -def _create_typed_submodel() -> model.Submodel: - """ - A Submodel containing the value types and edge cases that aren't part of the example data. - """ - return model.Submodel( - id_=TYPED_SUBMODEL_ID, - submodel_element=( - model.Property(id_short="IntProperty", value_type=model.datatypes.Int, value=42), - model.Property(id_short="BoolProperty", value_type=model.datatypes.Boolean, value=False), - model.Property(id_short="DoubleProperty", value_type=model.datatypes.Double, value=1.5), - model.Property(id_short="NanProperty", value_type=model.datatypes.Double, value=math.nan), - # xs:integer is unbounded, this value cannot be represented as a float - model.Property(id_short="HugeIntProperty", value_type=model.datatypes.Integer, value=10**400), - model.Property( - id_short="DateProperty", value_type=model.datatypes.Date, value=model.datatypes.Date(2026, 8, 31) - ), - model.Property(id_short="EmptyProperty", value_type=model.datatypes.String, value=None), - model.SubmodelElementList( - id_short="ListWithEmptyElement", - type_value_list_element=model.Property, - value_type_list_element=model.datatypes.String, - value=( - model.Property(id_short=None, value_type=model.datatypes.String, value="first"), - model.Property(id_short=None, value_type=model.datatypes.String, value=None), - model.Property(id_short=None, value_type=model.datatypes.String, value="third"), - ), - ), - ), - ) - - -class ValueOnlyTest(unittest.TestCase): - def setUp(self) -> None: - self.example_data = create_full_example() - self.example_data.add(_create_typed_submodel()) - self.client = Client(WSGIApp(self.example_data, DictSupplementaryFileContainer())) - - def _get_json(self, url: str, status_code: int = 200, **kwargs: Any) -> Any: - response = self.client.get(f"{BASE_PATH}{url}", **kwargs) - self.assertEqual(status_code, response.status_code, response.data) - return json.loads(response.data) - - # --------- GET /submodels/{submodelIdentifier}/$value --------- - - def test_submodel_value(self) -> None: - value = self._get_json(f"/submodels/{_encode(IDENTIFICATION_SUBMODEL_ID)}/$value") - self.assertEqual({"ManufacturerName": "ACPLT", "InstanceId": "978-8234-234-342"}, value) - - def test_submodel_value_omits_operations_and_capabilities(self) -> None: - value = self._get_json(f"/submodels/{_encode(TEST_SUBMODEL_ID)}/$value") - self.assertNotIn("ExampleOperation", value) - self.assertNotIn("ExampleCapability", value) - - def test_submodel_value_level_core_empties_nested_containers(self) -> None: - value = self._get_json(f"/submodels/{_encode(TEST_SUBMODEL_ID)}/$value?level=core") - # the direct children are present, their children are not - self.assertIn("ExampleSubmodelCollection", value) - self.assertEqual({}, value["ExampleSubmodelCollection"]) - self.assertEqual([], value["ExampleAnnotatedRelationshipElement"]["annotations"]) - - def test_submodel_value_unknown_submodel(self) -> None: - self._get_json(f"/submodels/{_encode('http://example.org/does_not_exist')}/$value", status_code=404) - - # --------- GET /submodels/{submodelIdentifier}/submodel-elements/{idShortPath}/$value --------- - - def test_property_value_types(self) -> None: - base_url = f"/submodels/{_encode(TYPED_SUBMODEL_ID)}/submodel-elements" - self.assertEqual(42, self._get_json(f"{base_url}/IntProperty/$value")) - self.assertEqual(False, self._get_json(f"{base_url}/BoolProperty/$value")) - self.assertEqual(1.5, self._get_json(f"{base_url}/DoubleProperty/$value")) - # JSON has no representation for NaN, INF and -INF, so the XSD representation is returned instead - self.assertEqual("NaN", self._get_json(f"{base_url}/NanProperty/$value")) - # integers are not converted to float, as that would raise an OverflowError for large values - self.assertEqual(10**400, self._get_json(f"{base_url}/HugeIntProperty/$value")) - self.assertEqual("2026-08-31", self._get_json(f"{base_url}/DateProperty/$value")) - self.assertIsNone(self._get_json(f"{base_url}/EmptyProperty/$value")) - - def test_submodel_value_with_huge_integer(self) -> None: - # integers that exceed the range of a float are also serialized when the whole Submodel is requested - value = self._get_json(f"/submodels/{_encode(TYPED_SUBMODEL_ID)}/$value") - self.assertEqual(10**400, value["HugeIntProperty"]) - self.assertEqual(42, value["IntProperty"]) - - def test_property_without_value_is_omitted_in_containers(self) -> None: - value = self._get_json(f"/submodels/{_encode(TYPED_SUBMODEL_ID)}/$value") - self.assertNotIn("EmptyProperty", value) - - def test_submodel_element_list_keeps_indices(self) -> None: - value = self._get_json(f"/submodels/{_encode(TYPED_SUBMODEL_ID)}/submodel-elements/ListWithEmptyElement/$value") - self.assertEqual(["first", None, "third"], value) - - def test_multi_language_property_value(self) -> None: - value = self._get_json( - f"/submodels/{_encode(TEST_SUBMODEL_ID)}" - "/submodel-elements/ExampleSubmodelCollection.ExampleMultiLanguageProperty/$value" - ) - self.assertEqual(2, len(value)) - self.assertEqual([1, 1], [len(entry) for entry in value]) - self.assertEqual(["de", "en-US"], [next(iter(entry)) for entry in value]) - - def test_range_value(self) -> None: - value = self._get_json( - f"/submodels/{_encode(TEST_SUBMODEL_ID)}/submodel-elements/ExampleSubmodelCollection.ExampleRange/$value" - ) - self.assertEqual({"min": 0, "max": 100}, value) - - def test_blob_value(self) -> None: - value = self._get_json( - f"/submodels/{_encode(TEST_SUBMODEL_ID)}/submodel-elements/ExampleSubmodelCollection.ExampleBlob/$value" - ) - self.assertEqual( - {"contentType": "application/pdf", "value": base64.b64encode(b"\x01\x02\x03\x04\x05").decode()}, value - ) - - def test_file_value(self) -> None: - value = self._get_json( - f"/submodels/{_encode(TEST_SUBMODEL_ID)}/submodel-elements/ExampleSubmodelCollection.ExampleFile/$value" - ) - self.assertEqual({"contentType": "application/pdf", "value": "/TestFile.pdf"}, value) - - def test_reference_element_value(self) -> None: - value = self._get_json( - f"/submodels/{_encode(TEST_SUBMODEL_ID)}" - "/submodel-elements/ExampleSubmodelCollection.ExampleReferenceElement/$value" - ) - self.assertEqual("ModelReference", value["type"]) - self.assertEqual("Submodel", value["keys"][0]["type"]) - - def test_relationship_element_value(self) -> None: - value = self._get_json( - f"/submodels/{_encode(TEST_SUBMODEL_ID)}/submodel-elements/ExampleRelationshipElement/$value" - ) - self.assertEqual({"first", "second"}, set(value)) - self.assertEqual("ModelReference", value["first"]["type"]) - - def test_annotated_relationship_element_value(self) -> None: - value = self._get_json( - f"/submodels/{_encode(TEST_SUBMODEL_ID)}/submodel-elements/ExampleAnnotatedRelationshipElement/$value" - ) - self.assertEqual({"first", "second", "annotations"}, set(value)) - self.assertIn({"ExampleAnnotatedProperty": "exampleValue"}, value["annotations"]) - - def test_basic_event_element_value(self) -> None: - value = self._get_json( - f"/submodels/{_encode(TEST_SUBMODEL_ID)}/submodel-elements/ExampleBasicEventElement/$value" - ) - self.assertEqual(["observed"], list(value)) - self.assertEqual("ModelReference", value["observed"]["type"]) - - def test_entity_value(self) -> None: - value = self._get_json( - f"/submodels/{_encode('http://example.org/Submodels/Assets/TestAsset/BillOfMaterial')}" - "/submodel-elements/ExampleEntity/$value" - ) - self.assertEqual("SelfManagedEntity", value["entityType"]) - self.assertEqual("http://example.org/TestAsset/", value["globalAssetId"]) - self.assertEqual("exampleValue", value["statements"]["ExampleProperty"]) - self.assertEqual("TestKey", value["specificAssetIds"][0]["name"]) - - def test_submodel_element_collection_value(self) -> None: - value = self._get_json( - f"/submodels/{_encode(TEST_SUBMODEL_ID)}/submodel-elements/ExampleSubmodelCollection/$value" - ) - self.assertIn("ExampleBlob", value) - self.assertIn("ExampleSubmodelList", value) - - def test_operation_and_capability_are_rejected(self) -> None: - base_url = f"/submodels/{_encode(TEST_SUBMODEL_ID)}/submodel-elements" - self._get_json(f"{base_url}/ExampleOperation/$value", status_code=400) - self._get_json(f"{base_url}/ExampleCapability/$value", status_code=400) - - def test_unknown_submodel_element(self) -> None: - self._get_json(f"/submodels/{_encode(TEST_SUBMODEL_ID)}/submodel-elements/DoesNotExist/$value", status_code=404) - - # --------- GET /submodels/{submodelIdentifier}/submodel-elements/$value --------- - - def test_submodel_elements_value(self) -> None: - result = self._get_json(f"/submodels/{_encode(IDENTIFICATION_SUBMODEL_ID)}/submodel-elements/$value") - self.assertEqual( - [{"ManufacturerName": "ACPLT"}, {"InstanceId": "978-8234-234-342"}], - result["result"], - ) - - def test_submodel_elements_value_omits_operations_and_capabilities(self) -> None: - result = self._get_json(f"/submodels/{_encode(TEST_SUBMODEL_ID)}/submodel-elements/$value") - id_shorts = [next(iter(entry)) for entry in result["result"]] - self.assertNotIn("ExampleOperation", id_shorts) - self.assertNotIn("ExampleCapability", id_shorts) - - def test_submodel_elements_value_pagination(self) -> None: - result = self._get_json(f"/submodels/{_encode(IDENTIFICATION_SUBMODEL_ID)}/submodel-elements/$value?limit=1") - self.assertEqual([{"ManufacturerName": "ACPLT"}], result["result"]) - self.assertEqual("2", result["paging_metadata"]["cursor"]) - - # --------- GET /submodels/$value --------- - - def test_submodel_all_value(self) -> None: - result = self._get_json("/submodels/$value") - self.assertIn({"ManufacturerName": "ACPLT", "InstanceId": "978-8234-234-342"}, result["result"]) - - def test_submodel_all_value_pagination(self) -> None: - result = self._get_json("/submodels/$value?limit=1") - self.assertEqual(1, len(result["result"])) - self.assertEqual("2", result["paging_metadata"]["cursor"]) - - # --------- serialization modifiers and content negotiation --------- - - def test_xml_is_not_acceptable(self) -> None: - response = self.client.get( - f"{BASE_PATH}/submodels/{_encode(TEST_SUBMODEL_ID)}/$value", headers={"Accept": "application/xml"} - ) - self.assertEqual(406, response.status_code) - - def test_extent_is_not_implemented(self) -> None: - response = self.client.get(f"{BASE_PATH}/submodels/{_encode(TEST_SUBMODEL_ID)}/$value?extent=withBlobValue") - self.assertEqual(501, response.status_code) - - def test_invalid_level(self) -> None: - response = self.client.get(f"{BASE_PATH}/submodels/{_encode(TEST_SUBMODEL_ID)}/$value?level=invalid") - self.assertEqual(400, response.status_code) - - -class ValueOnlyEmptyStoreTest(unittest.TestCase): - def test_empty_submodel(self) -> None: - object_store: DictIdentifiableStore = DictIdentifiableStore() - object_store.add(model.Submodel(id_=TYPED_SUBMODEL_ID)) - client = Client(WSGIApp(object_store, DictSupplementaryFileContainer())) - response = client.get(f"{BASE_PATH}/submodels/{_encode(TYPED_SUBMODEL_ID)}/$value") - self.assertEqual(200, response.status_code) - self.assertEqual({}, json.loads(response.data)) - - -if __name__ == "__main__": - unittest.main() From bff5dc70a1744ff23e73f7d694f968ef3a01e9d3 Mon Sep 17 00:00:00 2001 From: hpoeche Date: Sat, 10 Oct 2026 09:55:10 +0200 Subject: [PATCH 03/22] Rework ValueOnly serialization as `JSONEncoder` subclass Previously, the ValueOnly serialization was method based in the server package. These changes shift the code from serve package to the sdk, as ValueOnly serialiaztion is defined in Part 1 of the spec. Additionally, I restructured the code to live inside a `JSONEncoder` subclass, so it can be used the same way as `AASToJsonEncoder`. Still TODO: - Implement `level=core` serialization - Integrate this serialiazer into the server --- .../adapter/json/value_only_serialization.py | 179 +++++++++++ .../json/test_value_only_serialization.py | 285 ++++++++++++++++++ 2 files changed, 464 insertions(+) create mode 100644 sdk/basyx/aas/adapter/json/value_only_serialization.py create mode 100644 sdk/test/adapter/json/test_value_only_serialization.py diff --git a/sdk/basyx/aas/adapter/json/value_only_serialization.py b/sdk/basyx/aas/adapter/json/value_only_serialization.py new file mode 100644 index 000000000..1699eb483 --- /dev/null +++ b/sdk/basyx/aas/adapter/json/value_only_serialization.py @@ -0,0 +1,179 @@ +# Copyright (c) 2026 the Eclipse BaSyx Authors +# +# This program and the accompanying materials are made available under the terms of the MIT License, available in +# the LICENSE file of this project. +# +# SPDX-License-Identifier: MIT +import base64 +import math +from typing import Callable, Iterable, Union + +from basyx.aas import model +from basyx.aas.adapter import _generic +from basyx.aas.adapter.json import AASToJsonEncoder + +TYPES_WITHOUT_VALUE_ONLY = (model.Capability, model.Operation) + +class AASToValueOnlyJsonEncoder(AASToJsonEncoder): + + @classmethod + def _get_aas_class_serializers(cls) -> dict[type, Callable]: + serializers = super()._get_aas_class_serializers() + + serializers.update( + { + model.Submodel: cls._submodel_to_value_only, + model.SubmodelElementList: cls._submodel_element_list_to_value_only, + model.SubmodelElementCollection: cls._submodel_element_collection_to_value_only, + model.Property: cls._property_to_value_only, + model.MultiLanguageProperty: cls._multi_language_property_to_value_only, + model.Range: cls._range_to_value_only, + model.File: cls._file_or_blob_to_value_only, + model.Blob: cls._file_or_blob_to_value_only, + model.ReferenceElement: cls._reference_element_to_value_only, + model.RelationshipElement: cls._relationship_element_to_value_only, + model.AnnotatedRelationshipElement: cls._annotated_relationship_element_to_value_only, + model.Entity: cls._entity_to_value_only, + model.BasicEventElement: cls._basic_event_element_to_value_only, + } + ) + # Capability and Operation have no ValueOnly representation + for key in TYPES_WITHOUT_VALUE_ONLY: + serializers.pop(key, None) + + return serializers + + @classmethod + def _has_value(cls, obj: model.SubmodelElement) -> bool: + if isinstance(obj, TYPES_WITHOUT_VALUE_ONLY): + return False + if isinstance(obj, model.Property): + return obj.value is not None + if isinstance(obj, model.MultiLanguageProperty): + return bool(obj.value) + if isinstance(obj, model.Range): + return obj.min is not None or obj.max is not None + if isinstance(obj, model.File): + return obj.value is not None and obj.content_type is not None + if isinstance(obj, model.Blob): + return obj.content_type is not None + if isinstance(obj, model.ReferenceElement): + return obj.value is not None + if isinstance(obj, model.RelationshipElement): + return obj.first is not None or obj.second is not None + if isinstance(obj, model.Entity): + return obj.global_asset_id is not None or bool(obj.specific_asset_id) + if isinstance(obj, (model.SubmodelElementList, model.SubmodelElementCollection)): + return any(cls._has_value(element) for element in obj.value) + return True + + @classmethod + def _submodel_to_value_only(cls, obj: model.Submodel) -> dict[str, object]: + data: dict[str, object] = {} + + for element in obj.submodel_element: + if cls._has_value(element): + data[element.id_short] = element + + return data + + @classmethod + def _submodel_element_list_to_value_only( + cls, obj: model.SubmodelElementList + ) -> list[object]: + return [element for element in obj.value] + + @classmethod + def _submodel_element_iterable_to_value_only(cls, it: Iterable[model.SubmodelElement]) -> dict[str, object]: + return { + element.id_short: element + for element in it + if cls._has_value(element) and element.id_short is not None + } + + @classmethod + def _submodel_element_collection_to_value_only(cls, obj: model.SubmodelElementCollection) -> dict[str, object]: + data: dict[str, object] = cls._submodel_element_iterable_to_value_only(obj.value) + return data + + @classmethod + def _value_to_json_type(cls, value: model.datatypes.AnyXSDType) -> Union[bool, str, int, float]: + if isinstance(value, (bool, model.datatypes.Boolean)): + return bool(value) + if isinstance(value, int): + return value + if isinstance(value, (float, model.datatypes.Decimal)): + if not math.isfinite(float(value)): + return model.datatypes.xsd_repr(value) + return float(value) + return model.datatypes.xsd_repr(value) + + @classmethod + def _property_to_value_only(cls, obj: model.Property) -> object: + if obj.value is not None: + return cls._value_to_json_type(obj.value) + return None + + @classmethod + def _multi_language_property_to_value_only(cls, obj: model.MultiLanguageProperty) -> list[object]: + value = obj.value + if not value: + return [] + return [{language: text} for language, text in sorted(value.items())] + + @classmethod + def _range_to_value_only(cls, obj: model.Range) -> Union[object, dict[str, object]]: + data: dict[str, object] = {} + if obj.min is not None: + data["min"] = cls._value_to_json_type(obj.min) + if obj.max is not None: + data["max"] = cls._value_to_json_type(obj.max) + return data + + @classmethod + def _file_or_blob_to_value_only(cls, obj: Union[model.Blob, model.File]) -> dict[str, object]: + data: dict[str, object] = {} + data["contentType"] = obj.content_type + if obj.value is not None: + data["value"] = base64.b64encode(obj.value).decode() if isinstance(obj, model.Blob) else obj.value + return data + + @classmethod + def _reference_element_to_value_only(cls, obj: model.ReferenceElement) -> object: + return obj.value + + @classmethod + def _relationship_element_to_value_only(cls, obj: model.RelationshipElement) -> dict[str, object]: + data: dict[str, object] = {} + if obj.first is not None: + data["first"] = obj.first + if obj.second is not None: + data["second"] = obj.second + return data + + @classmethod + def _annotated_relationship_element_to_value_only( + cls, obj: model.AnnotatedRelationshipElement + ) -> dict[str, object]: + data = cls._relationship_element_to_value_only(obj) + if obj.annotation: + data["annotations"] = cls._submodel_element_iterable_to_value_only(obj.annotation) + return data + + @classmethod + def _entity_to_value_only(cls, obj: model.Entity) -> object: + data: dict[str, object] = {} + if obj.statement: + data["statements"] = cls._submodel_element_iterable_to_value_only(obj.statement) + if obj.global_asset_id: + data["globalAssetId"] = obj.global_asset_id + if obj.specific_asset_id: + data["specificAssetIds"] = list(obj.specific_asset_id) + if obj.entity_type: + data["entityType"] = _generic.ENTITY_TYPES[obj.entity_type] + return data + + @classmethod + def _basic_event_element_to_value_only(cls, obj: model.BasicEventElement) -> dict[str, object]: + data: dict[str, object] = {"observed": obj.observed} + return data diff --git a/sdk/test/adapter/json/test_value_only_serialization.py b/sdk/test/adapter/json/test_value_only_serialization.py new file mode 100644 index 000000000..085777ba5 --- /dev/null +++ b/sdk/test/adapter/json/test_value_only_serialization.py @@ -0,0 +1,285 @@ +import json +import unittest + +from basyx.aas import model +from basyx.aas.adapter.json.value_only_serialization import AASToValueOnlyJsonEncoder + + +class TestValueOnlyEncoder(unittest.TestCase): + def test_omits_empty(self): + sm = model.Submodel(id_="http://example.org/TestingSM", id_short="TestingSM") + + prop1 = model.Property(id_short="TestProperty1", value_type=model.datatypes.String, value="TestValue1") + prop2 = model.Property(id_short="TestProperty2", value_type=model.datatypes.Boolean, value=True) + collection1 = model.SubmodelElementCollection(id_short="TestCollection", value=[prop1, prop2]) + + prop3 = model.Property(id_short="TestProperty3", value_type=model.datatypes.String) + mlp = model.MultiLanguageProperty(id_short="TestMLP") + rng1 = model.Range(id_short="TestRange", value_type=model.datatypes.Int) + file = model.File(id_short="TestFile", content_type="application/pdf") + blob = model.Blob(id_short="TestBlob") + ref = model.ReferenceElement(id_short="TestRef") + rel = model.RelationshipElement(id_short="TestRelationship") + entity = model.Entity(id_short="TestEntity", entity_type=model.EntityType.CO_MANAGED_ENTITY) + + sm_list = model.SubmodelElementList( + id_short="EmptyList", + type_value_list_element=model.Property, + value_type_list_element=model.datatypes.String, + value=[prop3], + ) + collection2 = model.SubmodelElementCollection( + id_short="EmptyCollection", value=[mlp, rng1, file, blob, ref, rel, entity] + ) + + sm.submodel_element.add(collection1) + sm.submodel_element.add(collection2) + sm.submodel_element.add(sm_list) + + sm_str = json.dumps(sm, cls=AASToValueOnlyJsonEncoder) + + sm_dict = json.loads(sm_str) + self.assertIsInstance(sm_dict, dict) + self.assertNotIn("EmptyList", sm_dict) + self.assertNotIn("EmptyCollection", sm_dict) + + def test_property(self): + prop = model.Property(id_short="TestProperty", value_type=model.datatypes.Int, value=5000) + + out_str = json.dumps(prop, cls=AASToValueOnlyJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertEqual(5000, parsed_output) + + def test_submodel_element_collection(self): + prop1 = model.Property( + id_short="ProductClassificationSystem", value_type=model.datatypes.String, value="ECLASS" + ) + prop2 = model.Property(id_short="ProductOwnership", value_type=model.datatypes.Boolean, value=True) + collection = model.SubmodelElementCollection(id_short="TestCollection", value=[prop1, prop2]) + + out_str = json.dumps(collection, cls=AASToValueOnlyJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertIsInstance(parsed_output, dict) + self.assertEqual({"ProductClassificationSystem", "ProductOwnership"}, set(parsed_output.keys())) + self.assertEqual("ECLASS", parsed_output["ProductClassificationSystem"]) + self.assertEqual(True, parsed_output["ProductOwnership"]) + + def test_submodel_element_list(self): + prop1 = model.Property(id_short="Prop1", value_type=model.datatypes.String, value="Martha") + prop2 = model.Property(id_short="Prop2", value_type=model.datatypes.String, value="Jonathan") + prop3 = model.Property(id_short="Prop3", value_type=model.datatypes.String, value="Clark") + sme_list = model.SubmodelElementList( + id_short="Authors", + type_value_list_element=model.Property, + value_type_list_element=model.datatypes.String, + value=[prop1, prop2, prop3], + ) + + out_str = json.dumps(sme_list, cls=AASToValueOnlyJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertIsInstance(parsed_output, list) + self.assertEqual(["Martha", "Jonathan", "Clark"], parsed_output) + + def test_submodel_element_list_preserves_order(self): + prop1 = model.Property(id_short="Prop1", value_type=model.datatypes.String, value="Martha") + prop2 = model.Property(id_short="Prop2", value_type=model.datatypes.String) + prop3 = model.Property(id_short="Prop3", value_type=model.datatypes.String, value="Clark") + sme_list = model.SubmodelElementList( + id_short="Authors", + type_value_list_element=model.Property, + value_type_list_element=model.datatypes.String, + value=[prop1, prop2, prop3], + ) + + out_str = json.dumps(sme_list, cls=AASToValueOnlyJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertIsInstance(parsed_output, list) + self.assertEqual(["Martha", None, "Clark"], parsed_output) + + def test_multi_language_property(self): + mlp = model.MultiLanguageProperty( + id_short="MLP", + value=model.MultiLanguageTextType( + {"de": "Das ist ein deutscher Bezeichner", "en": "That's an English label"} + ), + ) + + out_str = json.dumps(mlp, cls=AASToValueOnlyJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertIsInstance(parsed_output, list) + self.assertEqual(2, len(parsed_output)) + self.assertIn({"de": "Das ist ein deutscher Bezeichner"}, parsed_output) + self.assertIn({"en": "That's an English label"}, parsed_output) + + def test_range(self): + rng = model.Range(id_short="TorqueRange", value_type=model.datatypes.Int, min=3, max=15) + + out_str = json.dumps(rng, cls=AASToValueOnlyJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertIsInstance(parsed_output, dict) + self.assertEqual({"min", "max"}, parsed_output.keys()) + self.assertEqual(3, parsed_output["min"]) + self.assertEqual(15, parsed_output["max"]) + + def test_reference_element(self): + ref = model.ReferenceElement( + id_short="MaxRotationSpeedReference", + value=model.ExternalReference( + key=(model.Key(type_=model.KeyTypes.GLOBAL_REFERENCE, value="0173-1#02-BAA120#008"),) + ), + ) + + out_str = json.dumps(ref, cls=AASToValueOnlyJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertIsInstance(parsed_output, dict) + self.assertEqual({"type", "keys"}, parsed_output.keys()) + self.assertIsInstance(parsed_output["keys"], list) + self.assertEqual(1, len(parsed_output["keys"])) + self.assertEqual({"type", "value"}, parsed_output["keys"][0].keys()) + self.assertEqual("GlobalReference", parsed_output["keys"][0]["type"]) + self.assertEqual("0173-1#02-BAA120#008", parsed_output["keys"][0]["value"]) + + def test_file(self): + file = model.File( + id_short="Document", content_type="application/pdf", value="https://example.org/SafetyInstructions.pdf" + ) + + out_str = json.dumps(file, cls=AASToValueOnlyJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertIsInstance(parsed_output, dict) + self.assertEqual({"contentType", "value"}, parsed_output.keys()) + self.assertEqual("application/pdf", parsed_output["contentType"]) + self.assertEqual("https://example.org/SafetyInstructions.pdf", parsed_output["value"]) + + def test_file_without_value(self): + file = model.File(id_short="Document", content_type="application/pdf") + + out_str = json.dumps(file, cls=AASToValueOnlyJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertIsInstance(parsed_output, dict) + self.assertEqual({"contentType"}, parsed_output.keys()) + self.assertEqual("application/pdf", parsed_output["contentType"]) + + def test_blob(self): + blob = model.Blob(id_short="Logo", content_type="application/octet-stream", value=b"\x00\x01BaSyx") + + out_str = json.dumps(blob, cls=AASToValueOnlyJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertIsInstance(parsed_output, dict) + self.assertEqual({"contentType", "value"}, parsed_output.keys()) + self.assertEqual("application/octet-stream", parsed_output["contentType"]) + self.assertEqual("AAFCYVN5eA==", parsed_output["value"]) + + def test_relationship_element(self): + rel = model.RelationshipElement( + id_short="CurrentFlowsFrom", + first=model.ModelReference( + type_=model.Property, + key=( + model.Key(type_=model.KeyTypes.SUBMODEL, value="http://customer.com/demo/aas/1/1/1234859590"), + model.Key(type_=model.KeyTypes.PROPERTY, value="PlusPole"), + ), + ), + second=model.ModelReference( + type_=model.Property, + key=( + model.Key(type_=model.KeyTypes.SUBMODEL, value="http://customer.com/demo/aas/1/0/1234859123490"), + model.Key(type_=model.KeyTypes.PROPERTY, value="MinusPole"), + ), + ), + ) + + out_str = json.dumps(rel, cls=AASToValueOnlyJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertIsInstance(parsed_output, dict) + self.assertEqual({"first", "second"}, parsed_output.keys()) + self.assertEqual("PlusPole", parsed_output["first"]["keys"][1]["value"]) + self.assertEqual("MinusPole", parsed_output["second"]["keys"][1]["value"]) + + def test_annotated_relationship_element(self): + anrel = model.AnnotatedRelationshipElement( + id_short="CurrentFlowsFrom", + first=model.ModelReference( + type_=model.Property, + key=( + model.Key(type_=model.KeyTypes.SUBMODEL, value="http://customer.com/demo/aas/1/1/1234859590"), + model.Key(type_=model.KeyTypes.PROPERTY, value="PlusPole"), + ), + ), + second=model.ModelReference( + type_=model.Property, + key=( + model.Key(type_=model.KeyTypes.SUBMODEL, value="http://customer.com/demo/aas/1/0/1234859123490"), + model.Key(type_=model.KeyTypes.PROPERTY, value="MinusPole"), + ), + ), + annotation=[ + model.Property( + id_short="AppliedRule", value_type=model.datatypes.String, value="TechnicalCurrentFlowDirection" + ) + ], + ) + + out_str = json.dumps(anrel, cls=AASToValueOnlyJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertIsInstance(parsed_output, dict) + self.assertEqual({"first", "second", "annotations"}, parsed_output.keys()) + self.assertEqual("PlusPole", parsed_output["first"]["keys"][1]["value"]) + self.assertEqual("MinusPole", parsed_output["second"]["keys"][1]["value"]) + self.assertIsInstance(parsed_output["annotations"], dict) + self.assertEqual(1, len(parsed_output["annotations"])) + self.assertEqual("TechnicalCurrentFlowDirection", parsed_output["annotations"]["AppliedRule"]) + + def test_entity(self): + entity = model.Entity( + id_short="MySubAssetEntity", + entity_type=model.EntityType.SELF_MANAGED_ENTITY, + global_asset_id="http://customer.com/demo/asset/1/1/MySubAsset", + specific_asset_id=[ + model.SpecificAssetId(name="SpecificAsset", value="http://customer.com/demo/asset/2/2/TestAsset") + ], + statement=[ + model.Property(id_short="MaxRotationSpeed", value_type=model.datatypes.Int, value=5000) + ] + ) + + out_str = json.dumps(entity, cls=AASToValueOnlyJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertIsInstance(parsed_output, dict) + self.assertEqual({"entityType", "globalAssetId", "specificAssetIds", "statements"}, parsed_output.keys()) + self.assertEqual(5000, parsed_output["statements"]["MaxRotationSpeed"]) + self.assertEqual("http://customer.com/demo/asset/1/1/MySubAsset", parsed_output["globalAssetId"]) + + def test_basic_event_element(self): + event = model.BasicEventElement( + id_short="MyBasicEvent", + observed=model.ModelReference( + type_=model.Property, + key=( + model.Key(type_=model.KeyTypes.SUBMODEL, value="http://customer.com/demo/aas/1/1/1234859590"), + model.Key(type_=model.KeyTypes.PROPERTY, value="MaxRotation"), + ) + ), + direction=model.Direction.INPUT, + state=model.StateOfEvent.ON + ) + + out_str = json.dumps(event, cls=AASToValueOnlyJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertIsInstance(parsed_output, dict) + self.assertEqual({"observed"}, parsed_output.keys()) + self.assertEqual("MaxRotation", parsed_output["observed"]["keys"][1]["value"]) From 00016d9eaca7150088893fe31073f61e2d86cd9a Mon Sep 17 00:00:00 2001 From: Joshua Benning Date: Sat, 10 Oct 2026 16:30:26 +0200 Subject: [PATCH 04/22] Rewire path serialization into JSON Encoder subclass Functionality follows #649 --- .../aas/adapter/json/path_serialization.py | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 sdk/basyx/aas/adapter/json/path_serialization.py diff --git a/sdk/basyx/aas/adapter/json/path_serialization.py b/sdk/basyx/aas/adapter/json/path_serialization.py new file mode 100644 index 000000000..8bb996df0 --- /dev/null +++ b/sdk/basyx/aas/adapter/json/path_serialization.py @@ -0,0 +1,132 @@ +# Copyright (c) 2026 the Eclipse BaSyx Authors +# +# This program and the accompanying materials are made available under the terms of the MIT License, available in +# the LICENSE file of this project. +# +# SPDX-License-Identifier: MIT + +from typing import Callable, Iterable, List + +from basyx.aas import model +from basyx.aas.adapter.json import AASToJsonEncoder + + +class PathJsonEncoder(AASToJsonEncoder): + """ + Custom JSONEncoder that serializes :class:`~basyx.aas.model.Referable` objects as the list of + idShortPaths reachable from them (``$path`` content modifier). + + :cvar deep: If True (default), all descendants on all sublevels are included. If False, only + the requested element and its direct children are included. Compare to + SerializationModifier level. + """ + + deep: bool = True + + @classmethod + def _get_aas_class_serializers(cls) -> dict[type, Callable]: + serializers = super()._get_aas_class_serializers() + serializers.update( + { + model.Submodel: cls._submodel_to_path, + model.SubmodelElementCollection: cls._submodel_element_collection_to_path, + model.SubmodelElementList: cls._submodel_element_list_to_path, + model.Entity: cls._entity_to_path, + model.AnnotatedRelationshipElement: cls._annotated_relationship_element_to_path, + model.Property: cls._leaf_to_path, + model.MultiLanguageProperty: cls._leaf_to_path, + model.Range: cls._leaf_to_path, + model.Blob: cls._leaf_to_path, + model.File: cls._leaf_to_path, + model.ReferenceElement: cls._leaf_to_path, + model.RelationshipElement: cls._leaf_to_path, + model.BasicEventElement: cls._leaf_to_path, + model.Capability: cls._leaf_to_path, + model.Operation: cls._leaf_to_path, + } + ) + return serializers + + @classmethod + def _submodel_to_path(cls, obj: model.Submodel) -> List[str]: + paths: List[str] = [] + cls._append_children(paths, obj.submodel_element, prefix=[]) + return paths + + @classmethod + def _submodel_element_collection_to_path(cls, obj: model.SubmodelElementCollection) -> List[str]: + paths, prefix = cls._self_path(obj) + cls._append_children(paths, obj.value, prefix) + return paths + + @classmethod + def _submodel_element_list_to_path(cls, obj: model.SubmodelElementList) -> List[str]: + paths, prefix = cls._self_path(obj) + cls._append_indexed_children(paths, obj.value, prefix) + return paths + + @classmethod + def _entity_to_path(cls, obj: model.Entity) -> List[str]: + paths, prefix = cls._self_path(obj) + cls._append_children(paths, obj.statement, prefix) + return paths + + @classmethod + def _annotated_relationship_element_to_path(cls, obj: model.AnnotatedRelationshipElement) -> List[str]: + paths, prefix = cls._self_path(obj) + cls._append_children(paths, obj.annotation, prefix) + return paths + + @classmethod + def _leaf_to_path(cls, obj: model.Referable) -> List[str]: + paths, _ = cls._self_path(obj) + return paths + + @staticmethod + def _self_path(obj: model.Referable) -> tuple[List[str], List[str]]: + """Return (initial paths list containing the element's own path, prefix for children).""" + if obj.id_short is None: + raise ValueError(f"{obj!r} has no idShort, so no idShortPath can be computed for it!") + prefix = [obj.id_short] + return [model.Referable.build_id_short_path(prefix)], prefix + + @classmethod + def _append_children( + cls, paths: List[str], children: Iterable[model.SubmodelElement], prefix: List[str] + ) -> None: + for child in children: + child_prefix = prefix + [child.id_short] + paths.append(model.Referable.build_id_short_path(child_prefix)) + if cls.deep: + cls._recurse(paths, child, child_prefix) + + @classmethod + def _append_indexed_children( + cls, paths: List[str], children: Iterable[model.SubmodelElement], prefix: List[str] + ) -> None: + for index, child in enumerate(children): + child_prefix = prefix + [str(index)] + paths.append(model.Referable.build_id_short_path(child_prefix)) + if cls.deep: + cls._recurse(paths, child, child_prefix) + + @classmethod + def _recurse(cls, paths: List[str], element: object, prefix: List[str]) -> None: + if isinstance(element, model.SubmodelElementList): + cls._append_indexed_children(paths, element.value, prefix) + elif isinstance(element, model.SubmodelElementCollection): + cls._append_children(paths, element.value, prefix) + elif isinstance(element, model.Entity): + cls._append_children(paths, element.statement, prefix) + elif isinstance(element, model.AnnotatedRelationshipElement): + cls._append_children(paths, element.annotation, prefix) + # Leaf elements have no children to traverse into. + + +class ShallowPathJsonEncoder(PathJsonEncoder): + """ + :class:`PathJsonEncoder` variant that emits only the requested element and its direct children + (matches ``?level=core``). + """ + + deep: bool = False From 2aec044264b5fdf24db6f4d8153e22cb8df4a8ca Mon Sep 17 00:00:00 2001 From: Joshua Benning Date: Sun, 11 Oct 2026 21:55:45 +0200 Subject: [PATCH 05/22] Draft central modifier and encoder handling --- server/app/adapter/__init__.py | 1 + server/app/adapter/modifiers.py | 70 +++++++++++++++++++++++++++++++++ server/app/interfaces/base.py | 63 ++++++++++++++++++++++++++--- server/app/util/converters.py | 24 +++++++++++ 4 files changed, 153 insertions(+), 5 deletions(-) create mode 100644 server/app/adapter/modifiers.py diff --git a/server/app/adapter/__init__.py b/server/app/adapter/__init__.py index 8a3e2af17..65ad6b609 100644 --- a/server/app/adapter/__init__.py +++ b/server/app/adapter/__init__.py @@ -1,2 +1,3 @@ from .jsonization import * +from .modifiers import * from .value_only import * diff --git a/server/app/adapter/modifiers.py b/server/app/adapter/modifiers.py new file mode 100644 index 000000000..f1ab4d04e --- /dev/null +++ b/server/app/adapter/modifiers.py @@ -0,0 +1,70 @@ +# Copyright (c) 2026 the Eclipse BaSyx Authors +# +# This program and the accompanying materials are made available under the terms of the MIT License, available in +# the LICENSE file of this project. +# +# SPDX-License-Identifier: MIT +""" +Central parsing and validation of serialization modifiers +""" + +from dataclasses import dataclass +from enum import Enum +from typing import Optional + +from werkzeug import Request +from werkzeug.exceptions import BadRequest + +__all__ = ["Content", "Modifiers", "parse_modifiers"] + + +class Content(str, Enum): + NORMAL = "normal" + METADATA = "metadata" + VALUE = "value" + REFERENCE = "reference" + PATH = "path" + + +@dataclass(frozen=True) +class Modifiers: + content: Content + deep: bool + with_blob: bool + + +def parse_modifiers(request: Request, content: Optional[Content] = None) -> Modifiers: + # content is None when URL carried no $... suffix. Default: normal + resolved = content if content is not None else Content.NORMAL + deep = _parse_level(request, resolved) + with_blob = _parse_extent(request, resolved) + return Modifiers(content=resolved, deep=deep, with_blob=with_blob) + + +def _parse_level(request: Request, content: Content) -> bool: + raw = request.args.get("level") + if raw is None: + # Spec default is deep + return True + if content is Content.METADATA: + raise BadRequest("Parameter 'level' is not allowed with /$metadata.") + if content is Content.REFERENCE and raw == "deep": + raise BadRequest("Parameter 'level=deep' is not allowed with /$reference.") + if raw not in {"deep", "core"}: + raise BadRequest(f"Invalid value for 'level': {raw!r}. Expected 'deep' or 'core'.") + return raw == "deep" + + +def _parse_extent(request: Request, content: Content) -> bool: + raw = request.args.get("extent") + if raw is None: + return False + norm = raw.lower() + if norm not in {"withblobvalue", "withoutblobvalue"}: + raise BadRequest( + f"Invalid value for 'extent': {raw!r}. Expected 'WithBLOBValue' or 'WithoutBLOBValue'." + ) + want_blob = norm == "withblobvalue" + if want_blob and content in {Content.METADATA, Content.REFERENCE, Content.PATH}: + raise BadRequest(f"Parameter 'extent=WithBLOBValue' is not allowed with /${content.value}.") + return want_blob diff --git a/server/app/interfaces/base.py b/server/app/interfaces/base.py index ac974cbad..189bbe00a 100644 --- a/server/app/interfaces/base.py +++ b/server/app/interfaces/base.py @@ -17,6 +17,8 @@ import werkzeug.utils from basyx.aas import model from basyx.aas.adapter._generic import XML_NS_MAP +from basyx.aas.adapter.json.path_serialization import PathJsonEncoder, ShallowPathJsonEncoder +from basyx.aas.adapter.json.value_only_serialization import AASToValueOnlyJsonEncoder from basyx.aas.adapter.xml import XMLConstructables, read_aas_xml_element, xml_serialization from basyx.aas.model import AbstractObjectStore from basyx.aas.model.datatypes import NonNegativeInteger @@ -26,7 +28,13 @@ from werkzeug.routing import MapAdapter import app.model -from app.adapter import ServerAASToJsonEncoder, ServerStrictAASFromJsonDecoder, ServerStrictStrippedAASFromJsonDecoder +from app.adapter import ( + Content, + Modifiers, + ServerAASToJsonEncoder, + ServerStrictAASFromJsonDecoder, + ServerStrictStrippedAASFromJsonDecoder, +) from app.model import AssetAdministrationShellDescriptor, AssetLink, SubmodelDescriptor from app.util.converters import base64url_decode @@ -110,17 +118,21 @@ def serialize(self, obj: ResponseData, paging_metadata: Optional[PagingMetadata] class JsonResponse(APIResponse): - def __init__(self, *args, content_type="application/json", **kwargs): + def __init__(self, *args, modifiers: Optional[Modifiers] = None, content_type="application/json", **kwargs): + self._modifiers = modifiers super().__init__(*args, **kwargs, content_type=content_type) def serialize(self, obj: ResponseData, paging_metadata: Optional[PagingMetadata], stripped: bool) -> str: + # TODO: stripped is still handled here, needs to be replaced see #650 + if self._modifiers is not None: + encoder_cls = pick_encoder(self._modifiers) + else: + encoder_cls = StrippedResultToJsonEncoder if stripped else ResultToJsonEncoder if paging_metadata is None: data = obj else: data = {"paging_metadata": paging_metadata, "result": obj} - return json.dumps( - data, cls=StrippedResultToJsonEncoder if stripped else ResultToJsonEncoder, separators=(",", ":") - ) + return json.dumps(data, cls=encoder_cls, separators=(",", ":")) class XmlResponse(APIResponse): @@ -221,6 +233,47 @@ class StrippedResultToJsonEncoder(ResultToJsonEncoder): stripped = True +# TODO need to be added + +class ResultMetadataJsonEncoder(ResultToJsonEncoder): + pass + + +class ResultReferenceJsonEncoder(ResultToJsonEncoder): + pass + + +class ResultValueOnlyJsonEncoder(ResultToJsonEncoder, AASToValueOnlyJsonEncoder): + pass + + +class ResultShallowValueOnlyJsonEncoder(ResultValueOnlyJsonEncoder): + pass + + +class ResultPathJsonEncoder(ResultToJsonEncoder, PathJsonEncoder): + pass + + +class ResultShallowPathJsonEncoder(ResultToJsonEncoder, ShallowPathJsonEncoder): + pass + + +def pick_encoder(mods: Modifiers) -> Type["ResultToJsonEncoder"]: + """Pick a JSON encoder class for the given modifier combination.""" + match mods.content: + case Content.NORMAL: + return StrippedResultToJsonEncoder if not mods.deep else ResultToJsonEncoder + case Content.METADATA: + return ResultMetadataJsonEncoder + case Content.VALUE: + return ResultShallowValueOnlyJsonEncoder if not mods.deep else ResultValueOnlyJsonEncoder + case Content.REFERENCE: + return ResultReferenceJsonEncoder + case Content.PATH: + return ResultShallowPathJsonEncoder if not mods.deep else ResultPathJsonEncoder + + class BaseWSGIApp: url_map: werkzeug.routing.Map diff --git a/server/app/util/converters.py b/server/app/util/converters.py index 3b98cd5d4..d7f5b3f5d 100644 --- a/server/app/util/converters.py +++ b/server/app/util/converters.py @@ -20,6 +20,8 @@ from basyx.aas import model from werkzeug.exceptions import BadRequest +from app.adapter.modifiers import Content + BASE64URL_ENCODING = "utf-8" @@ -57,6 +59,28 @@ def to_python(self, value: str) -> model.Identifier: return decoded +class ContentModifierConverter(werkzeug.routing.BaseConverter): + """ + URL converter matching the ``$metadata``, ``$value``, ``$reference`` and ``$path`` suffixes to convert + to :class:`~app.adapter.modifiers.Content` value + """ + + _TO_PYTHON = { + "$metadata": Content.METADATA, + "$value": Content.VALUE, + "$reference": Content.REFERENCE, + "$path": Content.PATH, + } + _TO_URL = {v: k for k, v in _TO_PYTHON.items()} + regex = r"\$(?:metadata|value|reference|path)" + + def to_python(self, value: str) -> Content: + return self._TO_PYTHON[value] + + def to_url(self, value: Content) -> str: + return self._TO_URL[value] + + class IdShortPathConverter(werkzeug.routing.UnicodeConverter): """ A custom Werkzeug URL converter for handling dot-separated idShort paths and indexes. From 54a49f48a7a8a8d526eb0a4e881c8521c5ebc920 Mon Sep 17 00:00:00 2001 From: Joshua Benning Date: Sun, 11 Oct 2026 21:56:30 +0200 Subject: [PATCH 06/22] Exmplatory wire up for get submodel endpoint Showcases collapse of rules and handler methods using converter and modifiers --- server/app/interfaces/repository.py | 48 ++++++++++++----------------- 1 file changed, 19 insertions(+), 29 deletions(-) diff --git a/server/app/interfaces/repository.py b/server/app/interfaces/repository.py index 57d2bd7cb..bdd4fa45b 100644 --- a/server/app/interfaces/repository.py +++ b/server/app/interfaces/repository.py @@ -25,13 +25,19 @@ from app._config import API_BASE_PATH from app.adapter import ( has_value_only_representation, + parse_modifiers, submodel_element_to_named_value_only, submodel_element_to_value_only, submodel_to_value_only, ) from app.interfaces.base import PagingMetadata from app.model import ServiceDescription, ServiceSpecificationProfileEnum -from app.util.converters import IdentifierToBase64URLConverter, IdShortPathConverter, base64url_decode +from app.util.converters import ( + ContentModifierConverter, + IdentifierToBase64URLConverter, + IdShortPathConverter, + base64url_decode, +) from .base import APIResponse, HTTPApiDecoder, JsonResponse, ObjectStoreWSGIApp, T, is_stripped_request @@ -132,18 +138,19 @@ def __init__( Rule("/$value", methods=["GET"], endpoint=self.get_submodel_all_value), Rule("/$path", methods=["GET"], endpoint=self.not_implemented), Rule("/", methods=["GET"], endpoint=self.get_submodel), + Rule( + "//", + methods=["GET"], + endpoint=self.get_submodel, + ), Rule("/", methods=["PUT"], endpoint=self.put_submodel), Rule("/", methods=["DELETE"], endpoint=self.delete_submodel), Rule("/", methods=["PATCH"], endpoint=self.not_implemented), Submount( "/", [ - Rule("/$metadata", methods=["GET"], endpoint=self.get_submodels_metadata), Rule("/$metadata", methods=["PATCH"], endpoint=self.not_implemented), - Rule("/$value", methods=["GET"], endpoint=self.get_submodels_value), Rule("/$value", methods=["PATCH"], endpoint=self.not_implemented), - Rule("/$reference", methods=["GET"], endpoint=self.get_submodels_reference), - Rule("/$path", methods=["GET"], endpoint=self.not_implemented), Rule( "/submodel-elements", methods=["GET"], @@ -363,7 +370,11 @@ def __init__( ], ) ], - converters={"base64url": IdentifierToBase64URLConverter, "id_short_path": IdShortPathConverter}, + converters={ + "base64url": IdentifierToBase64URLConverter, + "content_modifier": ContentModifierConverter, + "id_short_path": IdShortPathConverter, + }, strict_slashes=False, ) @@ -750,30 +761,9 @@ def delete_submodel(self, request: Request, url_args: Dict, response_t: Type[API return response_t() def get_submodel(self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs) -> Response: + mods = parse_modifiers(request, url_args.get("content")) submodel = self._get_submodel(url_args) - return response_t(submodel, stripped=is_stripped_request(request)) - - def get_submodels_metadata( - self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs - ) -> Response: - if "level" in request.args: - raise BadRequest("level cannot be used when retrieving metadata!") - submodel = self._get_submodel(url_args) - return response_t(submodel, stripped=True) - - def get_submodels_value( - self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs - ) -> Response: - self._assert_json_response(response_t) - submodel = self._get_submodel(url_args) - return response_t(submodel_to_value_only(submodel, not is_stripped_request(request))) - - def get_submodels_reference( - self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs - ) -> Response: - submodel = self._get_submodel(url_args) - reference = model.ModelReference.from_referable(submodel) - return response_t(reference, stripped=is_stripped_request(request)) + return response_t(submodel, modifiers=mods) def put_submodel(self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs) -> Response: submodel = self._get_submodel(url_args) From fde20f93944bbb66587f5813656cee472a69a5dd Mon Sep 17 00:00:00 2001 From: hpoeche Date: Wed, 14 Oct 2026 12:31:41 +0200 Subject: [PATCH 07/22] Implement Reference-Serialization as AASToJsonEnocoder subclass --- sdk/basyx/aas/adapter/json/__init__.py | 2 + .../adapter/json/reference_serialization.py | 41 ++++++++++++++++++ .../json/test_reference_serialization.py | 43 +++++++++++++++++++ server/app/interfaces/base.py | 3 +- server/test/interfaces/test_base.py | 28 ++++++++++++ 5 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 sdk/basyx/aas/adapter/json/reference_serialization.py create mode 100644 sdk/test/adapter/json/test_reference_serialization.py diff --git a/sdk/basyx/aas/adapter/json/__init__.py b/sdk/basyx/aas/adapter/json/__init__.py index d2431c211..767a4b10f 100644 --- a/sdk/basyx/aas/adapter/json/__init__.py +++ b/sdk/basyx/aas/adapter/json/__init__.py @@ -31,6 +31,7 @@ object_store_to_json, write_aas_json_file, ) +from .reference_serialization import ReferenceJsonEncoder __all__ = [ "AASFromJsonDecoder", @@ -43,4 +44,5 @@ "StrippedAASToJsonEncoder", "object_store_to_json", "write_aas_json_file", + "ReferenceJsonEncoder", ] diff --git a/sdk/basyx/aas/adapter/json/reference_serialization.py b/sdk/basyx/aas/adapter/json/reference_serialization.py new file mode 100644 index 000000000..3d51f716b --- /dev/null +++ b/sdk/basyx/aas/adapter/json/reference_serialization.py @@ -0,0 +1,41 @@ +# Copyright (c) 2026 the Eclipse BaSyx Authors +# +# This program and the accompanying materials are made available under the terms of the MIT License, available in +# the LICENSE file of this project. +# +# SPDX-License-Identifier: MIT +from typing import Callable, Dict, Type + +from basyx.aas import model +from basyx.aas.adapter.json import AASToJsonEncoder + + +class ReferenceJsonEncoder(AASToJsonEncoder): + """ + Custom JSONEncoder that serializes :class:`~basyx.aas.model.Referable` objects, which are contained + in an :class:`~basyx.aas.model.Identifiable`, as their :class:`~basyx.aas.model.ModelReference`. + """ + + @classmethod + def _get_aas_class_serializers(cls) -> Dict[Type, Callable]: + normal_serializers = super()._get_aas_class_serializers() + serializers: Dict[Type, Callable] = {} + for typ in normal_serializers: + if not issubclass(typ, model.Referable): + serializers[typ] = cls._no_referable + + serializers.update({ + model.Reference: normal_serializers[model.Reference], + model.Key: normal_serializers[model.Key], + model.Referable: cls._referable_to_reference, + }) + + return serializers + + @classmethod + def _referable_to_reference(cls, obj: model.Referable) -> model.Reference: + return model.ModelReference.from_referable(obj) + + @classmethod + def _no_referable(cls, obj: object): + raise TypeError(f"Object of type {obj.__class__.__name__} has no Reference serialization") diff --git a/sdk/test/adapter/json/test_reference_serialization.py b/sdk/test/adapter/json/test_reference_serialization.py new file mode 100644 index 000000000..19fe00b19 --- /dev/null +++ b/sdk/test/adapter/json/test_reference_serialization.py @@ -0,0 +1,43 @@ +# Copyright (c) 2026 the Eclipse BaSyx Authors +# +# This program and the accompanying materials are made available under the terms of the MIT License, available in +# the LICENSE file of this project. +# +# SPDX-License-Identifier: MIT +import json +import unittest + +from basyx.aas import model +from basyx.aas.adapter.json.reference_serialization import ReferenceJsonEncoder +from basyx.aas.examples.data import example_aas + + +class TestReferenceSerialization(unittest.TestCase): + + def test_submodel_element(self): + sm = example_aas.create_example_submodel() + element = sm.get_referable("ExampleRelationshipElement") + + out_str = json.dumps(element, cls=ReferenceJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertIsInstance(parsed_output, dict) + self.assertEqual({"keys", "type"}, parsed_output.keys()) + self.assertGreater(len(parsed_output["keys"]), 0) + + def test_non_referable(self): + obj = model.AssetInformation(global_asset_id="https://example.or/TestAssetId") + + with self.assertRaises(TypeError): + json.dumps(obj, cls=ReferenceJsonEncoder) + + def test_list_of_elements(self): + objects = [example_aas.create_example_submodel(), example_aas.create_example_asset_administration_shell()] + + out_str = json.dumps(objects, cls=ReferenceJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertIsInstance(parsed_output, list) + for ref in parsed_output: + self.assertEqual({"keys", "type"}, ref.keys()) + self.assertGreater(len(ref["keys"]), 0) diff --git a/server/app/interfaces/base.py b/server/app/interfaces/base.py index 189bbe00a..ef00e3e7b 100644 --- a/server/app/interfaces/base.py +++ b/server/app/interfaces/base.py @@ -18,6 +18,7 @@ from basyx.aas import model from basyx.aas.adapter._generic import XML_NS_MAP from basyx.aas.adapter.json.path_serialization import PathJsonEncoder, ShallowPathJsonEncoder +from basyx.aas.adapter.json.reference_serialization import ReferenceJsonEncoder from basyx.aas.adapter.json.value_only_serialization import AASToValueOnlyJsonEncoder from basyx.aas.adapter.xml import XMLConstructables, read_aas_xml_element, xml_serialization from basyx.aas.model import AbstractObjectStore @@ -239,7 +240,7 @@ class ResultMetadataJsonEncoder(ResultToJsonEncoder): pass -class ResultReferenceJsonEncoder(ResultToJsonEncoder): +class ResultReferenceJsonEncoder(ResultToJsonEncoder, ReferenceJsonEncoder): pass diff --git a/server/test/interfaces/test_base.py b/server/test/interfaces/test_base.py index afeed463f..29c5a0ea7 100644 --- a/server/test/interfaces/test_base.py +++ b/server/test/interfaces/test_base.py @@ -12,6 +12,7 @@ from typing import Optional from unittest import mock +from app.adapter import Content from app.interfaces import base from basyx.aas import model from basyx.aas.adapter._generic import XML_NS_MAP @@ -265,3 +266,30 @@ def test_result(self): self.assertEqual("Error", messages[0].findtext("messageType")) isodatetime = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}$") self.assertIsNotNone(isodatetime.match(messages[0].findtext("timestamp") or "")) + +class TestJsonReferenceResponse(unittest.TestCase): + + def test_paginated_list(self): + # Test that Reference Serialization and Result Serialization is combined correctly + response = base.JsonResponse( + [ + model.Submodel(id_="https://example.org/Example_Submodel"), + model.Submodel(id_="https://example.org/Second_Submodel"), + ], + modifiers=base.Modifiers(Content.REFERENCE, True, False), + paging_metadata=base.PagingMetadata(cursor="asdf"), + ) + + self.assertEqual("application/json", response.content_type) + self.assertEqual(200, response.status_code) + + parsed_body = json.loads(response.get_data(as_text=True)) + if not isinstance(parsed_body, dict): + self.fail("Response is no JSON dict") + self.assertEqual({"paging_metadata", "result"}, parsed_body.keys()) + + result = parsed_body["result"] + self.assertIsInstance(result, list) + for ref in result: + self.assertEqual({"keys", "type"}, ref.keys()) + self.assertGreater(len(ref["keys"]), 0) From 39d4685edf7331252a454ee4b4c5c4b3a4902fe4 Mon Sep 17 00:00:00 2001 From: hpoeche Date: Wed, 14 Oct 2026 12:34:43 +0200 Subject: [PATCH 08/22] Implement Metadata-Serialization as AASToJsonEnocoder subclass --- sdk/basyx/aas/adapter/json/__init__.py | 2 + .../adapter/json/metadata_serialization.py | 128 ++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 sdk/basyx/aas/adapter/json/metadata_serialization.py diff --git a/sdk/basyx/aas/adapter/json/__init__.py b/sdk/basyx/aas/adapter/json/__init__.py index 767a4b10f..2062b6ee3 100644 --- a/sdk/basyx/aas/adapter/json/__init__.py +++ b/sdk/basyx/aas/adapter/json/__init__.py @@ -31,6 +31,7 @@ object_store_to_json, write_aas_json_file, ) +from .metadata_serialization import MetadataJsonEncoder from .reference_serialization import ReferenceJsonEncoder __all__ = [ @@ -44,5 +45,6 @@ "StrippedAASToJsonEncoder", "object_store_to_json", "write_aas_json_file", + "MetadataJsonEncoder", "ReferenceJsonEncoder", ] diff --git a/sdk/basyx/aas/adapter/json/metadata_serialization.py b/sdk/basyx/aas/adapter/json/metadata_serialization.py new file mode 100644 index 000000000..45b9a2937 --- /dev/null +++ b/sdk/basyx/aas/adapter/json/metadata_serialization.py @@ -0,0 +1,128 @@ +# Copyright (c) 2026 the Eclipse BaSyx Authors +# +# This program and the accompanying materials are made available under the terms of the MIT License, available in +# the LICENSE file of this project. +# +# SPDX-License-Identifier: MIT +from typing import Dict + +from basyx.aas import model +from basyx.aas.adapter.json import AASToJsonEncoder + + +class MetadataJsonEncoder(AASToJsonEncoder): + """ + Custom JSONEncoder that serializes BaSyx Python SDK objects into the JSON format "Metadata". This equals + the normal serialization format but omits certain keys. + """ + + @classmethod + def _asset_administration_shell_to_json( + cls, obj: model.AssetAdministrationShell + ) -> Dict[str, object]: + data = super()._asset_administration_shell_to_json(obj) + data.pop("assetInformation", None) + data.pop("submodels", None) + return data + + @classmethod + def _submodel_to_json( + cls, obj: model.Submodel + ) -> Dict[str, object]: + data = super()._submodel_to_json(obj) + data.pop("submodelElements", None) + return data + + @classmethod + def _submodel_element_collection_to_json( + cls, obj: model.SubmodelElementCollection + ) -> Dict[str, object]: + data = super()._submodel_element_collection_to_json(obj) + data.pop("value", None) + return data + + @classmethod + def _submodel_element_list_to_json( + cls, obj: model.SubmodelElementList + ) -> Dict[str, object]: + data = super()._submodel_element_list_to_json(obj) + data.pop("value", None) + return data + + @classmethod + def _entity_to_json(cls, obj: model.Entity) -> Dict[str, object]: + data = super()._entity_to_json(obj) + data.pop("statements", None) + data.pop("globalAssetId", None) + data.pop("specificAssetIds", None) + return data + + @classmethod + def _basic_event_element_to_json( + cls, obj: model.BasicEventElement + ) -> Dict[str, object]: + data = super()._basic_event_element_to_json(obj) + data.pop("observed", None) + return data + + @classmethod + def _property_to_json(cls, obj: model.Property) -> Dict[str, object]: + data = super()._property_to_json(obj) + data.pop("value", None) + data.pop("valueId", None) + return data + + @classmethod + def _multi_language_property_to_json(cls, obj: model.MultiLanguageProperty) -> Dict[str, object]: + data = super()._multi_language_property_to_json(obj) + data.pop("value", None) + data.pop("valueId", None) + return data + + @classmethod + def _range_to_json(cls, obj: model.Range) -> Dict[str, object]: + data = super()._range_to_json(obj) + data.pop("min", None) + data.pop("max", None) + return data + + @classmethod + def _reference_element_to_json( + cls, obj: model.ReferenceElement + ) -> Dict[str, object]: + data = super()._reference_element_to_json(obj) + data.pop("value", None) + return data + + @classmethod + def _relationship_element_to_json( + cls, obj: model.RelationshipElement + ) -> Dict[str, object]: + data = super()._relationship_element_to_json(obj) + data.pop("first", None) + data.pop("second", None) + return data + + @classmethod + def _annotated_relationship_element_to_json( + cls, obj: model.AnnotatedRelationshipElement + ) -> Dict[str, object]: + data = super()._annotated_relationship_element_to_json(obj) + data.pop("first", None) + data.pop("second", None) + data.pop("annotations", None) + return data + + @classmethod + def _blob_to_json(cls, obj: model.Blob) -> Dict[str, object]: + data = super()._blob_to_json(obj) + data.pop("value", None) + data.pop("contentType", None) + return data + + @classmethod + def _file_to_json(cls, obj: model.File) -> Dict[str, object]: + data = super()._file_to_json(obj) + data.pop("value", None) + data.pop("contentType", None) + return data From bd0dbcd851cc85b7373992d0d51961f0fd417a0d Mon Sep 17 00:00:00 2001 From: hpoeche Date: Wed, 14 Oct 2026 12:35:45 +0200 Subject: [PATCH 09/22] Fix import errors and missing copyright --- sdk/basyx/aas/adapter/json/__init__.py | 5 +++++ sdk/test/adapter/json/test_value_only_serialization.py | 6 ++++++ 2 files changed, 11 insertions(+) diff --git a/sdk/basyx/aas/adapter/json/__init__.py b/sdk/basyx/aas/adapter/json/__init__.py index 2062b6ee3..d47a2cc49 100644 --- a/sdk/basyx/aas/adapter/json/__init__.py +++ b/sdk/basyx/aas/adapter/json/__init__.py @@ -32,7 +32,9 @@ write_aas_json_file, ) from .metadata_serialization import MetadataJsonEncoder +from .path_serialization import PathJsonEncoder, ShallowPathJsonEncoder from .reference_serialization import ReferenceJsonEncoder +from .value_only_serialization import AASToValueOnlyJsonEncoder __all__ = [ "AASFromJsonDecoder", @@ -45,6 +47,9 @@ "StrippedAASToJsonEncoder", "object_store_to_json", "write_aas_json_file", + "PathJsonEncoder", + "ShallowPathJsonEncoder", + "AASToValueOnlyJsonEncoder", "MetadataJsonEncoder", "ReferenceJsonEncoder", ] diff --git a/sdk/test/adapter/json/test_value_only_serialization.py b/sdk/test/adapter/json/test_value_only_serialization.py index 085777ba5..72d6a39f9 100644 --- a/sdk/test/adapter/json/test_value_only_serialization.py +++ b/sdk/test/adapter/json/test_value_only_serialization.py @@ -1,3 +1,9 @@ +# Copyright (c) 2026 the Eclipse BaSyx Authors +# +# This program and the accompanying materials are made available under the terms of the MIT License, available in +# the LICENSE file of this project. +# +# SPDX-License-Identifier: MIT import json import unittest From 2fe15bc63cd2b539a9775aa82ee880325d65fbb7 Mon Sep 17 00:00:00 2001 From: hpoeche Date: Wed, 14 Oct 2026 12:36:03 +0200 Subject: [PATCH 10/22] Add testing for path serialization --- .../adapter/json/test_path_serialization.py | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 sdk/test/adapter/json/test_path_serialization.py diff --git a/sdk/test/adapter/json/test_path_serialization.py b/sdk/test/adapter/json/test_path_serialization.py new file mode 100644 index 000000000..c66dde126 --- /dev/null +++ b/sdk/test/adapter/json/test_path_serialization.py @@ -0,0 +1,108 @@ +# Copyright (c) 2026 the Eclipse BaSyx Authors +# +# This program and the accompanying materials are made available under the terms of the MIT License, available in +# the LICENSE file of this project. +# +# SPDX-License-Identifier: MIT +import json +import unittest + +from basyx.aas.adapter.json import PathJsonEncoder, ShallowPathJsonEncoder +from basyx.aas.examples.data import example_aas + + +class TestPathSerialization(unittest.TestCase): + def test_submodel_top_level(self): + sm = example_aas.create_example_submodel() + + out_str = json.dumps(sm, cls=PathJsonEncoder) + parsed_output = json.loads(out_str) + + if not isinstance(parsed_output, list): + self.fail("Path serialization is not a list") + self.assertNotIn(sm.id_short, parsed_output) + self.assertIn("ExampleOperation", parsed_output) + self.assertIn("ExampleCapability", parsed_output) + self.assertIn("ExampleBasicEventElement", parsed_output) + self.assertIn("ExampleRelationshipElement", parsed_output) + self.assertIn("ExampleAnnotatedRelationshipElement", parsed_output) + self.assertIn("ExampleSubmodelCollection", parsed_output) + + def test_submodel_collection_deep(self): + sm = example_aas.create_example_submodel() + + out_str = json.dumps(sm, cls=PathJsonEncoder) + parsed_output = json.loads(out_str) + + if not isinstance(parsed_output, list): + self.fail("Path serialization is not a list") + + self.assertIn("ExampleSubmodelCollection.ExampleBlob", parsed_output) + self.assertIn("ExampleSubmodelCollection.ExampleFile", parsed_output) + self.assertIn("ExampleSubmodelCollection.ExampleFileURI", parsed_output) + self.assertIn("ExampleSubmodelCollection.ExampleMultiLanguageProperty", parsed_output) + self.assertIn("ExampleSubmodelCollection.ExampleRange", parsed_output) + self.assertIn("ExampleSubmodelCollection.ExampleReferenceElement", parsed_output) + self.assertIn("ExampleSubmodelCollection.ExampleSubmodelList", parsed_output) + + def test_submodel_list_deep(self): + sm = example_aas.create_example_submodel() + + out_str = json.dumps(sm, cls=PathJsonEncoder) + parsed_output = json.loads(out_str) + + if not isinstance(parsed_output, list): + self.fail("Path serialization is not a list") + + self.assertIn("ExampleSubmodelCollection.ExampleSubmodelList[0]", parsed_output) + self.assertIn("ExampleSubmodelCollection.ExampleSubmodelList[1]", parsed_output) + + def test_annotation_deep(self): + sm = example_aas.create_example_submodel() + + out_str = json.dumps(sm, cls=PathJsonEncoder) + parsed_output = json.loads(out_str) + + if not isinstance(parsed_output, list): + self.fail("Path serialization is not a list") + + self.assertIn("ExampleAnnotatedRelationshipElement.ExampleAnnotatedProperty", parsed_output) + self.assertIn("ExampleAnnotatedRelationshipElement.ExampleAnnotatedRange", parsed_output) + + def test_nested_element(self): + sm = example_aas.create_example_submodel() + sml = sm.get_referable("ExampleSubmodelCollection.ExampleSubmodelList") + + out_str = json.dumps(sml, cls=PathJsonEncoder) + parsed_output = json.loads(out_str) + + if not isinstance(parsed_output, list): + self.fail("Path serialization is not a list") + + self.assertIn("ExampleSubmodelList[0]", parsed_output) + self.assertIn("ExampleSubmodelList[1]", parsed_output) + + def test_error_on_unsupported_types(self): + aas = example_aas.create_example_asset_administration_shell() + + with self.assertRaises(TypeError): + json.dumps(aas, cls=PathJsonEncoder) + + def test_level_core(self): + sm = example_aas.create_example_submodel() + + out_str = json.dumps(sm, cls=ShallowPathJsonEncoder) + parsed_output = json.loads(out_str) + + if not isinstance(parsed_output, list): + self.fail("Path serialization is not a list") + + self.assertIn("ExampleOperation", parsed_output) + self.assertIn("ExampleCapability", parsed_output) + self.assertIn("ExampleBasicEventElement", parsed_output) + self.assertIn("ExampleRelationshipElement", parsed_output) + self.assertIn("ExampleAnnotatedRelationshipElement", parsed_output) + self.assertIn("ExampleSubmodelCollection", parsed_output) + self.assertNotIn("ExampleSubmodelCollection.ExampleBlob", parsed_output) + self.assertNotIn("ExampleSubmodelCollection.ExampleSubmodelList[0]", parsed_output) + self.assertNotIn("ExampleAnnotatedRelationshipElement.ExampleAnnotatedProperty", parsed_output) From ca6a36348fd891b8d275937b6d96bbd77b33875c Mon Sep 17 00:00:00 2001 From: Joshua Benning Date: Wed, 14 Oct 2026 21:38:50 +0200 Subject: [PATCH 11/22] Refactor bool related names for better clarity --- server/app/adapter/modifiers.py | 14 +++++++------- server/app/interfaces/base.py | 6 +++--- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/server/app/adapter/modifiers.py b/server/app/adapter/modifiers.py index f1ab4d04e..7eef67ce3 100644 --- a/server/app/adapter/modifiers.py +++ b/server/app/adapter/modifiers.py @@ -29,19 +29,19 @@ class Content(str, Enum): @dataclass(frozen=True) class Modifiers: content: Content - deep: bool - with_blob: bool + is_deep: bool + is_with_blob: bool def parse_modifiers(request: Request, content: Optional[Content] = None) -> Modifiers: # content is None when URL carried no $... suffix. Default: normal resolved = content if content is not None else Content.NORMAL - deep = _parse_level(request, resolved) - with_blob = _parse_extent(request, resolved) - return Modifiers(content=resolved, deep=deep, with_blob=with_blob) + is_deep = _is_deep_request(request, resolved) + is_with_blob = _is_with_blob_request(request, resolved) + return Modifiers(content=resolved, is_deep=is_deep, is_with_blob=is_with_blob) -def _parse_level(request: Request, content: Content) -> bool: +def _is_deep_request(request: Request, content: Content) -> bool: raw = request.args.get("level") if raw is None: # Spec default is deep @@ -55,7 +55,7 @@ def _parse_level(request: Request, content: Content) -> bool: return raw == "deep" -def _parse_extent(request: Request, content: Content) -> bool: +def _is_with_blob_request(request: Request, content: Content) -> bool: raw = request.args.get("extent") if raw is None: return False diff --git a/server/app/interfaces/base.py b/server/app/interfaces/base.py index ef00e3e7b..9a488686a 100644 --- a/server/app/interfaces/base.py +++ b/server/app/interfaces/base.py @@ -264,15 +264,15 @@ def pick_encoder(mods: Modifiers) -> Type["ResultToJsonEncoder"]: """Pick a JSON encoder class for the given modifier combination.""" match mods.content: case Content.NORMAL: - return StrippedResultToJsonEncoder if not mods.deep else ResultToJsonEncoder + return StrippedResultToJsonEncoder if not mods.is_deep else ResultToJsonEncoder case Content.METADATA: return ResultMetadataJsonEncoder case Content.VALUE: - return ResultShallowValueOnlyJsonEncoder if not mods.deep else ResultValueOnlyJsonEncoder + return ResultShallowValueOnlyJsonEncoder if not mods.is_deep else ResultValueOnlyJsonEncoder case Content.REFERENCE: return ResultReferenceJsonEncoder case Content.PATH: - return ResultShallowPathJsonEncoder if not mods.deep else ResultPathJsonEncoder + return ResultShallowPathJsonEncoder if not mods.is_deep else ResultPathJsonEncoder class BaseWSGIApp: From 1e456885a2889a34979cee881c265e3f44c8eb7c Mon Sep 17 00:00:00 2001 From: Joshua Benning Date: Mon, 19 Oct 2026 20:36:23 +0200 Subject: [PATCH 12/22] Adjust `deep` as init arg on path encoder Previously, `PathJsonEncoder` carried `deep` as a class variable and the `?level=core` variant was expressed by a dedicated `ShallowPathJsonEncoder` subclass that flipped it to `False`. Composing this axis with other content modes required another `Shallow*` sibling per encoder, so the class count scaled with the Cartesian product of axes. With this change, `deep` is passed to `PathJsonEncoder.__init__` as a keyword argument and stored on the instance. The encoder uses `default()` directly instead of extending the classmethod- based `_get_aas_class_serializers` dispatch, which avoids `cls`/`self` mismatches along the MRO when carrying per-request state. On the server side, `pick_encoder` now returns `(cls, kwargs)` and `JsonResponse.serialize` forwards the kwargs through `json.dumps` into the encoder constructor; `ResultShallowPathJsonEncoder` is removed since the single `ResultPathJsonEncoder` now covers both levels via the kwarg. --- sdk/basyx/aas/adapter/json/__init__.py | 3 +- .../aas/adapter/json/path_serialization.py | 133 ++++++------------ server/app/interfaces/base.py | 30 ++-- 3 files changed, 59 insertions(+), 107 deletions(-) diff --git a/sdk/basyx/aas/adapter/json/__init__.py b/sdk/basyx/aas/adapter/json/__init__.py index d47a2cc49..f927a28ed 100644 --- a/sdk/basyx/aas/adapter/json/__init__.py +++ b/sdk/basyx/aas/adapter/json/__init__.py @@ -32,7 +32,7 @@ write_aas_json_file, ) from .metadata_serialization import MetadataJsonEncoder -from .path_serialization import PathJsonEncoder, ShallowPathJsonEncoder +from .path_serialization import PathJsonEncoder from .reference_serialization import ReferenceJsonEncoder from .value_only_serialization import AASToValueOnlyJsonEncoder @@ -48,7 +48,6 @@ "object_store_to_json", "write_aas_json_file", "PathJsonEncoder", - "ShallowPathJsonEncoder", "AASToValueOnlyJsonEncoder", "MetadataJsonEncoder", "ReferenceJsonEncoder", diff --git a/sdk/basyx/aas/adapter/json/path_serialization.py b/sdk/basyx/aas/adapter/json/path_serialization.py index 8bb996df0..dfe24634d 100644 --- a/sdk/basyx/aas/adapter/json/path_serialization.py +++ b/sdk/basyx/aas/adapter/json/path_serialization.py @@ -5,7 +5,7 @@ # # SPDX-License-Identifier: MIT -from typing import Callable, Iterable, List +from typing import Iterable, List from basyx.aas import model from basyx.aas.adapter.json import AASToJsonEncoder @@ -16,117 +16,66 @@ class PathJsonEncoder(AASToJsonEncoder): Custom JSONEncoder that serializes :class:`~basyx.aas.model.Referable` objects as the list of idShortPaths reachable from them (``$path`` content modifier). - :cvar deep: If True (default), all descendants on all sublevels are included. If False, only - the requested element and its direct children are included. Compare to - SerializationModifier level. + :param deep: If True (default), all descendants on all sublevels are included. If False, only + the requested element and its direct children are included. Compare to + SerializationModifier level. """ - deep: bool = True - - @classmethod - def _get_aas_class_serializers(cls) -> dict[type, Callable]: - serializers = super()._get_aas_class_serializers() - serializers.update( - { - model.Submodel: cls._submodel_to_path, - model.SubmodelElementCollection: cls._submodel_element_collection_to_path, - model.SubmodelElementList: cls._submodel_element_list_to_path, - model.Entity: cls._entity_to_path, - model.AnnotatedRelationshipElement: cls._annotated_relationship_element_to_path, - model.Property: cls._leaf_to_path, - model.MultiLanguageProperty: cls._leaf_to_path, - model.Range: cls._leaf_to_path, - model.Blob: cls._leaf_to_path, - model.File: cls._leaf_to_path, - model.ReferenceElement: cls._leaf_to_path, - model.RelationshipElement: cls._leaf_to_path, - model.BasicEventElement: cls._leaf_to_path, - model.Capability: cls._leaf_to_path, - model.Operation: cls._leaf_to_path, - } - ) - return serializers - - @classmethod - def _submodel_to_path(cls, obj: model.Submodel) -> List[str]: - paths: List[str] = [] - cls._append_children(paths, obj.submodel_element, prefix=[]) - return paths - - @classmethod - def _submodel_element_collection_to_path(cls, obj: model.SubmodelElementCollection) -> List[str]: - paths, prefix = cls._self_path(obj) - cls._append_children(paths, obj.value, prefix) - return paths - - @classmethod - def _submodel_element_list_to_path(cls, obj: model.SubmodelElementList) -> List[str]: - paths, prefix = cls._self_path(obj) - cls._append_indexed_children(paths, obj.value, prefix) - return paths - - @classmethod - def _entity_to_path(cls, obj: model.Entity) -> List[str]: - paths, prefix = cls._self_path(obj) - cls._append_children(paths, obj.statement, prefix) - return paths - - @classmethod - def _annotated_relationship_element_to_path(cls, obj: model.AnnotatedRelationshipElement) -> List[str]: - paths, prefix = cls._self_path(obj) - cls._append_children(paths, obj.annotation, prefix) - return paths - - @classmethod - def _leaf_to_path(cls, obj: model.Referable) -> List[str]: - paths, _ = cls._self_path(obj) - return paths - - @staticmethod - def _self_path(obj: model.Referable) -> tuple[List[str], List[str]]: - """Return (initial paths list containing the element's own path, prefix for children).""" + def __init__(self, *args, deep: bool = True, **kwargs): + super().__init__(*args, **kwargs) + self.deep = deep + + def default(self, obj: object) -> object: + if isinstance(obj, model.Referable): + return self._referable_to_path(obj) + return super().default(obj) + + def _referable_to_path(self, obj: model.Referable) -> List[str]: + if isinstance(obj, model.Submodel): + # Submodel is Identifiable; its idShort is optional and never part of an idShortPath. + paths: List[str] = [] + self._append_children(paths, obj.submodel_element, prefix=[]) + return paths if obj.id_short is None: raise ValueError(f"{obj!r} has no idShort, so no idShortPath can be computed for it!") prefix = [obj.id_short] - return [model.Referable.build_id_short_path(prefix)], prefix + paths = [model.Referable.build_id_short_path(prefix)] + if isinstance(obj, model.SubmodelElementList): + self._append_indexed_children(paths, obj.value, prefix) + elif isinstance(obj, model.SubmodelElementCollection): + self._append_children(paths, obj.value, prefix) + elif isinstance(obj, model.Entity): + self._append_children(paths, obj.statement, prefix) + elif isinstance(obj, model.AnnotatedRelationshipElement): + self._append_children(paths, obj.annotation, prefix) + # Other Referables are leaves with no further idShortPaths beyond self. + return paths - @classmethod def _append_children( - cls, paths: List[str], children: Iterable[model.SubmodelElement], prefix: List[str] + self, paths: List[str], children: Iterable[model.SubmodelElement], prefix: List[str] ) -> None: for child in children: child_prefix = prefix + [child.id_short] paths.append(model.Referable.build_id_short_path(child_prefix)) - if cls.deep: - cls._recurse(paths, child, child_prefix) + if self.deep: + self._recurse(paths, child, child_prefix) - @classmethod def _append_indexed_children( - cls, paths: List[str], children: Iterable[model.SubmodelElement], prefix: List[str] + self, paths: List[str], children: Iterable[model.SubmodelElement], prefix: List[str] ) -> None: for index, child in enumerate(children): child_prefix = prefix + [str(index)] paths.append(model.Referable.build_id_short_path(child_prefix)) - if cls.deep: - cls._recurse(paths, child, child_prefix) + if self.deep: + self._recurse(paths, child, child_prefix) - @classmethod - def _recurse(cls, paths: List[str], element: object, prefix: List[str]) -> None: + def _recurse(self, paths: List[str], element: object, prefix: List[str]) -> None: if isinstance(element, model.SubmodelElementList): - cls._append_indexed_children(paths, element.value, prefix) + self._append_indexed_children(paths, element.value, prefix) elif isinstance(element, model.SubmodelElementCollection): - cls._append_children(paths, element.value, prefix) + self._append_children(paths, element.value, prefix) elif isinstance(element, model.Entity): - cls._append_children(paths, element.statement, prefix) + self._append_children(paths, element.statement, prefix) elif isinstance(element, model.AnnotatedRelationshipElement): - cls._append_children(paths, element.annotation, prefix) + self._append_children(paths, element.annotation, prefix) # Leaf elements have no children to traverse into. - - -class ShallowPathJsonEncoder(PathJsonEncoder): - """ - :class:`PathJsonEncoder` variant that emits only the requested element and its direct children - (matches ``?level=core``). - """ - - deep: bool = False diff --git a/server/app/interfaces/base.py b/server/app/interfaces/base.py index 9a488686a..7009e9b7b 100644 --- a/server/app/interfaces/base.py +++ b/server/app/interfaces/base.py @@ -17,7 +17,7 @@ import werkzeug.utils from basyx.aas import model from basyx.aas.adapter._generic import XML_NS_MAP -from basyx.aas.adapter.json.path_serialization import PathJsonEncoder, ShallowPathJsonEncoder +from basyx.aas.adapter.json.path_serialization import PathJsonEncoder from basyx.aas.adapter.json.reference_serialization import ReferenceJsonEncoder from basyx.aas.adapter.json.value_only_serialization import AASToValueOnlyJsonEncoder from basyx.aas.adapter.xml import XMLConstructables, read_aas_xml_element, xml_serialization @@ -126,14 +126,15 @@ def __init__(self, *args, modifiers: Optional[Modifiers] = None, content_type="a def serialize(self, obj: ResponseData, paging_metadata: Optional[PagingMetadata], stripped: bool) -> str: # TODO: stripped is still handled here, needs to be replaced see #650 if self._modifiers is not None: - encoder_cls = pick_encoder(self._modifiers) + encoder_cls, encoder_kwargs = pick_encoder(self._modifiers) else: encoder_cls = StrippedResultToJsonEncoder if stripped else ResultToJsonEncoder + encoder_kwargs = {} if paging_metadata is None: data = obj else: data = {"paging_metadata": paging_metadata, "result": obj} - return json.dumps(data, cls=encoder_cls, separators=(",", ":")) + return json.dumps(data, cls=encoder_cls, separators=(",", ":"), **encoder_kwargs) class XmlResponse(APIResponse): @@ -256,23 +257,26 @@ class ResultPathJsonEncoder(ResultToJsonEncoder, PathJsonEncoder): pass -class ResultShallowPathJsonEncoder(ResultToJsonEncoder, ShallowPathJsonEncoder): - pass - +def pick_encoder(mods: Modifiers) -> Tuple[Type["ResultToJsonEncoder"], Dict[str, Any]]: + """Pick a JSON encoder class + encoder-constructor kwargs for the given modifier combination. -def pick_encoder(mods: Modifiers) -> Type["ResultToJsonEncoder"]: - """Pick a JSON encoder class for the given modifier combination.""" + Encoders that take per-request axis state (e.g. ``deep``) receive it via kwargs here; + ``json.dumps`` forwards them into the encoder's ``__init__``. + """ match mods.content: case Content.NORMAL: - return StrippedResultToJsonEncoder if not mods.is_deep else ResultToJsonEncoder + cls = StrippedResultToJsonEncoder if not mods.is_deep else ResultToJsonEncoder + return cls, {} case Content.METADATA: - return ResultMetadataJsonEncoder + return ResultMetadataJsonEncoder, {} case Content.VALUE: - return ResultShallowValueOnlyJsonEncoder if not mods.is_deep else ResultValueOnlyJsonEncoder + # TODO: AASToValueOnlyJsonEncoder needs a ``deep`` kwarg; currently always deep. + cls = ResultShallowValueOnlyJsonEncoder if not mods.is_deep else ResultValueOnlyJsonEncoder + return cls, {} case Content.REFERENCE: - return ResultReferenceJsonEncoder + return ResultReferenceJsonEncoder, {} case Content.PATH: - return ResultShallowPathJsonEncoder if not mods.is_deep else ResultPathJsonEncoder + return ResultPathJsonEncoder, {"deep": mods.is_deep} class BaseWSGIApp: From d6dbf01b48dadc2efb203513282cfb16f2a9012d Mon Sep 17 00:00:00 2001 From: hpoeche Date: Fri, 23 Oct 2026 10:50:20 +0200 Subject: [PATCH 13/22] Refine `PathJsonEncoder` testing --- .../adapter/json/test_path_serialization.py | 309 ++++++++++++++---- 1 file changed, 243 insertions(+), 66 deletions(-) diff --git a/sdk/test/adapter/json/test_path_serialization.py b/sdk/test/adapter/json/test_path_serialization.py index c66dde126..53006c9c9 100644 --- a/sdk/test/adapter/json/test_path_serialization.py +++ b/sdk/test/adapter/json/test_path_serialization.py @@ -7,102 +7,279 @@ import json import unittest -from basyx.aas.adapter.json import PathJsonEncoder, ShallowPathJsonEncoder -from basyx.aas.examples.data import example_aas +from basyx.aas import model +from basyx.aas.adapter.json import PathJsonEncoder +from basyx.aas.examples.data import example_aas, example_aas_mandatory_attributes -class TestPathSerialization(unittest.TestCase): - def test_submodel_top_level(self): - sm = example_aas.create_example_submodel() +class TestPathJsonEncoder(unittest.TestCase): + def setUp(self): + self.submodel = example_aas.create_example_submodel() - out_str = json.dumps(sm, cls=PathJsonEncoder) + def test_submodel_deep_ok(self): + out_str = json.dumps(self.submodel, cls=PathJsonEncoder) parsed_output = json.loads(out_str) - if not isinstance(parsed_output, list): - self.fail("Path serialization is not a list") - self.assertNotIn(sm.id_short, parsed_output) - self.assertIn("ExampleOperation", parsed_output) - self.assertIn("ExampleCapability", parsed_output) - self.assertIn("ExampleBasicEventElement", parsed_output) - self.assertIn("ExampleRelationshipElement", parsed_output) - self.assertIn("ExampleAnnotatedRelationshipElement", parsed_output) - self.assertIn("ExampleSubmodelCollection", parsed_output) + self.assertCountEqual([ + "ExampleRelationshipElement", + "ExampleAnnotatedRelationshipElement", + "ExampleAnnotatedRelationshipElement.ExampleAnnotatedRange", + "ExampleAnnotatedRelationshipElement.ExampleAnnotatedProperty", + "ExampleOperation", + "ExampleCapability", + "ExampleBasicEventElement", + "ExampleSubmodelCollection", + "ExampleSubmodelCollection.ExampleBlob", + "ExampleSubmodelCollection.ExampleFile", + "ExampleSubmodelCollection.ExampleFileURI", + "ExampleSubmodelCollection.ExampleMultiLanguageProperty", + "ExampleSubmodelCollection.ExampleRange", + "ExampleSubmodelCollection.ExampleReferenceElement", + "ExampleSubmodelCollection.ExampleSubmodelList", + "ExampleSubmodelCollection.ExampleSubmodelList[0]", + "ExampleSubmodelCollection.ExampleSubmodelList[1]", + ], parsed_output) - def test_submodel_collection_deep(self): - sm = example_aas.create_example_submodel() + def test_submodel_core_ok(self): + out_str = json.dumps(self.submodel, cls=PathJsonEncoder, deep=False) + parsed_output = json.loads(out_str) + + self.assertCountEqual([ + "ExampleRelationshipElement", + "ExampleAnnotatedRelationshipElement", + "ExampleOperation", + "ExampleCapability", + "ExampleBasicEventElement", + "ExampleSubmodelCollection", + ], parsed_output) - out_str = json.dumps(sm, cls=PathJsonEncoder) + def test_deep_is_default_ok(self): + deep_output = json.loads(json.dumps(self.submodel, cls=PathJsonEncoder, deep=True)) + + out_str = json.dumps(self.submodel, cls=PathJsonEncoder) parsed_output = json.loads(out_str) - if not isinstance(parsed_output, list): - self.fail("Path serialization is not a list") + self.assertEqual(deep_output, parsed_output) + + def test_submodel_without_id_short_ok(self): + submodel = example_aas_mandatory_attributes.create_example_submodel() - self.assertIn("ExampleSubmodelCollection.ExampleBlob", parsed_output) - self.assertIn("ExampleSubmodelCollection.ExampleFile", parsed_output) - self.assertIn("ExampleSubmodelCollection.ExampleFileURI", parsed_output) - self.assertIn("ExampleSubmodelCollection.ExampleMultiLanguageProperty", parsed_output) - self.assertIn("ExampleSubmodelCollection.ExampleRange", parsed_output) - self.assertIn("ExampleSubmodelCollection.ExampleReferenceElement", parsed_output) - self.assertIn("ExampleSubmodelCollection.ExampleSubmodelList", parsed_output) + out_str = json.dumps(submodel, cls=PathJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertCountEqual([ + "ExampleRelationshipElement", + "ExampleAnnotatedRelationshipElement", + "ExampleOperation", + "ExampleCapability", + "ExampleBasicEventElement", + "ExampleSubmodelList", + "ExampleSubmodelList[0]", + "ExampleSubmodelList[0].ExampleBlob", + "ExampleSubmodelList[0].ExampleFile", + "ExampleSubmodelList[0].ExampleMultiLanguageProperty", + "ExampleSubmodelList[0].ExampleProperty", + "ExampleSubmodelList[0].ExampleRange", + "ExampleSubmodelList[0].ExampleReferenceElement", + "ExampleSubmodelList[1]", + "ExampleSubmodelList2", + ], parsed_output) - def test_submodel_list_deep(self): - sm = example_aas.create_example_submodel() + def test_empty_submodel_ok(self): + submodel = example_aas_mandatory_attributes.create_example_empty_submodel() - out_str = json.dumps(sm, cls=PathJsonEncoder) + out_str = json.dumps(submodel, cls=PathJsonEncoder) parsed_output = json.loads(out_str) - if not isinstance(parsed_output, list): - self.fail("Path serialization is not a list") + self.assertEqual([], parsed_output) - self.assertIn("ExampleSubmodelCollection.ExampleSubmodelList[0]", parsed_output) - self.assertIn("ExampleSubmodelCollection.ExampleSubmodelList[1]", parsed_output) + def test_submodel_element_collection_deep_ok(self): + smc = self.submodel.get_referable("ExampleSubmodelCollection") + + out_str = json.dumps(smc, cls=PathJsonEncoder) + parsed_output = json.loads(out_str) - def test_annotation_deep(self): - sm = example_aas.create_example_submodel() + self.assertCountEqual([ + "ExampleSubmodelCollection", + "ExampleSubmodelCollection.ExampleBlob", + "ExampleSubmodelCollection.ExampleFile", + "ExampleSubmodelCollection.ExampleFileURI", + "ExampleSubmodelCollection.ExampleMultiLanguageProperty", + "ExampleSubmodelCollection.ExampleRange", + "ExampleSubmodelCollection.ExampleReferenceElement", + "ExampleSubmodelCollection.ExampleSubmodelList", + "ExampleSubmodelCollection.ExampleSubmodelList[0]", + "ExampleSubmodelCollection.ExampleSubmodelList[1]", + ], parsed_output) - out_str = json.dumps(sm, cls=PathJsonEncoder) + def test_submodel_element_collection_core_ok(self): + smc = self.submodel.get_referable("ExampleSubmodelCollection") + + out_str = json.dumps(smc, cls=PathJsonEncoder, deep=False) parsed_output = json.loads(out_str) - if not isinstance(parsed_output, list): - self.fail("Path serialization is not a list") + self.assertCountEqual([ + "ExampleSubmodelCollection", + "ExampleSubmodelCollection.ExampleBlob", + "ExampleSubmodelCollection.ExampleFile", + "ExampleSubmodelCollection.ExampleFileURI", + "ExampleSubmodelCollection.ExampleMultiLanguageProperty", + "ExampleSubmodelCollection.ExampleRange", + "ExampleSubmodelCollection.ExampleReferenceElement", + "ExampleSubmodelCollection.ExampleSubmodelList", + ], parsed_output) + + def test_submodel_element_list_deep_ok(self): + submodel = example_aas_mandatory_attributes.create_example_submodel() + sml = submodel.get_referable("ExampleSubmodelList") + + out_str = json.dumps(sml, cls=PathJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertCountEqual([ + "ExampleSubmodelList", + "ExampleSubmodelList[0]", + "ExampleSubmodelList[0].ExampleBlob", + "ExampleSubmodelList[0].ExampleFile", + "ExampleSubmodelList[0].ExampleMultiLanguageProperty", + "ExampleSubmodelList[0].ExampleProperty", + "ExampleSubmodelList[0].ExampleRange", + "ExampleSubmodelList[0].ExampleReferenceElement", + "ExampleSubmodelList[1]", + ], parsed_output) + + def test_submodel_element_list_core_ok(self): + submodel = example_aas_mandatory_attributes.create_example_submodel() + sml = submodel.get_referable("ExampleSubmodelList") - self.assertIn("ExampleAnnotatedRelationshipElement.ExampleAnnotatedProperty", parsed_output) - self.assertIn("ExampleAnnotatedRelationshipElement.ExampleAnnotatedRange", parsed_output) + out_str = json.dumps(sml, cls=PathJsonEncoder, deep=False) + parsed_output = json.loads(out_str) + + self.assertCountEqual([ + "ExampleSubmodelList", + "ExampleSubmodelList[0]", + "ExampleSubmodelList[1]", + ], parsed_output) - def test_nested_element(self): - sm = example_aas.create_example_submodel() - sml = sm.get_referable("ExampleSubmodelCollection.ExampleSubmodelList") + def test_empty_submodel_element_list_ok(self): + submodel = example_aas_mandatory_attributes.create_example_submodel() + sml = submodel.get_referable("ExampleSubmodelList2") out_str = json.dumps(sml, cls=PathJsonEncoder) parsed_output = json.loads(out_str) - if not isinstance(parsed_output, list): - self.fail("Path serialization is not a list") + self.assertEqual(["ExampleSubmodelList2"], parsed_output) - self.assertIn("ExampleSubmodelList[0]", parsed_output) - self.assertIn("ExampleSubmodelList[1]", parsed_output) + def test_entity_ok(self): + submodel = example_aas.create_example_bill_of_material_submodel() + entity = submodel.get_referable("ExampleEntity") - def test_error_on_unsupported_types(self): - aas = example_aas.create_example_asset_administration_shell() + out_str = json.dumps(entity, cls=PathJsonEncoder) + parsed_output = json.loads(out_str) - with self.assertRaises(TypeError): - json.dumps(aas, cls=PathJsonEncoder) + self.assertCountEqual([ + "ExampleEntity", + "ExampleEntity.ExampleProperty", + "ExampleEntity.ExampleProperty2", + ], parsed_output) - def test_level_core(self): - sm = example_aas.create_example_submodel() + def test_submodel_with_entity_deep_ok(self): + submodel = example_aas.create_example_bill_of_material_submodel() - out_str = json.dumps(sm, cls=ShallowPathJsonEncoder) + out_str = json.dumps(submodel, cls=PathJsonEncoder) parsed_output = json.loads(out_str) - if not isinstance(parsed_output, list): - self.fail("Path serialization is not a list") + self.assertCountEqual([ + "ExampleEntity", + "ExampleEntity.ExampleProperty", + "ExampleEntity.ExampleProperty2", + "ExampleEntity2", + ], parsed_output) + + def test_submodel_with_entity_core_ok(self): + submodel = example_aas.create_example_bill_of_material_submodel() + + out_str = json.dumps(submodel, cls=PathJsonEncoder, deep=False) + parsed_output = json.loads(out_str) + + self.assertCountEqual(["ExampleEntity", "ExampleEntity2"], parsed_output) + + def test_empty_entity_ok(self): + submodel = example_aas.create_example_bill_of_material_submodel() + entity = submodel.get_referable("ExampleEntity2") + + out_str = json.dumps(entity, cls=PathJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertEqual(["ExampleEntity2"], parsed_output) + + def test_annotated_relationship_element_ok(self): + are = self.submodel.get_referable("ExampleAnnotatedRelationshipElement") - self.assertIn("ExampleOperation", parsed_output) - self.assertIn("ExampleCapability", parsed_output) - self.assertIn("ExampleBasicEventElement", parsed_output) - self.assertIn("ExampleRelationshipElement", parsed_output) - self.assertIn("ExampleAnnotatedRelationshipElement", parsed_output) - self.assertIn("ExampleSubmodelCollection", parsed_output) - self.assertNotIn("ExampleSubmodelCollection.ExampleBlob", parsed_output) - self.assertNotIn("ExampleSubmodelCollection.ExampleSubmodelList[0]", parsed_output) - self.assertNotIn("ExampleAnnotatedRelationshipElement.ExampleAnnotatedProperty", parsed_output) + out_str = json.dumps(are, cls=PathJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertCountEqual([ + "ExampleAnnotatedRelationshipElement", + "ExampleAnnotatedRelationshipElement.ExampleAnnotatedProperty", + "ExampleAnnotatedRelationshipElement.ExampleAnnotatedRange", + ], parsed_output) + + def test_annotated_relationship_element_without_annotation_ok(self): + submodel = example_aas_mandatory_attributes.create_example_submodel() + are = submodel.get_referable("ExampleAnnotatedRelationshipElement") + + out_str = json.dumps(are, cls=PathJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertEqual(["ExampleAnnotatedRelationshipElement"], parsed_output) + + def test_operation_variables_not_included_ok(self): + operation = self.submodel.get_referable("ExampleOperation") + + out_str = json.dumps(operation, cls=PathJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertEqual(["ExampleOperation"], parsed_output) + + def test_leaf_element_ok(self): + range_element = self.submodel.get_referable("ExampleSubmodelCollection.ExampleRange") + + out_str = json.dumps(range_element, cls=PathJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertEqual(["ExampleRange"], parsed_output) + + def test_submodel_element_list_child_fail(self): + submodel = example_aas_mandatory_attributes.create_example_submodel() + sml = submodel.get_referable("ExampleSubmodelList") + if not isinstance(sml, model.SubmodelElementList): + self.fail("Expected `ExampleSubmodelList` to be of type `SubmodelElementList`.") + sml_child = sml.value[0] + + with self.assertRaises(ValueError): + json.dumps(sml_child, cls=PathJsonEncoder) + + def test_element_without_id_short_fail(self): + prop = model.Property(None, model.datatypes.String) + + with self.assertRaises(ValueError): + json.dumps(prop, cls=PathJsonEncoder) + + def test_unsupported_type_fail(self): + unsupported = object() + + with self.assertRaises(TypeError): + json.dumps(unsupported, cls=PathJsonEncoder) + + def test_asset_administration_shell_fail(self): + aas = example_aas.create_example_asset_administration_shell() + + with self.assertRaises(TypeError): + json.dumps(aas, cls=PathJsonEncoder) + + def test_concept_description_fail(self): + concept_description = example_aas.create_example_concept_description() + + with self.assertRaises(TypeError): + json.dumps(concept_description, cls=PathJsonEncoder) From c04a436baa760b489e5b021389b8a7dc9e563ac6 Mon Sep 17 00:00:00 2001 From: hpoeche Date: Sun, 25 Oct 2026 20:02:11 +0100 Subject: [PATCH 14/22] Implement modifier `level=core` for Normal and ValueOnly These changes implement a core serialization for the `AASToJsonEncoder` that is backward compatible to custom subclasses of this class. For all five types, that are affected by core serialization, addtional methods were introduced that call the standard serialization method and afterwards serialize all children, omitting the grandchildren. This solution allows us to pass the `deep` parameter via the `__init__()` method, which helps to avoid addtional subclass constructs. The `ValueOnlyJsonEncoder` overwrites these methods to also implement core serialization. Test are written for both classes in core serialization mode. --- sdk/basyx/aas/adapter/json/__init__.py | 4 +- .../aas/adapter/json/json_serialization.py | 139 ++++ .../adapter/json/value_only_serialization.py | 114 ++- .../adapter/json/test_core_serialization.py | 276 +++++++ .../json/test_value_only_serialization.py | 723 ++++++++++++------ server/app/interfaces/base.py | 19 +- 6 files changed, 1026 insertions(+), 249 deletions(-) create mode 100644 sdk/test/adapter/json/test_core_serialization.py diff --git a/sdk/basyx/aas/adapter/json/__init__.py b/sdk/basyx/aas/adapter/json/__init__.py index f927a28ed..8a9ee781f 100644 --- a/sdk/basyx/aas/adapter/json/__init__.py +++ b/sdk/basyx/aas/adapter/json/__init__.py @@ -34,7 +34,7 @@ from .metadata_serialization import MetadataJsonEncoder from .path_serialization import PathJsonEncoder from .reference_serialization import ReferenceJsonEncoder -from .value_only_serialization import AASToValueOnlyJsonEncoder +from .value_only_serialization import ValueOnlyJsonEncoder __all__ = [ "AASFromJsonDecoder", @@ -48,7 +48,7 @@ "object_store_to_json", "write_aas_json_file", "PathJsonEncoder", - "AASToValueOnlyJsonEncoder", + "ValueOnlyJsonEncoder", "MetadataJsonEncoder", "ReferenceJsonEncoder", ] diff --git a/sdk/basyx/aas/adapter/json/json_serialization.py b/sdk/basyx/aas/adapter/json/json_serialization.py index 155aced55..43f833057 100644 --- a/sdk/basyx/aas/adapter/json/json_serialization.py +++ b/sdk/basyx/aas/adapter/json/json_serialization.py @@ -13,6 +13,8 @@ to be used with the Python standard :mod:`json` module. While the former serializes objects as defined in the specification, the latter serializes stripped objects, excluding some attributes (see https://git.rwth-aachen.de/acplt/pyi40aas/-/issues/91). +Additionally, :class:`CoreAASToJsonEncoder` serializes objects with the serialization level ``core`` of the AAS API +specification, only including the serialized object and its direct children. Each class contains a custom :meth:`~.AASToJsonEncoder.default` function which converts BaSyx Python SDK objects to simple python types for an automatic JSON serialization. To simplify the usage of this module, the :meth:`write_aas_json_file` and :meth:`object_store_to_json` are provided. @@ -64,13 +66,23 @@ class AASToJsonEncoder(json.JSONEncoder): json_string = json.dumps(data, cls=AASToJsonEncoder) + To extend the encoder, override the respective ``__to_json()`` methods or the mapping returned by + ``_get_aas_class_serializers()``. Override ``_get_aas_class_no_children_serializers()`` to adapt ``deep=False``. + :cvar stripped: If True, the JSON objects will be serialized in a stripped manner, excluding some attributes. Defaults to ``False``. See https://git.rwth-aachen.de/acplt/pyi40aas/-/issues/91 + :param deep: If True (default), all children of the serialized object are included on all sublevels. If False, + only the serialized object and its direct children are included, while the children of these are + omitted. This corresponds to the serialization level ``core`` of the AAS API specification. """ stripped = False + def __init__(self, *args, deep: bool = True, **kwargs): + super().__init__(*args, **kwargs) + self.deep = deep + @classmethod def _get_aas_class_serializers(cls) -> Dict[Type, Callable]: mapping: Dict[Type, Callable] = { @@ -105,6 +117,28 @@ def _get_aas_class_serializers(cls) -> Dict[Type, Callable]: } return mapping + @classmethod + def _get_aas_class_no_children_serializers(cls) -> Dict[Type, Callable]: + mapping: Dict[Type, Callable] = { + model.AnnotatedRelationshipElement: cls._annotated_relationship_element_to_json_no_children, + model.Entity: cls._entity_to_json_no_children, + model.Submodel: cls._submodel_to_json_no_children, + model.SubmodelElementCollection: cls._submodel_element_collection_to_json_no_children, + model.SubmodelElementList: cls._submodel_element_list_to_json_no_children, + } + return mapping + + @classmethod + def _get_aas_class_children_keys(cls) -> Dict[Type, str]: + mapping: Dict[Type, str] = { + model.AnnotatedRelationshipElement: "annotations", + model.Entity: "statements", + model.Submodel: "submodelElements", + model.SubmodelElementCollection: "value", + model.SubmodelElementList: "value", + } + return mapping + def default(self, obj: object) -> object: """ The overwritten ``default`` method for :class:`json.JSONEncoder` @@ -112,6 +146,12 @@ def default(self, obj: object) -> object: :param obj: The object to serialize to json :return: The serialized object """ + if not self.deep: + no_children_mapping = self._get_aas_class_no_children_serializers() + for typ in no_children_mapping: + if isinstance(obj, typ): + no_children_mapping_method = no_children_mapping[typ] + return no_children_mapping_method(obj) mapping = self._get_aas_class_serializers() for typ in mapping: if isinstance(obj, typ): @@ -750,6 +790,105 @@ def _basic_event_element_to_json( data["maxInterval"] = model.datatypes.xsd_repr(obj.max_interval) return data + # ############################################################# + # transformation functions to serialize with deep=False + # ############################################################# + + @classmethod + def _remove_children(cls, obj: object) -> object: + """ + Serialization of a child element without its own children + + :param obj: The child element to serialize + :return: dict with the serialized attributes of the child element without its children or the child element + itself, if it cannot contain children + """ + mapping = cls._get_aas_class_serializers() + children_keys = cls._get_aas_class_children_keys() + for typ in children_keys: + if isinstance(obj, typ): + data = mapping[typ](obj) + data.pop(children_keys[typ], None) + return data + return obj + + @classmethod + def _submodel_to_json_no_children(cls, obj: model.Submodel) -> Dict[str, object]: + """ + serialization of an object from class Submodel to json, omitting the children of its submodel elements + + :param obj: object of class Submodel + :return: dict with the serialized attributes of this object + """ + data = cls._get_aas_class_serializers()[model.Submodel](obj) + children = data.get("submodelElements") + if isinstance(children, list): + data["submodelElements"] = [cls._remove_children(child) for child in children] + return data + + @classmethod + def _submodel_element_collection_to_json_no_children( + cls, obj: model.SubmodelElementCollection + ) -> Dict[str, object]: + """ + serialization of an object from class SubmodelElementCollection to json, omitting the children of its value + + :param obj: object of class SubmodelElementCollection + :return: dict with the serialized attributes of this object + """ + data = cls._get_aas_class_serializers()[model.SubmodelElementCollection](obj) + children = data.get("value") + if isinstance(children, list): + data["value"] = [cls._remove_children(child) for child in children] + return data + + @classmethod + def _submodel_element_list_to_json_no_children( + cls, obj: model.SubmodelElementList + ) -> Dict[str, object]: + """ + serialization of an object from class SubmodelElementList to json, omitting the children of its value + + :param obj: object of class SubmodelElementList + :return: dict with the serialized attributes of this object + """ + data = cls._get_aas_class_serializers()[model.SubmodelElementList](obj) + children = data.get("value") + if isinstance(children, list): + data["value"] = [cls._remove_children(child) for child in children] + return data + + @classmethod + def _annotated_relationship_element_to_json_no_children( + cls, obj: model.AnnotatedRelationshipElement + ) -> Dict[str, object]: + """ + serialization of an object from class AnnotatedRelationshipElement to json, omitting the children of its + annotations + + :param obj: object of class AnnotatedRelationshipElement + :return: dict with the serialized attributes of this object + """ + data = cls._get_aas_class_serializers()[model.AnnotatedRelationshipElement](obj) + children = data.get("annotations") + if isinstance(children, list): + data["annotations"] = [cls._remove_children(child) for child in children] + return data + + @classmethod + def _entity_to_json_no_children(cls, obj: model.Entity) -> Dict[str, object]: + """ + serialization of an object from class Entity to json, omitting the children of its statements + + :param obj: object of class Entity + :return: dict with the serialized attributes of this object + """ + data = cls._get_aas_class_serializers()[model.Entity](obj) + children = data.get("statements") + if isinstance(children, list): + data["statements"] = [cls._remove_children(child) for child in children] + return data + class StrippedAASToJsonEncoder(AASToJsonEncoder): """ diff --git a/sdk/basyx/aas/adapter/json/value_only_serialization.py b/sdk/basyx/aas/adapter/json/value_only_serialization.py index 1699eb483..47ac8aa8a 100644 --- a/sdk/basyx/aas/adapter/json/value_only_serialization.py +++ b/sdk/basyx/aas/adapter/json/value_only_serialization.py @@ -13,8 +13,30 @@ from basyx.aas.adapter.json import AASToJsonEncoder TYPES_WITHOUT_VALUE_ONLY = (model.Capability, model.Operation) +CONTAINER_TYPES = (model.SubmodelElementCollection, model.SubmodelElementList) -class AASToValueOnlyJsonEncoder(AASToJsonEncoder): + +class ValueOnlyJsonEncoder(AASToJsonEncoder): + """ + Custom JSONEncoder that serializes BaSyx Python SDK objects into the JSON format "ValueOnly" as defined by the + specification. + + .. note:: + + The specification contradicts itself at some points. In these cases the following assumptions were made: + + * ``annotations`` of an AnnotatedRelationshipElement and ``statements`` of an Entity are serialized as JSON + object with the idShorts as names, as defined in the schema and the Entity example, instead of an array, + as stated in the text. + * ``specificAssetIds`` of an Entity are serialized as array of SpecificAssetIds in format "Normal", as stated + in the text, instead of objects with string values only, as defined in the schema. + * ``globalAssetId`` of an Entity is serialized as string, as defined in the schema and the metamodel, instead + of a Reference, as shown in the Entity example. + + :param deep: If True (default), all children of the serialized object are included on all sublevels. If False, + only the serialized object and its direct children are included without their values. Compare to + serialization level ``core``. + """ @classmethod def _get_aas_class_serializers(cls) -> dict[type, Callable]: @@ -43,6 +65,22 @@ def _get_aas_class_serializers(cls) -> dict[type, Callable]: return serializers + @classmethod + def _get_aas_class_no_children_serializers(cls) -> dict[type, Callable]: + serializers = super()._get_aas_class_no_children_serializers() + + serializers.update( + { + model.Submodel: cls._submodel_to_value_only_no_children, + model.SubmodelElementList: cls._submodel_element_list_to_value_only_no_children, + model.SubmodelElementCollection: cls._submodel_element_collection_to_value_only_no_children, + model.AnnotatedRelationshipElement: cls._annotated_relationship_element_to_value_only_no_children, + model.Entity: cls._entity_to_value_only_no_children, + } + ) + + return serializers + @classmethod def _has_value(cls, obj: model.SubmodelElement) -> bool: if isinstance(obj, TYPES_WITHOUT_VALUE_ONLY): @@ -62,8 +100,9 @@ def _has_value(cls, obj: model.SubmodelElement) -> bool: if isinstance(obj, model.RelationshipElement): return obj.first is not None or obj.second is not None if isinstance(obj, model.Entity): - return obj.global_asset_id is not None or bool(obj.specific_asset_id) - if isinstance(obj, (model.SubmodelElementList, model.SubmodelElementCollection)): + return (obj.global_asset_id is not None or bool(obj.specific_asset_id) + or any(cls._has_value(statement) for statement in obj.statement)) + if isinstance(obj, CONTAINER_TYPES): return any(cls._has_value(element) for element in obj.value) return True @@ -177,3 +216,72 @@ def _entity_to_value_only(cls, obj: model.Entity) -> object: def _basic_event_element_to_value_only(cls, obj: model.BasicEventElement) -> dict[str, object]: data: dict[str, object] = {"observed": obj.observed} return data + + # ############################################################# + # transformation functions to serialize with deep=False + # ############################################################# + + @classmethod + def _remove_children(cls, obj: object) -> object: + # The ValueOnly representation of SubmodelElementCollections and SubmodelElementLists consists only of their + # children + if isinstance(obj, model.SubmodelElementCollection): + return {} + if isinstance(obj, model.SubmodelElementList): + return [] + return super()._remove_children(obj) + + @classmethod + def _remove_children_of_elements(cls, data: dict[str, object]) -> dict[str, object]: + return {id_short: cls._remove_children(element) for id_short, element in data.items()} + + @classmethod + def _submodel_to_value_only_no_children(cls, obj: model.Submodel) -> dict[str, object]: + data = cls._get_aas_class_serializers()[model.Submodel](obj) + # Direct child SubmodelElementCollections and SubmodelElementLists are always serialized with an empty value, + # even if they contain no elements with a value + for element in obj.submodel_element: + if isinstance(element, CONTAINER_TYPES) and element.id_short is not None: + data.setdefault(element.id_short, element) + return cls._remove_children_of_elements(data) + + @classmethod + def _submodel_element_list_to_value_only_no_children(cls, obj: model.SubmodelElementList) -> list[object]: + data = cls._get_aas_class_serializers()[model.SubmodelElementList](obj) + return [cls._remove_children(element) for element in data] + + @classmethod + def _submodel_element_collection_to_value_only_no_children( + cls, obj: model.SubmodelElementCollection + ) -> dict[str, object]: + data = cls._get_aas_class_serializers()[model.SubmodelElementCollection](obj) + # Direct child SubmodelElementCollections and SubmodelElementLists are always serialized with an empty value, + # even if they contain no elements with a value + for element in obj.value: + if isinstance(element, CONTAINER_TYPES) and element.id_short is not None: + data.setdefault(element.id_short, element) + return cls._remove_children_of_elements(data) + + @classmethod + def _annotated_relationship_element_to_value_only_no_children( + cls, obj: model.AnnotatedRelationshipElement + ) -> dict[str, object]: + data = cls._get_aas_class_serializers()[model.AnnotatedRelationshipElement](obj) + annotations = data.get("annotations") + if isinstance(annotations, dict): + # No checking for child SubmodelElementCollections and SubmodelElementLists neccessary + data["annotations"] = cls._remove_children_of_elements(annotations) + return data + + @classmethod + def _entity_to_value_only_no_children(cls, obj: model.Entity) -> dict[str, object]: + data = cls._get_aas_class_serializers()[model.Entity](obj) + statements = data.get("statements") + if isinstance(statements, dict): + # Direct child SubmodelElementCollections and SubmodelElementLists are always serialized with an empty + # value, even if they contain no elements with a value + for element in obj.statement: + if isinstance(element, CONTAINER_TYPES) and element.id_short is not None: + statements.setdefault(element.id_short, element) + data["statements"] = cls._remove_children_of_elements(statements) + return data diff --git a/sdk/test/adapter/json/test_core_serialization.py b/sdk/test/adapter/json/test_core_serialization.py new file mode 100644 index 000000000..0069dc4ab --- /dev/null +++ b/sdk/test/adapter/json/test_core_serialization.py @@ -0,0 +1,276 @@ +# Copyright (c) 2026 the Eclipse BaSyx Authors +# +# This program and the accompanying materials are made available under the terms of the MIT License, available in +# the LICENSE file of this project. +# +# SPDX-License-Identifier: MIT +import json +import unittest + +from basyx.aas import model +from basyx.aas.adapter.json import AASToJsonEncoder, StrippedAASToJsonEncoder +from basyx.aas.examples.data import example_aas, example_aas_mandatory_attributes + + +class TestAASToJsonEncoderCore(unittest.TestCase): + def setUp(self): + self.submodel = example_aas.create_example_submodel() + self.deep_submodel = json.loads(json.dumps(self.submodel, cls=AASToJsonEncoder)) + + def test_deep_is_default_ok(self): + deep_output = json.loads(json.dumps(self.submodel, cls=AASToJsonEncoder, deep=True)) + + out_str = json.dumps(self.submodel, cls=AASToJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertEqual(deep_output, parsed_output) + + def test_deep_keeps_grandchildren_ok(self): + smc = self.submodel.get_referable("ExampleSubmodelCollection") + + out_str = json.dumps(smc, cls=AASToJsonEncoder, deep=True) + parsed_output = json.loads(out_str) + + children = {element["idShort"]: element for element in parsed_output["value"]} + self.assertEqual(2, len(children["ExampleSubmodelList"]["value"])) + + def test_submodel_ok(self): + out_str = json.dumps(self.submodel, cls=AASToJsonEncoder, deep=False) + parsed_output = json.loads(out_str) + + self.assertCountEqual([ + "ExampleRelationshipElement", + "ExampleAnnotatedRelationshipElement", + "ExampleOperation", + "ExampleCapability", + "ExampleBasicEventElement", + "ExampleSubmodelCollection", + ], [element["idShort"] for element in parsed_output["submodelElements"]]) + + def test_submodel_keeps_own_attributes_ok(self): + expected = {key: value for key, value in self.deep_submodel.items() if key != "submodelElements"} + + out_str = json.dumps(self.submodel, cls=AASToJsonEncoder, deep=False) + parsed_output = json.loads(out_str) + + parsed_output.pop("submodelElements") + self.assertEqual(expected, parsed_output) + + def test_submodel_omits_submodel_element_collection_children_ok(self): + deep_children = {element["idShort"]: element for element in self.deep_submodel["submodelElements"]} + expected = deep_children["ExampleSubmodelCollection"] + expected.pop("value") + + out_str = json.dumps(self.submodel, cls=AASToJsonEncoder, deep=False) + parsed_output = json.loads(out_str) + + children = {element["idShort"]: element for element in parsed_output["submodelElements"]} + self.assertEqual(expected, children["ExampleSubmodelCollection"]) + + def test_submodel_omits_annotated_relationship_element_annotations_ok(self): + deep_children = {element["idShort"]: element for element in self.deep_submodel["submodelElements"]} + expected = deep_children["ExampleAnnotatedRelationshipElement"] + expected.pop("annotations") + + out_str = json.dumps(self.submodel, cls=AASToJsonEncoder, deep=False) + parsed_output = json.loads(out_str) + + children = {element["idShort"]: element for element in parsed_output["submodelElements"]} + self.assertEqual(expected, children["ExampleAnnotatedRelationshipElement"]) + + def test_submodel_keeps_leaf_children_ok(self): + deep_children = {element["idShort"]: element for element in self.deep_submodel["submodelElements"]} + + out_str = json.dumps(self.submodel, cls=AASToJsonEncoder, deep=False) + parsed_output = json.loads(out_str) + + children = {element["idShort"]: element for element in parsed_output["submodelElements"]} + self.assertEqual(deep_children["ExampleRelationshipElement"], children["ExampleRelationshipElement"]) + self.assertEqual(deep_children["ExampleOperation"], children["ExampleOperation"]) + self.assertEqual(deep_children["ExampleCapability"], children["ExampleCapability"]) + self.assertEqual(deep_children["ExampleBasicEventElement"], children["ExampleBasicEventElement"]) + + def test_submodel_omits_entity_statements_ok(self): + submodel = example_aas.create_example_bill_of_material_submodel() + deep_output = json.loads(json.dumps(submodel, cls=AASToJsonEncoder)) + deep_children = {element["idShort"]: element for element in deep_output["submodelElements"]} + expected = deep_children["ExampleEntity"] + expected.pop("statements") + + out_str = json.dumps(submodel, cls=AASToJsonEncoder, deep=False) + parsed_output = json.loads(out_str) + + children = {element["idShort"]: element for element in parsed_output["submodelElements"]} + self.assertEqual(expected, children["ExampleEntity"]) + self.assertEqual(deep_children["ExampleEntity2"], children["ExampleEntity2"]) + + def test_empty_submodel_ok(self): + submodel = example_aas_mandatory_attributes.create_example_empty_submodel() + + out_str = json.dumps(submodel, cls=AASToJsonEncoder, deep=False) + parsed_output = json.loads(out_str) + + self.assertNotIn("submodelElements", parsed_output) + + def test_submodel_element_collection_ok(self): + smc = self.submodel.get_referable("ExampleSubmodelCollection") + deep_output = json.loads(json.dumps(smc, cls=AASToJsonEncoder)) + deep_children = {element["idShort"]: element for element in deep_output["value"]} + deep_children["ExampleSubmodelList"].pop("value") + + out_str = json.dumps(smc, cls=AASToJsonEncoder, deep=False) + parsed_output = json.loads(out_str) + + children = {element["idShort"]: element for element in parsed_output["value"]} + self.assertEqual(deep_children, children) + + def test_submodel_element_list_ok(self): + submodel = example_aas_mandatory_attributes.create_example_submodel() + sml = submodel.get_referable("ExampleSubmodelList") + deep_output = json.loads(json.dumps(sml, cls=AASToJsonEncoder)) + expected = deep_output["value"] + expected[0].pop("value") + + out_str = json.dumps(sml, cls=AASToJsonEncoder, deep=False) + parsed_output = json.loads(out_str) + + self.assertEqual(expected, parsed_output["value"]) + + def test_empty_submodel_element_list_ok(self): + submodel = example_aas_mandatory_attributes.create_example_submodel() + sml = submodel.get_referable("ExampleSubmodelList2") + + out_str = json.dumps(sml, cls=AASToJsonEncoder, deep=False) + parsed_output = json.loads(out_str) + + self.assertNotIn("value", parsed_output) + + def test_entity_ok(self): + submodel = example_aas.create_example_bill_of_material_submodel() + entity = submodel.get_referable("ExampleEntity") + deep_output = json.loads(json.dumps(entity, cls=AASToJsonEncoder)) + + out_str = json.dumps(entity, cls=AASToJsonEncoder, deep=False) + parsed_output = json.loads(out_str) + + self.assertEqual(deep_output, parsed_output) + + def test_annotated_relationship_element_ok(self): + are = self.submodel.get_referable("ExampleAnnotatedRelationshipElement") + deep_output = json.loads(json.dumps(are, cls=AASToJsonEncoder)) + + out_str = json.dumps(are, cls=AASToJsonEncoder, deep=False) + parsed_output = json.loads(out_str) + + self.assertEqual(deep_output, parsed_output) + + def test_leaf_element_ok(self): + range_element = self.submodel.get_referable("ExampleSubmodelCollection.ExampleRange") + deep_output = json.loads(json.dumps(range_element, cls=AASToJsonEncoder)) + + out_str = json.dumps(range_element, cls=AASToJsonEncoder, deep=False) + parsed_output = json.loads(out_str) + + self.assertEqual(deep_output, parsed_output) + + def test_core_is_relative_to_each_top_level_object_ok(self): + smc = self.submodel.get_referable("ExampleSubmodelCollection") + + out_str = json.dumps([self.submodel, smc], cls=AASToJsonEncoder, deep=False) + parsed_output = json.loads(out_str) + + submodel_output, smc_output = parsed_output + submodel_children = {element["idShort"]: element for element in submodel_output["submodelElements"]} + self.assertNotIn("value", submodel_children["ExampleSubmodelCollection"]) + self.assertEqual(7, len(smc_output["value"])) + + def test_core_with_indent_ok(self): + compact_output = json.loads(json.dumps(self.submodel, cls=AASToJsonEncoder, deep=False)) + + out_str = json.dumps(self.submodel, cls=AASToJsonEncoder, deep=False, indent=2) + parsed_output = json.loads(out_str) + + self.assertEqual(compact_output, parsed_output) + + def test_stripped_core_ok(self): + stripped_output = json.loads(json.dumps(self.submodel, cls=StrippedAASToJsonEncoder)) + + out_str = json.dumps(self.submodel, cls=StrippedAASToJsonEncoder, deep=False) + parsed_output = json.loads(out_str) + + self.assertEqual(stripped_output, parsed_output) + + def test_subclass_override_applies_to_children_ok(self): + class CustomCoreEncoder(AASToJsonEncoder): + @classmethod + def _submodel_element_collection_to_json(cls, obj): + data = super()._submodel_element_collection_to_json(obj) + data["custom"] = True + return data + + out_str = json.dumps(self.submodel, cls=CustomCoreEncoder, deep=False) + parsed_output = json.loads(out_str) + + children = {element["idShort"]: element for element in parsed_output["submodelElements"]} + self.assertTrue(children["ExampleSubmodelCollection"]["custom"]) + self.assertNotIn("value", children["ExampleSubmodelCollection"]) + + def test_subclass_override_applies_to_requested_object_ok(self): + class CustomCoreEncoder(AASToJsonEncoder): + @classmethod + def _submodel_element_collection_to_json(cls, obj): + data = super()._submodel_element_collection_to_json(obj) + data["custom"] = True + return data + + smc = self.submodel.get_referable("ExampleSubmodelCollection") + + out_str = json.dumps(smc, cls=CustomCoreEncoder, deep=False) + parsed_output = json.loads(out_str) + + self.assertTrue(parsed_output["custom"]) + self.assertEqual(7, len(parsed_output["value"])) + + def test_subclass_deep_override_ok(self): + class CustomEncoder(AASToJsonEncoder): + @classmethod + def _submodel_element_collection_to_json(cls, obj): + data = super()._submodel_element_collection_to_json(obj) + data["childTypes"] = [type(element).__name__ for element in data["value"]] + return data + + smc = self.submodel.get_referable("ExampleSubmodelCollection") + + out_str = json.dumps(smc, cls=CustomEncoder) + parsed_output = json.loads(out_str) + + self.assertCountEqual([ + "Blob", + "File", + "File", + "MultiLanguageProperty", + "Range", + "ReferenceElement", + "SubmodelElementList", + ], parsed_output["childTypes"]) + + def test_subclass_mapping_override_applies_to_children_ok(self): + class CustomCoreEncoder(AASToJsonEncoder): + @classmethod + def _get_aas_class_serializers(cls): + mapping = super()._get_aas_class_serializers() + mapping[model.SubmodelElementCollection] = cls._custom_collection_to_json + return mapping + + @classmethod + def _custom_collection_to_json(cls, obj): + data = cls._submodel_element_collection_to_json(obj) + data["custom"] = True + return data + + out_str = json.dumps(self.submodel, cls=CustomCoreEncoder, deep=False) + parsed_output = json.loads(out_str) + + children = {element["idShort"]: element for element in parsed_output["submodelElements"]} + self.assertTrue(children["ExampleSubmodelCollection"]["custom"]) + self.assertNotIn("value", children["ExampleSubmodelCollection"]) diff --git a/sdk/test/adapter/json/test_value_only_serialization.py b/sdk/test/adapter/json/test_value_only_serialization.py index 72d6a39f9..68bd6227f 100644 --- a/sdk/test/adapter/json/test_value_only_serialization.py +++ b/sdk/test/adapter/json/test_value_only_serialization.py @@ -4,288 +4,551 @@ # the LICENSE file of this project. # # SPDX-License-Identifier: MIT +import decimal import json +import math import unittest from basyx.aas import model -from basyx.aas.adapter.json.value_only_serialization import AASToValueOnlyJsonEncoder - - -class TestValueOnlyEncoder(unittest.TestCase): - def test_omits_empty(self): - sm = model.Submodel(id_="http://example.org/TestingSM", id_short="TestingSM") - - prop1 = model.Property(id_short="TestProperty1", value_type=model.datatypes.String, value="TestValue1") - prop2 = model.Property(id_short="TestProperty2", value_type=model.datatypes.Boolean, value=True) - collection1 = model.SubmodelElementCollection(id_short="TestCollection", value=[prop1, prop2]) - - prop3 = model.Property(id_short="TestProperty3", value_type=model.datatypes.String) - mlp = model.MultiLanguageProperty(id_short="TestMLP") - rng1 = model.Range(id_short="TestRange", value_type=model.datatypes.Int) - file = model.File(id_short="TestFile", content_type="application/pdf") - blob = model.Blob(id_short="TestBlob") - ref = model.ReferenceElement(id_short="TestRef") - rel = model.RelationshipElement(id_short="TestRelationship") - entity = model.Entity(id_short="TestEntity", entity_type=model.EntityType.CO_MANAGED_ENTITY) - - sm_list = model.SubmodelElementList( - id_short="EmptyList", - type_value_list_element=model.Property, - value_type_list_element=model.datatypes.String, - value=[prop3], - ) - collection2 = model.SubmodelElementCollection( - id_short="EmptyCollection", value=[mlp, rng1, file, blob, ref, rel, entity] - ) +from basyx.aas.adapter.json.value_only_serialization import ValueOnlyJsonEncoder +from basyx.aas.examples.data import example_aas, example_aas_mandatory_attributes - sm.submodel_element.add(collection1) - sm.submodel_element.add(collection2) - sm.submodel_element.add(sm_list) +EXAMPLE_SUBMODEL_ID = "http://example.org/Test_Submodel" - sm_str = json.dumps(sm, cls=AASToValueOnlyJsonEncoder) - sm_dict = json.loads(sm_str) - self.assertIsInstance(sm_dict, dict) - self.assertNotIn("EmptyList", sm_dict) - self.assertNotIn("EmptyCollection", sm_dict) +class TestValueOnlyJsonEncoder(unittest.TestCase): + def setUp(self): + self.submodel = example_aas.create_example_submodel() - def test_property(self): - prop = model.Property(id_short="TestProperty", value_type=model.datatypes.Int, value=5000) + # ------------------------------------------------------------------ Submodel - out_str = json.dumps(prop, cls=AASToValueOnlyJsonEncoder) + def test_submodel_ok(self): + submodel = example_aas.create_example_asset_identification_submodel() + + out_str = json.dumps(submodel, cls=ValueOnlyJsonEncoder) parsed_output = json.loads(out_str) - self.assertEqual(5000, parsed_output) + self.assertEqual({"ManufacturerName": "ACPLT", "InstanceId": "978-8234-234-342"}, parsed_output) - def test_submodel_element_collection(self): - prop1 = model.Property( - id_short="ProductClassificationSystem", value_type=model.datatypes.String, value="ECLASS" - ) - prop2 = model.Property(id_short="ProductOwnership", value_type=model.datatypes.Boolean, value=True) - collection = model.SubmodelElementCollection(id_short="TestCollection", value=[prop1, prop2]) - - out_str = json.dumps(collection, cls=AASToValueOnlyJsonEncoder) - parsed_output = json.loads(out_str) - - self.assertIsInstance(parsed_output, dict) - self.assertEqual({"ProductClassificationSystem", "ProductOwnership"}, set(parsed_output.keys())) - self.assertEqual("ECLASS", parsed_output["ProductClassificationSystem"]) - self.assertEqual(True, parsed_output["ProductOwnership"]) - - def test_submodel_element_list(self): - prop1 = model.Property(id_short="Prop1", value_type=model.datatypes.String, value="Martha") - prop2 = model.Property(id_short="Prop2", value_type=model.datatypes.String, value="Jonathan") - prop3 = model.Property(id_short="Prop3", value_type=model.datatypes.String, value="Clark") - sme_list = model.SubmodelElementList( - id_short="Authors", - type_value_list_element=model.Property, - value_type_list_element=model.datatypes.String, - value=[prop1, prop2, prop3], - ) + def test_submodel_omits_operation_and_capability_ok(self): + out_str = json.dumps(self.submodel, cls=ValueOnlyJsonEncoder) + parsed_output = json.loads(out_str) - out_str = json.dumps(sme_list, cls=AASToValueOnlyJsonEncoder) + self.assertCountEqual([ + "ExampleRelationshipElement", + "ExampleAnnotatedRelationshipElement", + "ExampleBasicEventElement", + "ExampleSubmodelCollection", + ], parsed_output.keys()) + + def test_submodel_omits_elements_without_value_ok(self): + submodel = model.Submodel("https://example.org/Submodel", submodel_element=[ + model.Property("FilledProperty", model.datatypes.String, "exampleValue"), + model.Property("EmptyProperty", model.datatypes.String), + model.MultiLanguageProperty("EmptyMultiLanguageProperty"), + model.Range("EmptyRange", model.datatypes.Int), + model.File("EmptyFile", "application/pdf"), + model.ReferenceElement("EmptyReferenceElement"), + model.RelationshipElement("EmptyRelationshipElement"), + ]) + + out_str = json.dumps(submodel, cls=ValueOnlyJsonEncoder) parsed_output = json.loads(out_str) - self.assertIsInstance(parsed_output, list) - self.assertEqual(["Martha", "Jonathan", "Clark"], parsed_output) + self.assertEqual({"FilledProperty": "exampleValue"}, parsed_output) - def test_submodel_element_list_preserves_order(self): - prop1 = model.Property(id_short="Prop1", value_type=model.datatypes.String, value="Martha") - prop2 = model.Property(id_short="Prop2", value_type=model.datatypes.String) - prop3 = model.Property(id_short="Prop3", value_type=model.datatypes.String, value="Clark") - sme_list = model.SubmodelElementList( - id_short="Authors", - type_value_list_element=model.Property, - value_type_list_element=model.datatypes.String, - value=[prop1, prop2, prop3], - ) + def test_submodel_omits_containers_of_omitted_elements_ok(self): + submodel = model.Submodel("https://example.org/Submodel", submodel_element=[ + model.Property("FilledProperty", model.datatypes.String, "exampleValue"), + model.SubmodelElementCollection("CollectionOfOperations", [model.Operation("Operation")]), + model.SubmodelElementList("ListOfOperations", model.Operation, [model.Operation(None)]), + ]) - out_str = json.dumps(sme_list, cls=AASToValueOnlyJsonEncoder) + out_str = json.dumps(submodel, cls=ValueOnlyJsonEncoder) parsed_output = json.loads(out_str) - self.assertIsInstance(parsed_output, list) - self.assertEqual(["Martha", None, "Clark"], parsed_output) + self.assertEqual({"FilledProperty": "exampleValue"}, parsed_output) - def test_multi_language_property(self): - mlp = model.MultiLanguageProperty( - id_short="MLP", - value=model.MultiLanguageTextType( - {"de": "Das ist ein deutscher Bezeichner", "en": "That's an English label"} - ), - ) + def test_empty_submodel_ok(self): + submodel = example_aas_mandatory_attributes.create_example_empty_submodel() - out_str = json.dumps(mlp, cls=AASToValueOnlyJsonEncoder) + out_str = json.dumps(submodel, cls=ValueOnlyJsonEncoder) parsed_output = json.loads(out_str) - self.assertIsInstance(parsed_output, list) - self.assertEqual(2, len(parsed_output)) - self.assertIn({"de": "Das ist ein deutscher Bezeichner"}, parsed_output) - self.assertIn({"en": "That's an English label"}, parsed_output) + self.assertEqual({}, parsed_output) - def test_range(self): - rng = model.Range(id_short="TorqueRange", value_type=model.datatypes.Int, min=3, max=15) + # ------------------------------------------------------------------ SubmodelElementCollection / -List - out_str = json.dumps(rng, cls=AASToValueOnlyJsonEncoder) + def test_submodel_element_collection_ok(self): + smc = self.submodel.get_referable("ExampleSubmodelCollection") + + out_str = json.dumps(smc, cls=ValueOnlyJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertCountEqual([ + "ExampleBlob", + "ExampleFile", + "ExampleFileURI", + "ExampleMultiLanguageProperty", + "ExampleRange", + "ExampleReferenceElement", + "ExampleSubmodelList", + ], parsed_output.keys()) + self.assertEqual({"min": 0, "max": 100}, parsed_output["ExampleRange"]) + self.assertEqual(["exampleValue", "exampleValue"], parsed_output["ExampleSubmodelList"]) + + def test_submodel_element_list_ok(self): + sml = self.submodel.get_referable("ExampleSubmodelCollection.ExampleSubmodelList") + + out_str = json.dumps(sml, cls=ValueOnlyJsonEncoder) parsed_output = json.loads(out_str) - self.assertIsInstance(parsed_output, dict) - self.assertEqual({"min", "max"}, parsed_output.keys()) - self.assertEqual(3, parsed_output["min"]) - self.assertEqual(15, parsed_output["max"]) + self.assertEqual(["exampleValue", "exampleValue"], parsed_output) - def test_reference_element(self): - ref = model.ReferenceElement( - id_short="MaxRotationSpeedReference", - value=model.ExternalReference( - key=(model.Key(type_=model.KeyTypes.GLOBAL_REFERENCE, value="0173-1#02-BAA120#008"),) - ), - ) + def test_submodel_element_list_keeps_index_of_element_without_value_ok(self): + sml = model.SubmodelElementList("List", model.Property, [ + model.Property(None, model.datatypes.String, "first"), + model.Property(None, model.datatypes.String), + model.Property(None, model.datatypes.String, "third"), + ], value_type_list_element=model.datatypes.String) - out_str = json.dumps(ref, cls=AASToValueOnlyJsonEncoder) + out_str = json.dumps(sml, cls=ValueOnlyJsonEncoder) parsed_output = json.loads(out_str) - self.assertIsInstance(parsed_output, dict) - self.assertEqual({"type", "keys"}, parsed_output.keys()) - self.assertIsInstance(parsed_output["keys"], list) - self.assertEqual(1, len(parsed_output["keys"])) - self.assertEqual({"type", "value"}, parsed_output["keys"][0].keys()) - self.assertEqual("GlobalReference", parsed_output["keys"][0]["type"]) - self.assertEqual("0173-1#02-BAA120#008", parsed_output["keys"][0]["value"]) + self.assertEqual(["first", None, "third"], parsed_output) - def test_file(self): - file = model.File( - id_short="Document", content_type="application/pdf", value="https://example.org/SafetyInstructions.pdf" - ) + def test_empty_submodel_element_list_ok(self): + submodel = example_aas_mandatory_attributes.create_example_submodel() + sml = submodel.get_referable("ExampleSubmodelList2") - out_str = json.dumps(file, cls=AASToValueOnlyJsonEncoder) + out_str = json.dumps(sml, cls=ValueOnlyJsonEncoder) parsed_output = json.loads(out_str) - self.assertIsInstance(parsed_output, dict) - self.assertEqual({"contentType", "value"}, parsed_output.keys()) - self.assertEqual("application/pdf", parsed_output["contentType"]) - self.assertEqual("https://example.org/SafetyInstructions.pdf", parsed_output["value"]) + self.assertEqual([], parsed_output) + + # ------------------------------------------------------------------ Property data type to value mapping - def test_file_without_value(self): - file = model.File(id_short="Document", content_type="application/pdf") + def test_property_ok(self): + submodel = example_aas.create_example_asset_identification_submodel() + prop = submodel.get_referable("ManufacturerName") - out_str = json.dumps(file, cls=AASToValueOnlyJsonEncoder) + out_str = json.dumps(prop, cls=ValueOnlyJsonEncoder) parsed_output = json.loads(out_str) - self.assertIsInstance(parsed_output, dict) - self.assertEqual({"contentType"}, parsed_output.keys()) - self.assertEqual("application/pdf", parsed_output["contentType"]) + self.assertEqual("ACPLT", parsed_output) - def test_blob(self): - blob = model.Blob(id_short="Logo", content_type="application/octet-stream", value=b"\x00\x01BaSyx") + def test_property_without_value_ok(self): + prop = model.Property("EmptyProperty", model.datatypes.String) - out_str = json.dumps(blob, cls=AASToValueOnlyJsonEncoder) + out_str = json.dumps(prop, cls=ValueOnlyJsonEncoder) parsed_output = json.loads(out_str) - self.assertIsInstance(parsed_output, dict) - self.assertEqual({"contentType", "value"}, parsed_output.keys()) - self.assertEqual("application/octet-stream", parsed_output["contentType"]) - self.assertEqual("AAFCYVN5eA==", parsed_output["value"]) + self.assertIsNone(parsed_output) - def test_relationship_element(self): - rel = model.RelationshipElement( - id_short="CurrentFlowsFrom", - first=model.ModelReference( - type_=model.Property, - key=( - model.Key(type_=model.KeyTypes.SUBMODEL, value="http://customer.com/demo/aas/1/1/1234859590"), - model.Key(type_=model.KeyTypes.PROPERTY, value="PlusPole"), - ), - ), - second=model.ModelReference( - type_=model.Property, - key=( - model.Key(type_=model.KeyTypes.SUBMODEL, value="http://customer.com/demo/aas/1/0/1234859123490"), - model.Key(type_=model.KeyTypes.PROPERTY, value="MinusPole"), - ), - ), - ) + def test_property_boolean_types_ok(self): + smc = model.SubmodelElementCollection("Booleans", [ + model.Property("True", model.datatypes.Boolean, True), + model.Property("False", model.datatypes.Boolean, False), + ]) - out_str = json.dumps(rel, cls=AASToValueOnlyJsonEncoder) - parsed_output = json.loads(out_str) - - self.assertIsInstance(parsed_output, dict) - self.assertEqual({"first", "second"}, parsed_output.keys()) - self.assertEqual("PlusPole", parsed_output["first"]["keys"][1]["value"]) - self.assertEqual("MinusPole", parsed_output["second"]["keys"][1]["value"]) - - def test_annotated_relationship_element(self): - anrel = model.AnnotatedRelationshipElement( - id_short="CurrentFlowsFrom", - first=model.ModelReference( - type_=model.Property, - key=( - model.Key(type_=model.KeyTypes.SUBMODEL, value="http://customer.com/demo/aas/1/1/1234859590"), - model.Key(type_=model.KeyTypes.PROPERTY, value="PlusPole"), - ), - ), - second=model.ModelReference( - type_=model.Property, - key=( - model.Key(type_=model.KeyTypes.SUBMODEL, value="http://customer.com/demo/aas/1/0/1234859123490"), - model.Key(type_=model.KeyTypes.PROPERTY, value="MinusPole"), - ), - ), - annotation=[ - model.Property( - id_short="AppliedRule", value_type=model.datatypes.String, value="TechnicalCurrentFlowDirection" - ) - ], - ) + out_str = json.dumps(smc, cls=ValueOnlyJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertEqual({"True": True, "False": False}, parsed_output) + + def test_property_number_types_ok(self): + smc = model.SubmodelElementCollection("Numbers", [ + model.Property("Int", model.datatypes.Int, -1), + model.Property("Long", model.datatypes.Long, 9223372036854775807), + model.Property("UnsignedLong", model.datatypes.UnsignedLong, 18446744073709551615), + model.Property("Zero", model.datatypes.NonNegativeInteger, 0), + # xs:integer is unbounded, this value cannot be represented as a float + model.Property("HugeInteger", model.datatypes.Integer, 10**400), + model.Property("Decimal", model.datatypes.Decimal, decimal.Decimal("-1.23")), + model.Property("Double", model.datatypes.Double, 234.567e8), + model.Property("Float", model.datatypes.Float, 1.5), + ]) + + out_str = json.dumps(smc, cls=ValueOnlyJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertEqual({ + "Int": -1, + "Long": 9223372036854775807, + "UnsignedLong": 18446744073709551615, + "Zero": 0, + "HugeInteger": 10**400, + "Decimal": -1.23, + "Double": 234.567e8, + "Float": 1.5, + }, parsed_output) + + def test_property_non_finite_double_ok(self): + # JSON has no representation for NaN, INF and -INF, so the XSD representation is used instead + smc = model.SubmodelElementCollection("NonFinite", [ + model.Property("NaN", model.datatypes.Double, math.nan), + model.Property("Inf", model.datatypes.Double, math.inf), + model.Property("NegativeInf", model.datatypes.Double, -math.inf), + ]) + + out_str = json.dumps(smc, cls=ValueOnlyJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertEqual({"NaN": "NaN", "Inf": "INF", "NegativeInf": "-INF"}, parsed_output) + + def test_property_string_types_ok(self): + smc = model.SubmodelElementCollection("Strings", [ + model.Property("String", model.datatypes.String, "Hello world"), + model.Property("EmptyString", model.datatypes.String, ""), + model.Property("Date", model.datatypes.Date, model.datatypes.Date(2000, 1, 1)), + model.Property("Time", model.datatypes.Time, model.datatypes.Time(14, 23)), + model.Property("DateTime", model.datatypes.DateTime, model.datatypes.DateTime(2000, 1, 1, 14, 23)), + model.Property("GYear", model.datatypes.GYear, model.datatypes.GYear(2000)), + model.Property("Duration", model.datatypes.Duration, model.datatypes.Duration(days=30)), + model.Property("HexBinary", model.datatypes.HexBinary, model.datatypes.HexBinary(b"kumowashere")), + model.Property("Base64Binary", model.datatypes.Base64Binary, + model.datatypes.Base64Binary(b"Hello, World!")), + model.Property("AnyURI", model.datatypes.AnyURI, model.datatypes.AnyURI("urn:example:company:1.0.0")), + ]) + + out_str = json.dumps(smc, cls=ValueOnlyJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertEqual({ + "String": "Hello world", + "EmptyString": "", + "Date": "2000-01-01", + "Time": "14:23:00", + "DateTime": "2000-01-01T14:23:00", + "GYear": "2000", + "Duration": "P30D", + "HexBinary": "6b756d6f77617368657265", + "Base64Binary": "SGVsbG8sIFdvcmxkIQ==", + "AnyURI": "urn:example:company:1.0.0", + }, parsed_output) + + # ------------------------------------------------------------------ other SubmodelElements + + def test_multi_language_property_ok(self): + mlp = self.submodel.get_referable("ExampleSubmodelCollection.ExampleMultiLanguageProperty") + + out_str = json.dumps(mlp, cls=ValueOnlyJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertCountEqual([ + {"de": "Beispielwert für ein MultiLanguageProperty-Element"}, + {"en-US": "Example value of a MultiLanguageProperty element"}, + ], parsed_output) + + def test_multi_language_property_without_value_ok(self): + mlp = model.MultiLanguageProperty("EmptyMultiLanguageProperty") + + out_str = json.dumps(mlp, cls=ValueOnlyJsonEncoder) + parsed_output = json.loads(out_str) - out_str = json.dumps(anrel, cls=AASToValueOnlyJsonEncoder) + self.assertEqual([], parsed_output) + + def test_range_ok(self): + range_element = self.submodel.get_referable("ExampleSubmodelCollection.ExampleRange") + + out_str = json.dumps(range_element, cls=ValueOnlyJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertEqual({"min": 0, "max": 100}, parsed_output) + + def test_blob_ok(self): + blob = self.submodel.get_referable("ExampleSubmodelCollection.ExampleBlob") + + out_str = json.dumps(blob, cls=ValueOnlyJsonEncoder) parsed_output = json.loads(out_str) - self.assertIsInstance(parsed_output, dict) - self.assertEqual({"first", "second", "annotations"}, parsed_output.keys()) - self.assertEqual("PlusPole", parsed_output["first"]["keys"][1]["value"]) - self.assertEqual("MinusPole", parsed_output["second"]["keys"][1]["value"]) - self.assertIsInstance(parsed_output["annotations"], dict) - self.assertEqual(1, len(parsed_output["annotations"])) - self.assertEqual("TechnicalCurrentFlowDirection", parsed_output["annotations"]["AppliedRule"]) + # the value is base64 encoded b"\x01\x02\x03\x04\x05" + self.assertEqual({"contentType": "application/pdf", "value": "AQIDBAU="}, parsed_output) - def test_entity(self): - entity = model.Entity( - id_short="MySubAssetEntity", - entity_type=model.EntityType.SELF_MANAGED_ENTITY, - global_asset_id="http://customer.com/demo/asset/1/1/MySubAsset", - specific_asset_id=[ - model.SpecificAssetId(name="SpecificAsset", value="http://customer.com/demo/asset/2/2/TestAsset") + def test_file_ok(self): + file = self.submodel.get_referable("ExampleSubmodelCollection.ExampleFile") + + out_str = json.dumps(file, cls=ValueOnlyJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertEqual({"contentType": "application/pdf", "value": "/TestFile.pdf"}, parsed_output) + + def test_reference_element_ok(self): + reference_element = self.submodel.get_referable("ExampleSubmodelCollection.ExampleReferenceElement") + + out_str = json.dumps(reference_element, cls=ValueOnlyJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertEqual({ + "type": "ModelReference", + "keys": [ + {"type": "Submodel", "value": EXAMPLE_SUBMODEL_ID}, + {"type": "Property", "value": "ExampleProperty"}, ], - statement=[ - model.Property(id_short="MaxRotationSpeed", value_type=model.datatypes.Int, value=5000) - ] - ) + }, parsed_output) + + def test_relationship_element_ok(self): + relationship_element = self.submodel.get_referable("ExampleRelationshipElement") - out_str = json.dumps(entity, cls=AASToValueOnlyJsonEncoder) - parsed_output = json.loads(out_str) - - self.assertIsInstance(parsed_output, dict) - self.assertEqual({"entityType", "globalAssetId", "specificAssetIds", "statements"}, parsed_output.keys()) - self.assertEqual(5000, parsed_output["statements"]["MaxRotationSpeed"]) - self.assertEqual("http://customer.com/demo/asset/1/1/MySubAsset", parsed_output["globalAssetId"]) - - def test_basic_event_element(self): - event = model.BasicEventElement( - id_short="MyBasicEvent", - observed=model.ModelReference( - type_=model.Property, - key=( - model.Key(type_=model.KeyTypes.SUBMODEL, value="http://customer.com/demo/aas/1/1/1234859590"), - model.Key(type_=model.KeyTypes.PROPERTY, value="MaxRotation"), - ) - ), - direction=model.Direction.INPUT, - state=model.StateOfEvent.ON + out_str = json.dumps(relationship_element, cls=ValueOnlyJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertEqual({ + "first": { + "type": "ModelReference", + "keys": [ + {"type": "Submodel", "value": EXAMPLE_SUBMODEL_ID}, + {"type": "Property", "value": "ExampleProperty"}, + ], + }, + "second": { + "type": "ModelReference", + "keys": [ + {"type": "Submodel", "value": EXAMPLE_SUBMODEL_ID}, + {"type": "Property", "value": "ExampleProperty2"}, + ], + }, + }, parsed_output) + + def test_annotated_relationship_element_ok(self): + are = self.submodel.get_referable("ExampleAnnotatedRelationshipElement") + + out_str = json.dumps(are, cls=ValueOnlyJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertEqual({ + "first": { + "type": "ModelReference", + "keys": [ + {"type": "Submodel", "value": EXAMPLE_SUBMODEL_ID}, + {"type": "Property", "value": "ExampleProperty"}, + ], + }, + "second": { + "type": "ModelReference", + "keys": [ + {"type": "Submodel", "value": EXAMPLE_SUBMODEL_ID}, + {"type": "Property", "value": "ExampleProperty2"}, + ], + }, + "annotations": { + "ExampleAnnotatedProperty": "exampleValue", + "ExampleAnnotatedRange": {"min": 1, "max": 5}, + }, + }, parsed_output) + + def test_annotated_relationship_element_without_annotation_ok(self): + submodel = example_aas_mandatory_attributes.create_example_submodel() + are = submodel.get_referable("ExampleAnnotatedRelationshipElement") + + out_str = json.dumps(are, cls=ValueOnlyJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertCountEqual(["first", "second"], parsed_output.keys()) + + def test_entity_ok(self): + submodel = example_aas.create_example_bill_of_material_submodel() + entity = submodel.get_referable("ExampleEntity") + + out_str = json.dumps(entity, cls=ValueOnlyJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertEqual({ + "statements": { + "ExampleProperty": "exampleValue", + "ExampleProperty2": "exampleValue2", + }, + "entityType": "SelfManagedEntity", + "globalAssetId": "http://example.org/TestAsset/", + "specificAssetIds": [{ + "name": "TestKey", + "value": "TestValue", + "externalSubjectId": { + "type": "ExternalReference", + "keys": [{"type": "GlobalReference", "value": "http://example.org/SpecificAssetId/"}], + }, + }], + }, parsed_output) + + def test_entity_without_statements_ok(self): + submodel = example_aas.create_example_bill_of_material_submodel() + entity = submodel.get_referable("ExampleEntity2") + + out_str = json.dumps(entity, cls=ValueOnlyJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertNotIn("statements", parsed_output) + + def test_submodel_keeps_co_managed_entity_with_statements_ok(self): + submodel = model.Submodel("https://example.org/Submodel", submodel_element=[ + model.Entity("CoManagedEntity", model.EntityType.CO_MANAGED_ENTITY, statement=[ + model.Property("Statement", model.datatypes.String, "exampleValue"), + ]), + ]) + + out_str = json.dumps(submodel, cls=ValueOnlyJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertEqual({ + "CoManagedEntity": { + "statements": {"Statement": "exampleValue"}, + "entityType": "CoManagedEntity", + }, + }, parsed_output) + + def test_basic_event_element_ok(self): + basic_event_element = self.submodel.get_referable("ExampleBasicEventElement") + + out_str = json.dumps(basic_event_element, cls=ValueOnlyJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertEqual({ + "observed": { + "type": "ModelReference", + "keys": [ + {"type": "Submodel", "value": EXAMPLE_SUBMODEL_ID}, + {"type": "Property", "value": "ExampleProperty"}, + ], + }, + }, parsed_output) + + def test_operation_fail(self): + operation = self.submodel.get_referable("ExampleOperation") + + with self.assertRaises(TypeError): + json.dumps(operation, cls=ValueOnlyJsonEncoder) + + def test_capability_fail(self): + capability = self.submodel.get_referable("ExampleCapability") + + with self.assertRaises(TypeError): + json.dumps(capability, cls=ValueOnlyJsonEncoder) + +class TestValueOnlyJsonEncoderCore(unittest.TestCase): + + def setUp(self): + self.submodel = example_aas.create_example_submodel() + + def test_deep_is_default_ok(self): + deep_output = json.loads(json.dumps(self.submodel, cls=ValueOnlyJsonEncoder, deep=True)) + + out_str = json.dumps(self.submodel, cls=ValueOnlyJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertEqual(deep_output, parsed_output) + + def test_submodel_core_empties_submodel_element_collection_ok(self): + out_str = json.dumps(self.submodel, cls=ValueOnlyJsonEncoder, deep=False) + parsed_output = json.loads(out_str) + + self.assertEqual({}, parsed_output["ExampleSubmodelCollection"]) + + def test_submodel_element_collection_core_empties_submodel_element_list_ok(self): + smc = self.submodel.get_referable("ExampleSubmodelCollection") + + out_str = json.dumps(smc, cls=ValueOnlyJsonEncoder, deep=False) + parsed_output = json.loads(out_str) + + self.assertEqual([], parsed_output["ExampleSubmodelList"]) + self.assertEqual({"min": 0, "max": 100}, parsed_output["ExampleRange"]) + + def test_submodel_core_keeps_leaf_values_ok(self): + submodel = example_aas.create_example_asset_identification_submodel() + + out_str = json.dumps(submodel, cls=ValueOnlyJsonEncoder, deep=False) + parsed_output = json.loads(out_str) + + self.assertEqual({"ManufacturerName": "ACPLT", "InstanceId": "978-8234-234-342"}, parsed_output) + + def test_submodel_element_list_core_empties_submodel_element_collections_ok(self): + submodel = example_aas_mandatory_attributes.create_example_submodel() + sml = submodel.get_referable("ExampleSubmodelList") + + out_str = json.dumps(sml, cls=ValueOnlyJsonEncoder, deep=False) + parsed_output = json.loads(out_str) + + self.assertEqual([{}, {}], parsed_output) + + def test_submodel_core_empties_submodel_element_list_ok(self): + submodel = example_aas_mandatory_attributes.create_example_submodel() + + out_str = json.dumps(submodel, cls=ValueOnlyJsonEncoder, deep=False) + parsed_output = json.loads(out_str) + + self.assertEqual([], parsed_output["ExampleSubmodelList"]) + + def test_submodel_core_omits_entity_statements_ok(self): + submodel = example_aas.create_example_bill_of_material_submodel() + deep_output = json.loads(json.dumps(submodel, cls=ValueOnlyJsonEncoder)) + expected = deep_output["ExampleEntity"] + expected.pop("statements") + + out_str = json.dumps(submodel, cls=ValueOnlyJsonEncoder, deep=False) + parsed_output = json.loads(out_str) + + self.assertEqual(expected, parsed_output["ExampleEntity"]) + + def test_entity_core_keeps_statements_ok(self): + submodel = example_aas.create_example_bill_of_material_submodel() + entity = submodel.get_referable("ExampleEntity") + + out_str = json.dumps(entity, cls=ValueOnlyJsonEncoder, deep=False) + parsed_output = json.loads(out_str) + + self.assertEqual({"ExampleProperty": "exampleValue", "ExampleProperty2": "exampleValue2"}, + parsed_output["statements"]) + + def test_entity_core_empties_statement_submodel_element_collection_ok(self): + collection = model.SubmodelElementCollection( + "ExampleCollection", + value=[model.Property("ExampleProperty", model.datatypes.String, value="exampleValue")], ) + entity = model.Entity("ExampleEntity", model.EntityType.CO_MANAGED_ENTITY, statement=[collection]) + + out_str = json.dumps(entity, cls=ValueOnlyJsonEncoder, deep=False) + parsed_output = json.loads(out_str) + + self.assertEqual({"ExampleCollection": {}}, parsed_output["statements"]) + + def test_submodel_core_omits_annotated_relationship_element_annotations_ok(self): + deep_output = json.loads(json.dumps(self.submodel, cls=ValueOnlyJsonEncoder)) + expected = deep_output["ExampleAnnotatedRelationshipElement"] + expected.pop("annotations") + + out_str = json.dumps(self.submodel, cls=ValueOnlyJsonEncoder, deep=False) + parsed_output = json.loads(out_str) + + self.assertEqual(expected, parsed_output["ExampleAnnotatedRelationshipElement"]) + + def test_annotated_relationship_element_core_keeps_annotations_ok(self): + are = self.submodel.get_referable("ExampleAnnotatedRelationshipElement") + deep_output = json.loads(json.dumps(are, cls=ValueOnlyJsonEncoder)) + + out_str = json.dumps(are, cls=ValueOnlyJsonEncoder, deep=False) + parsed_output = json.loads(out_str) + + self.assertEqual(deep_output, parsed_output) + + def test_leaf_element_core_ok(self): + range_element = self.submodel.get_referable("ExampleSubmodelCollection.ExampleRange") + + out_str = json.dumps(range_element, cls=ValueOnlyJsonEncoder, deep=False) + parsed_output = json.loads(out_str) + + self.assertEqual({"min": 0, "max": 100}, parsed_output) + + def test_submodel_core_keeps_containers_of_omitted_elements_ok(self): + submodel = model.Submodel("https://example.org/Submodel", submodel_element=[ + model.Property("FilledProperty", model.datatypes.String, "exampleValue"), + model.SubmodelElementCollection("CollectionOfOperations", [model.Operation("Operation")]), + model.SubmodelElementList("ListOfOperations", model.Operation, [model.Operation(None)]), + ]) - out_str = json.dumps(event, cls=AASToValueOnlyJsonEncoder) + out_str = json.dumps(submodel, cls=ValueOnlyJsonEncoder, deep=False) parsed_output = json.loads(out_str) - self.assertIsInstance(parsed_output, dict) - self.assertEqual({"observed"}, parsed_output.keys()) - self.assertEqual("MaxRotation", parsed_output["observed"]["keys"][1]["value"]) + self.assertEqual({ + "FilledProperty": "exampleValue", + "CollectionOfOperations": {}, + "ListOfOperations": [], + }, parsed_output) diff --git a/server/app/interfaces/base.py b/server/app/interfaces/base.py index 7009e9b7b..4cd19216e 100644 --- a/server/app/interfaces/base.py +++ b/server/app/interfaces/base.py @@ -17,9 +17,7 @@ import werkzeug.utils from basyx.aas import model from basyx.aas.adapter._generic import XML_NS_MAP -from basyx.aas.adapter.json.path_serialization import PathJsonEncoder -from basyx.aas.adapter.json.reference_serialization import ReferenceJsonEncoder -from basyx.aas.adapter.json.value_only_serialization import AASToValueOnlyJsonEncoder +from basyx.aas.adapter.json import MetadataJsonEncoder, PathJsonEncoder, ReferenceJsonEncoder, ValueOnlyJsonEncoder from basyx.aas.adapter.xml import XMLConstructables, read_aas_xml_element, xml_serialization from basyx.aas.model import AbstractObjectStore from basyx.aas.model.datatypes import NonNegativeInteger @@ -237,7 +235,7 @@ class StrippedResultToJsonEncoder(ResultToJsonEncoder): # TODO need to be added -class ResultMetadataJsonEncoder(ResultToJsonEncoder): +class ResultMetadataJsonEncoder(ResultToJsonEncoder, MetadataJsonEncoder): pass @@ -245,11 +243,7 @@ class ResultReferenceJsonEncoder(ResultToJsonEncoder, ReferenceJsonEncoder): pass -class ResultValueOnlyJsonEncoder(ResultToJsonEncoder, AASToValueOnlyJsonEncoder): - pass - - -class ResultShallowValueOnlyJsonEncoder(ResultValueOnlyJsonEncoder): +class ResultValueOnlyJsonEncoder(ResultToJsonEncoder, ValueOnlyJsonEncoder): pass @@ -265,14 +259,11 @@ def pick_encoder(mods: Modifiers) -> Tuple[Type["ResultToJsonEncoder"], Dict[str """ match mods.content: case Content.NORMAL: - cls = StrippedResultToJsonEncoder if not mods.is_deep else ResultToJsonEncoder - return cls, {} + return ResultToJsonEncoder, {"deep": mods.is_deep} case Content.METADATA: return ResultMetadataJsonEncoder, {} case Content.VALUE: - # TODO: AASToValueOnlyJsonEncoder needs a ``deep`` kwarg; currently always deep. - cls = ResultShallowValueOnlyJsonEncoder if not mods.is_deep else ResultValueOnlyJsonEncoder - return cls, {} + return ResultValueOnlyJsonEncoder, {"deep": mods.is_deep} case Content.REFERENCE: return ResultReferenceJsonEncoder, {} case Content.PATH: From 1d10056c02c46c98416157d3f386e6be43e71626 Mon Sep 17 00:00:00 2001 From: Joshua Benning Date: Tue, 27 Oct 2026 20:29:12 +0100 Subject: [PATCH 15/22] Refactor adapter structure --- sdk/basyx/aas/adapter/json/__init__.py | 20 +++++++++---------- .../{ => formats}/metadata_serialization.py | 0 .../json/{ => formats}/path_serialization.py | 0 .../{ => formats}/reference_serialization.py | 0 .../{ => formats}/value_only_serialization.py | 0 .../json/test_reference_serialization.py | 2 +- .../json/test_value_only_serialization.py | 2 +- 7 files changed, 12 insertions(+), 12 deletions(-) rename sdk/basyx/aas/adapter/json/{ => formats}/metadata_serialization.py (100%) rename sdk/basyx/aas/adapter/json/{ => formats}/path_serialization.py (100%) rename sdk/basyx/aas/adapter/json/{ => formats}/reference_serialization.py (100%) rename sdk/basyx/aas/adapter/json/{ => formats}/value_only_serialization.py (100%) diff --git a/sdk/basyx/aas/adapter/json/__init__.py b/sdk/basyx/aas/adapter/json/__init__.py index 8a9ee781f..105eaed8a 100644 --- a/sdk/basyx/aas/adapter/json/__init__.py +++ b/sdk/basyx/aas/adapter/json/__init__.py @@ -17,6 +17,12 @@ :class:`ObjectStore `. """ +from .json_serialization import ( + AASToJsonEncoder, + StrippedAASToJsonEncoder, + object_store_to_json, + write_aas_json_file, +) from .json_deserialization import ( AASFromJsonDecoder, StrictAASFromJsonDecoder, @@ -25,16 +31,10 @@ read_aas_json_file, read_aas_json_file_into, ) -from .json_serialization import ( - AASToJsonEncoder, - StrippedAASToJsonEncoder, - object_store_to_json, - write_aas_json_file, -) -from .metadata_serialization import MetadataJsonEncoder -from .path_serialization import PathJsonEncoder -from .reference_serialization import ReferenceJsonEncoder -from .value_only_serialization import ValueOnlyJsonEncoder +from .formats.metadata_serialization import MetadataJsonEncoder +from .formats.path_serialization import PathJsonEncoder +from .formats.reference_serialization import ReferenceJsonEncoder +from .formats.value_only_serialization import ValueOnlyJsonEncoder __all__ = [ "AASFromJsonDecoder", diff --git a/sdk/basyx/aas/adapter/json/metadata_serialization.py b/sdk/basyx/aas/adapter/json/formats/metadata_serialization.py similarity index 100% rename from sdk/basyx/aas/adapter/json/metadata_serialization.py rename to sdk/basyx/aas/adapter/json/formats/metadata_serialization.py diff --git a/sdk/basyx/aas/adapter/json/path_serialization.py b/sdk/basyx/aas/adapter/json/formats/path_serialization.py similarity index 100% rename from sdk/basyx/aas/adapter/json/path_serialization.py rename to sdk/basyx/aas/adapter/json/formats/path_serialization.py diff --git a/sdk/basyx/aas/adapter/json/reference_serialization.py b/sdk/basyx/aas/adapter/json/formats/reference_serialization.py similarity index 100% rename from sdk/basyx/aas/adapter/json/reference_serialization.py rename to sdk/basyx/aas/adapter/json/formats/reference_serialization.py diff --git a/sdk/basyx/aas/adapter/json/value_only_serialization.py b/sdk/basyx/aas/adapter/json/formats/value_only_serialization.py similarity index 100% rename from sdk/basyx/aas/adapter/json/value_only_serialization.py rename to sdk/basyx/aas/adapter/json/formats/value_only_serialization.py diff --git a/sdk/test/adapter/json/test_reference_serialization.py b/sdk/test/adapter/json/test_reference_serialization.py index 19fe00b19..da4668851 100644 --- a/sdk/test/adapter/json/test_reference_serialization.py +++ b/sdk/test/adapter/json/test_reference_serialization.py @@ -8,7 +8,7 @@ import unittest from basyx.aas import model -from basyx.aas.adapter.json.reference_serialization import ReferenceJsonEncoder +from basyx.aas.adapter.json import ReferenceJsonEncoder from basyx.aas.examples.data import example_aas diff --git a/sdk/test/adapter/json/test_value_only_serialization.py b/sdk/test/adapter/json/test_value_only_serialization.py index 68bd6227f..21acf5fe8 100644 --- a/sdk/test/adapter/json/test_value_only_serialization.py +++ b/sdk/test/adapter/json/test_value_only_serialization.py @@ -10,7 +10,7 @@ import unittest from basyx.aas import model -from basyx.aas.adapter.json.value_only_serialization import ValueOnlyJsonEncoder +from basyx.aas.adapter.json import ValueOnlyJsonEncoder from basyx.aas.examples.data import example_aas, example_aas_mandatory_attributes EXAMPLE_SUBMODEL_ID = "http://example.org/Test_Submodel" From 08fc93be3cfa42a8e5baf545e2e0a11ce3540d7f Mon Sep 17 00:00:00 2001 From: Joshua Benning Date: Tue, 27 Oct 2026 20:40:35 +0100 Subject: [PATCH 16/22] Restructure imports Fixes __init__ ordering by using local imports --- sdk/basyx/aas/adapter/json/__init__.py | 20 +++++++++---------- .../json/formats/metadata_serialization.py | 3 ++- .../json/formats/path_serialization.py | 3 ++- .../json/formats/reference_serialization.py | 3 ++- .../json/formats/value_only_serialization.py | 3 ++- 5 files changed, 18 insertions(+), 14 deletions(-) diff --git a/sdk/basyx/aas/adapter/json/__init__.py b/sdk/basyx/aas/adapter/json/__init__.py index 105eaed8a..89e3b3f0e 100644 --- a/sdk/basyx/aas/adapter/json/__init__.py +++ b/sdk/basyx/aas/adapter/json/__init__.py @@ -17,12 +17,10 @@ :class:`ObjectStore `. """ -from .json_serialization import ( - AASToJsonEncoder, - StrippedAASToJsonEncoder, - object_store_to_json, - write_aas_json_file, -) +from .formats.metadata_serialization import MetadataJsonEncoder +from .formats.path_serialization import PathJsonEncoder +from .formats.reference_serialization import ReferenceJsonEncoder +from .formats.value_only_serialization import ValueOnlyJsonEncoder from .json_deserialization import ( AASFromJsonDecoder, StrictAASFromJsonDecoder, @@ -31,10 +29,12 @@ read_aas_json_file, read_aas_json_file_into, ) -from .formats.metadata_serialization import MetadataJsonEncoder -from .formats.path_serialization import PathJsonEncoder -from .formats.reference_serialization import ReferenceJsonEncoder -from .formats.value_only_serialization import ValueOnlyJsonEncoder +from .json_serialization import ( + AASToJsonEncoder, + StrippedAASToJsonEncoder, + object_store_to_json, + write_aas_json_file, +) __all__ = [ "AASFromJsonDecoder", diff --git a/sdk/basyx/aas/adapter/json/formats/metadata_serialization.py b/sdk/basyx/aas/adapter/json/formats/metadata_serialization.py index 45b9a2937..bb0673250 100644 --- a/sdk/basyx/aas/adapter/json/formats/metadata_serialization.py +++ b/sdk/basyx/aas/adapter/json/formats/metadata_serialization.py @@ -7,7 +7,8 @@ from typing import Dict from basyx.aas import model -from basyx.aas.adapter.json import AASToJsonEncoder + +from ..json_serialization import AASToJsonEncoder class MetadataJsonEncoder(AASToJsonEncoder): diff --git a/sdk/basyx/aas/adapter/json/formats/path_serialization.py b/sdk/basyx/aas/adapter/json/formats/path_serialization.py index dfe24634d..44ad2c78f 100644 --- a/sdk/basyx/aas/adapter/json/formats/path_serialization.py +++ b/sdk/basyx/aas/adapter/json/formats/path_serialization.py @@ -8,7 +8,8 @@ from typing import Iterable, List from basyx.aas import model -from basyx.aas.adapter.json import AASToJsonEncoder + +from ..json_serialization import AASToJsonEncoder class PathJsonEncoder(AASToJsonEncoder): diff --git a/sdk/basyx/aas/adapter/json/formats/reference_serialization.py b/sdk/basyx/aas/adapter/json/formats/reference_serialization.py index 3d51f716b..74861c9ff 100644 --- a/sdk/basyx/aas/adapter/json/formats/reference_serialization.py +++ b/sdk/basyx/aas/adapter/json/formats/reference_serialization.py @@ -7,7 +7,8 @@ from typing import Callable, Dict, Type from basyx.aas import model -from basyx.aas.adapter.json import AASToJsonEncoder + +from ..json_serialization import AASToJsonEncoder class ReferenceJsonEncoder(AASToJsonEncoder): diff --git a/sdk/basyx/aas/adapter/json/formats/value_only_serialization.py b/sdk/basyx/aas/adapter/json/formats/value_only_serialization.py index 47ac8aa8a..5cce4c9f2 100644 --- a/sdk/basyx/aas/adapter/json/formats/value_only_serialization.py +++ b/sdk/basyx/aas/adapter/json/formats/value_only_serialization.py @@ -10,7 +10,8 @@ from basyx.aas import model from basyx.aas.adapter import _generic -from basyx.aas.adapter.json import AASToJsonEncoder + +from ..json_serialization import AASToJsonEncoder TYPES_WITHOUT_VALUE_ONLY = (model.Capability, model.Operation) CONTAINER_TYPES = (model.SubmodelElementCollection, model.SubmodelElementList) From c57c2cb2f4637c25b19733f5a738f044001e495b Mon Sep 17 00:00:00 2001 From: Joshua Benning Date: Tue, 27 Oct 2026 20:47:41 +0100 Subject: [PATCH 17/22] Restrurcture tests following serializers --- sdk/test/adapter/json/{ => formats}/test_core_serialization.py | 0 sdk/test/adapter/json/{ => formats}/test_path_serialization.py | 0 .../adapter/json/{ => formats}/test_reference_serialization.py | 0 .../adapter/json/{ => formats}/test_value_only_serialization.py | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename sdk/test/adapter/json/{ => formats}/test_core_serialization.py (100%) rename sdk/test/adapter/json/{ => formats}/test_path_serialization.py (100%) rename sdk/test/adapter/json/{ => formats}/test_reference_serialization.py (100%) rename sdk/test/adapter/json/{ => formats}/test_value_only_serialization.py (100%) diff --git a/sdk/test/adapter/json/test_core_serialization.py b/sdk/test/adapter/json/formats/test_core_serialization.py similarity index 100% rename from sdk/test/adapter/json/test_core_serialization.py rename to sdk/test/adapter/json/formats/test_core_serialization.py diff --git a/sdk/test/adapter/json/test_path_serialization.py b/sdk/test/adapter/json/formats/test_path_serialization.py similarity index 100% rename from sdk/test/adapter/json/test_path_serialization.py rename to sdk/test/adapter/json/formats/test_path_serialization.py diff --git a/sdk/test/adapter/json/test_reference_serialization.py b/sdk/test/adapter/json/formats/test_reference_serialization.py similarity index 100% rename from sdk/test/adapter/json/test_reference_serialization.py rename to sdk/test/adapter/json/formats/test_reference_serialization.py diff --git a/sdk/test/adapter/json/test_value_only_serialization.py b/sdk/test/adapter/json/formats/test_value_only_serialization.py similarity index 100% rename from sdk/test/adapter/json/test_value_only_serialization.py rename to sdk/test/adapter/json/formats/test_value_only_serialization.py From b92fd60ef596c85b59188b66a5a1e122f8fca7fd Mon Sep 17 00:00:00 2001 From: Joshua Benning Date: Tue, 27 Oct 2026 21:12:23 +0100 Subject: [PATCH 18/22] Create metadata serialization test --- .../formats/test_metadata_serialization.py | 279 ++++++++++++++++++ 1 file changed, 279 insertions(+) create mode 100644 sdk/test/adapter/json/formats/test_metadata_serialization.py diff --git a/sdk/test/adapter/json/formats/test_metadata_serialization.py b/sdk/test/adapter/json/formats/test_metadata_serialization.py new file mode 100644 index 000000000..706f283e4 --- /dev/null +++ b/sdk/test/adapter/json/formats/test_metadata_serialization.py @@ -0,0 +1,279 @@ +# Copyright (c) 2026 the Eclipse BaSyx Authors +# +# This program and the accompanying materials are made available under the terms of the MIT License, available in +# the LICENSE file of this project. +# +# SPDX-License-Identifier: MIT +import json +import unittest + +from basyx.aas import model +from basyx.aas.adapter.json import AASToJsonEncoder, MetadataJsonEncoder +from basyx.aas.examples.data import example_aas, example_aas_mandatory_attributes + + +class TestMetadataJsonEncoder(unittest.TestCase): + """ + Tests follow Table Metadata Attributes of the AAS API spec. For every class, verify that the fields + listed as "not available in metadata representation" are dropped, while other attributes are retained. + """ + + def setUp(self): + self.submodel = example_aas.create_example_submodel() + + # ================================================================== Identifiables + + # ----- AssetAdministrationShell: drops assetInformation, submodels + def test_asset_administration_shell_drops_asset_information_and_submodels_ok(self): + aas = example_aas.create_example_asset_administration_shell() + + base_output = json.loads(json.dumps(aas, cls=AASToJsonEncoder)) + parsed_output = json.loads(json.dumps(aas, cls=MetadataJsonEncoder)) + + self.assertNotIn("assetInformation", parsed_output) + self.assertNotIn("submodels", parsed_output) + self.assertEqual({"assetInformation", "submodels"}, set(base_output.keys()) - set(parsed_output.keys())) + self.assertEqual("AssetAdministrationShell", parsed_output["modelType"]) + + # ----- Submodel: drops submodelElements + def test_submodel_drops_submodel_elements_ok(self): + base_output = json.loads(json.dumps(self.submodel, cls=AASToJsonEncoder)) + parsed_output = json.loads(json.dumps(self.submodel, cls=MetadataJsonEncoder)) + + self.assertNotIn("submodelElements", parsed_output) + self.assertEqual({"submodelElements"}, set(base_output.keys()) - set(parsed_output.keys())) + + def test_submodel_keeps_identifiable_fields_ok(self): + parsed_output = json.loads(json.dumps(self.submodel, cls=MetadataJsonEncoder)) + + self.assertEqual(self.submodel.id, parsed_output["id"]) + self.assertEqual(self.submodel.id_short, parsed_output["idShort"]) + self.assertEqual("Submodel", parsed_output["modelType"]) + + def test_empty_submodel_ok(self): + submodel = example_aas_mandatory_attributes.create_example_empty_submodel() + + parsed_output = json.loads(json.dumps(submodel, cls=MetadataJsonEncoder)) + + self.assertNotIn("submodelElements", parsed_output) + self.assertEqual(submodel.id, parsed_output["id"]) + + # ================================================================== SubmodelElements + + # ----- SubmodelElementCollection: drops value + def test_submodel_element_collection_drops_value_ok(self): + smc = self.submodel.get_referable("ExampleSubmodelCollection") + + base_output = json.loads(json.dumps(smc, cls=AASToJsonEncoder)) + parsed_output = json.loads(json.dumps(smc, cls=MetadataJsonEncoder)) + + self.assertNotIn("value", parsed_output) + self.assertEqual({"value"}, set(base_output.keys()) - set(parsed_output.keys())) + self.assertEqual("SubmodelElementCollection", parsed_output["modelType"]) + + # ----- SubmodelElementList: drops value + def test_submodel_element_list_drops_value_ok(self): + sml = self.submodel.get_referable("ExampleSubmodelCollection.ExampleSubmodelList") + + base_output = json.loads(json.dumps(sml, cls=AASToJsonEncoder)) + parsed_output = json.loads(json.dumps(sml, cls=MetadataJsonEncoder)) + + self.assertNotIn("value", parsed_output) + self.assertEqual({"value"}, set(base_output.keys()) - set(parsed_output.keys())) + self.assertEqual("SubmodelElementList", parsed_output["modelType"]) + # List-level metadata (type/semantics of elements) must be retained + self.assertIn("typeValueListElement", parsed_output) + + # ----- Entity: drops statements, globalAssetId, specificAssetId + def test_entity_drops_statements_and_asset_identifiers_ok(self): + submodel = example_aas.create_example_bill_of_material_submodel() + entity = submodel.get_referable("ExampleEntity") + + base_output = json.loads(json.dumps(entity, cls=AASToJsonEncoder)) + parsed_output = json.loads(json.dumps(entity, cls=MetadataJsonEncoder)) + + self.assertNotIn("statements", parsed_output) + self.assertNotIn("globalAssetId", parsed_output) + self.assertNotIn("specificAssetIds", parsed_output) + self.assertEqual( + {"statements", "globalAssetId", "specificAssetIds"}, + set(base_output.keys()) - set(parsed_output.keys()), + ) + self.assertEqual("Entity", parsed_output["modelType"]) + self.assertEqual("SelfManagedEntity", parsed_output["entityType"]) + + def test_entity_without_statements_ok(self): + submodel = example_aas.create_example_bill_of_material_submodel() + entity = submodel.get_referable("ExampleEntity2") + + parsed_output = json.loads(json.dumps(entity, cls=MetadataJsonEncoder)) + + self.assertNotIn("statements", parsed_output) + + # ----- BasicEventElement: drops observed + def test_basic_event_element_drops_observed_ok(self): + basic_event_element = self.submodel.get_referable("ExampleBasicEventElement") + + base_output = json.loads(json.dumps(basic_event_element, cls=AASToJsonEncoder)) + parsed_output = json.loads(json.dumps(basic_event_element, cls=MetadataJsonEncoder)) + + self.assertNotIn("observed", parsed_output) + self.assertEqual({"observed"}, set(base_output.keys()) - set(parsed_output.keys())) + # Other BasicEventElement metadata fields must be kept + self.assertIn("direction", parsed_output) + self.assertIn("state", parsed_output) + + # ----- Capability: no fields dropped + def test_capability_ok(self): + capability = self.submodel.get_referable("ExampleCapability") + + base_output = json.loads(json.dumps(capability, cls=AASToJsonEncoder)) + parsed_output = json.loads(json.dumps(capability, cls=MetadataJsonEncoder)) + + self.assertEqual(set(base_output.keys()), set(parsed_output.keys())) + self.assertEqual("Capability", parsed_output["modelType"]) + + # ----- Operation: no fields dropped + def test_operation_ok(self): + operation = self.submodel.get_referable("ExampleOperation") + + base_output = json.loads(json.dumps(operation, cls=AASToJsonEncoder)) + parsed_output = json.loads(json.dumps(operation, cls=MetadataJsonEncoder)) + + # Operation metadata keeps input/output/inoutput variables (they describe shape, not value) + self.assertEqual(set(base_output.keys()), set(parsed_output.keys())) + self.assertEqual("Operation", parsed_output["modelType"]) + + # ================================================================== DataElements + + # ----- Property: drops value, valueId + def test_property_drops_value_and_value_id_ok(self): + prop = model.Property( + "Prop", + model.datatypes.String, + value="exampleValue", + value_id=model.ExternalReference( + (model.Key(model.KeyTypes.GLOBAL_REFERENCE, "https://example.org/ValueId"),) + ), + ) + + base_output = json.loads(json.dumps(prop, cls=AASToJsonEncoder)) + parsed_output = json.loads(json.dumps(prop, cls=MetadataJsonEncoder)) + + self.assertNotIn("value", parsed_output) + self.assertNotIn("valueId", parsed_output) + self.assertEqual({"value", "valueId"}, set(base_output.keys()) - set(parsed_output.keys())) + self.assertIn("valueType", parsed_output) + + # ----- MultiLanguageProperty: drops value, valueId + def test_multi_language_property_drops_value_and_value_id_ok(self): + mlp = self.submodel.get_referable("ExampleSubmodelCollection.ExampleMultiLanguageProperty") + + base_output = json.loads(json.dumps(mlp, cls=AASToJsonEncoder)) + parsed_output = json.loads(json.dumps(mlp, cls=MetadataJsonEncoder)) + + self.assertNotIn("value", parsed_output) + self.assertNotIn("valueId", parsed_output) + self.assertLessEqual( + set(base_output.keys()) - set(parsed_output.keys()), + {"value", "valueId"}, + ) + self.assertEqual("MultiLanguageProperty", parsed_output["modelType"]) + + # ----- Range: drops min, max + def test_range_drops_min_and_max_ok(self): + range_element = self.submodel.get_referable("ExampleSubmodelCollection.ExampleRange") + + base_output = json.loads(json.dumps(range_element, cls=AASToJsonEncoder)) + parsed_output = json.loads(json.dumps(range_element, cls=MetadataJsonEncoder)) + + self.assertNotIn("min", parsed_output) + self.assertNotIn("max", parsed_output) + self.assertEqual({"min", "max"}, set(base_output.keys()) - set(parsed_output.keys())) + self.assertIn("valueType", parsed_output) + + # ----- ReferenceElement: drops value + def test_reference_element_drops_value_ok(self): + reference_element = self.submodel.get_referable("ExampleSubmodelCollection.ExampleReferenceElement") + + base_output = json.loads(json.dumps(reference_element, cls=AASToJsonEncoder)) + parsed_output = json.loads(json.dumps(reference_element, cls=MetadataJsonEncoder)) + + self.assertNotIn("value", parsed_output) + self.assertEqual({"value"}, set(base_output.keys()) - set(parsed_output.keys())) + self.assertEqual("ReferenceElement", parsed_output["modelType"]) + + # ----- RelationshipElement: drops first, second + def test_relationship_element_drops_first_and_second_ok(self): + relationship_element = self.submodel.get_referable("ExampleRelationshipElement") + + base_output = json.loads(json.dumps(relationship_element, cls=AASToJsonEncoder)) + parsed_output = json.loads(json.dumps(relationship_element, cls=MetadataJsonEncoder)) + + self.assertNotIn("first", parsed_output) + self.assertNotIn("second", parsed_output) + self.assertEqual({"first", "second"}, set(base_output.keys()) - set(parsed_output.keys())) + self.assertEqual("RelationshipElement", parsed_output["modelType"]) + + # ----- AnnotatedRelationshipElement: drops first, second, annotations + def test_annotated_relationship_element_drops_endpoints_and_annotations_ok(self): + are = self.submodel.get_referable("ExampleAnnotatedRelationshipElement") + + base_output = json.loads(json.dumps(are, cls=AASToJsonEncoder)) + parsed_output = json.loads(json.dumps(are, cls=MetadataJsonEncoder)) + + self.assertNotIn("first", parsed_output) + self.assertNotIn("second", parsed_output) + self.assertNotIn("annotations", parsed_output) + self.assertEqual( + {"first", "second", "annotations"}, + set(base_output.keys()) - set(parsed_output.keys()), + ) + self.assertEqual("AnnotatedRelationshipElement", parsed_output["modelType"]) + + # ----- Blob: drops value, contentType + def test_blob_drops_value_and_content_type_ok(self): + blob = self.submodel.get_referable("ExampleSubmodelCollection.ExampleBlob") + + base_output = json.loads(json.dumps(blob, cls=AASToJsonEncoder)) + parsed_output = json.loads(json.dumps(blob, cls=MetadataJsonEncoder)) + + self.assertNotIn("value", parsed_output) + self.assertNotIn("contentType", parsed_output) + self.assertEqual({"value", "contentType"}, set(base_output.keys()) - set(parsed_output.keys())) + self.assertEqual("Blob", parsed_output["modelType"]) + + # ----- File: drops value, contentType + def test_file_drops_value_and_content_type_ok(self): + file = self.submodel.get_referable("ExampleSubmodelCollection.ExampleFile") + + base_output = json.loads(json.dumps(file, cls=AASToJsonEncoder)) + parsed_output = json.loads(json.dumps(file, cls=MetadataJsonEncoder)) + + self.assertNotIn("value", parsed_output) + self.assertNotIn("contentType", parsed_output) + self.assertEqual({"value", "contentType"}, set(base_output.keys()) - set(parsed_output.keys())) + self.assertEqual("File", parsed_output["modelType"]) + + # ================================================================== Unsupported types + + def test_concept_description_fail(self): + concept_description = example_aas.create_example_concept_description() + + with self.assertRaises(TypeError): + json.dumps(concept_description, cls=MetadataJsonEncoder) + + # ================================================================== Composite + + def test_list_of_elements_ok(self): + elements = [ + self.submodel.get_referable("ExampleRelationshipElement"), + self.submodel.get_referable("ExampleSubmodelCollection"), + ] + + parsed_output = json.loads(json.dumps(elements, cls=MetadataJsonEncoder)) + + self.assertEqual(2, len(parsed_output)) + self.assertNotIn("first", parsed_output[0]) + self.assertNotIn("second", parsed_output[0]) + self.assertNotIn("value", parsed_output[1]) From 05f8eb455e5eff12f260cf59bb02f69445a32417 Mon Sep 17 00:00:00 2001 From: hpoeche Date: Tue, 27 Oct 2026 21:27:55 +0100 Subject: [PATCH 19/22] Implement modifier `extent=WithoutBLOBValue` --- .../json/formats/value_only_serialization.py | 21 +++++--- .../aas/adapter/json/json_serialization.py | 6 ++- .../formats/test_value_only_serialization.py | 54 +++++++++++++++++++ .../adapter/json/test_json_serialization.py | 43 +++++++++++++++ 4 files changed, 116 insertions(+), 8 deletions(-) diff --git a/sdk/basyx/aas/adapter/json/formats/value_only_serialization.py b/sdk/basyx/aas/adapter/json/formats/value_only_serialization.py index 5cce4c9f2..7a40c66b0 100644 --- a/sdk/basyx/aas/adapter/json/formats/value_only_serialization.py +++ b/sdk/basyx/aas/adapter/json/formats/value_only_serialization.py @@ -34,9 +34,8 @@ class ValueOnlyJsonEncoder(AASToJsonEncoder): * ``globalAssetId`` of an Entity is serialized as string, as defined in the schema and the metamodel, instead of a Reference, as shown in the Entity example. - :param deep: If True (default), all children of the serialized object are included on all sublevels. If False, - only the serialized object and its direct children are included without their values. Compare to - serialization level ``core``. + :cvar with_blob_value: Inherited from :class:`~basyx.aas.adapter.json.json_serialization.AASToJsonEncoder`. + :param deep: Inherited from :class:`~basyx.aas.adapter.json.json_serialization.AASToJsonEncoder`. """ @classmethod @@ -51,8 +50,8 @@ def _get_aas_class_serializers(cls) -> dict[type, Callable]: model.Property: cls._property_to_value_only, model.MultiLanguageProperty: cls._multi_language_property_to_value_only, model.Range: cls._range_to_value_only, - model.File: cls._file_or_blob_to_value_only, - model.Blob: cls._file_or_blob_to_value_only, + model.File: cls._file_to_value_only, + model.Blob: cls._blob_to_value_only, model.ReferenceElement: cls._reference_element_to_value_only, model.RelationshipElement: cls._relationship_element_to_value_only, model.AnnotatedRelationshipElement: cls._annotated_relationship_element_to_value_only, @@ -171,11 +170,19 @@ def _range_to_value_only(cls, obj: model.Range) -> Union[object, dict[str, objec return data @classmethod - def _file_or_blob_to_value_only(cls, obj: Union[model.Blob, model.File]) -> dict[str, object]: + def _file_to_value_only(cls, obj: model.File) -> dict[str, object]: data: dict[str, object] = {} data["contentType"] = obj.content_type if obj.value is not None: - data["value"] = base64.b64encode(obj.value).decode() if isinstance(obj, model.Blob) else obj.value + data["value"] = obj.value + return data + + @classmethod + def _blob_to_value_only(cls, obj: model.Blob) -> dict[str, object]: + data: dict[str, object] = {} + data["contentType"] = obj.content_type + if cls.with_blob_value and obj.value is not None: + data["value"] = base64.b64encode(obj.value).decode() return data @classmethod diff --git a/sdk/basyx/aas/adapter/json/json_serialization.py b/sdk/basyx/aas/adapter/json/json_serialization.py index 43f833057..05f5bb8c1 100644 --- a/sdk/basyx/aas/adapter/json/json_serialization.py +++ b/sdk/basyx/aas/adapter/json/json_serialization.py @@ -72,12 +72,16 @@ class AASToJsonEncoder(json.JSONEncoder): :cvar stripped: If True, the JSON objects will be serialized in a stripped manner, excluding some attributes. Defaults to ``False``. See https://git.rwth-aachen.de/acplt/pyi40aas/-/issues/91 + :cvar with_blob_value: If True, objects of type :class:`~basyx.aas.model.submodel.Blob` will be serialized + containing their value in base64 encoding. Otherwise, the ``value`` key is omitted. + Default to ``True``. :param deep: If True (default), all children of the serialized object are included on all sublevels. If False, only the serialized object and its direct children are included, while the children of these are omitted. This corresponds to the serialization level ``core`` of the AAS API specification. """ stripped = False + with_blob_value = True def __init__(self, *args, deep: bool = True, **kwargs): super().__init__(*args, **kwargs) @@ -565,7 +569,7 @@ def _blob_to_json(cls, obj: model.Blob) -> Dict[str, object]: data = cls._abstract_classes_to_json(obj) if obj.content_type is not None: data["contentType"] = obj.content_type - if obj.value is not None: + if cls.with_blob_value and obj.value is not None: data["value"] = base64.b64encode(obj.value).decode() return data diff --git a/sdk/test/adapter/json/formats/test_value_only_serialization.py b/sdk/test/adapter/json/formats/test_value_only_serialization.py index 21acf5fe8..1048397ec 100644 --- a/sdk/test/adapter/json/formats/test_value_only_serialization.py +++ b/sdk/test/adapter/json/formats/test_value_only_serialization.py @@ -552,3 +552,57 @@ def test_submodel_core_keeps_containers_of_omitted_elements_ok(self): "CollectionOfOperations": {}, "ListOfOperations": [], }, parsed_output) + + +class WithoutBlobValueValueOnlyJsonEncoder(ValueOnlyJsonEncoder): + with_blob_value = False + + +class TestValueOnlyJsonEncoderBlobValue(unittest.TestCase): + def setUp(self): + self.submodel = example_aas.create_example_submodel() + self.blob = self.submodel.get_referable("ExampleSubmodelCollection.ExampleBlob") + + def test_with_blob_value_is_default_ok(self): + out_str = json.dumps(self.blob, cls=ValueOnlyJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertTrue(ValueOnlyJsonEncoder.with_blob_value) + # the value is base64 encoded b"\x01\x02\x03\x04\x05" + self.assertEqual({"contentType": "application/pdf", "value": "AQIDBAU="}, parsed_output) + + def test_without_blob_value_omits_value_ok(self): + out_str = json.dumps(self.blob, cls=WithoutBlobValueValueOnlyJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertEqual({"contentType": "application/pdf"}, parsed_output) + + def test_without_blob_value_keeps_nested_blob_ok(self): + out_str = json.dumps(self.submodel, cls=WithoutBlobValueValueOnlyJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertEqual({"contentType": "application/pdf"}, parsed_output["ExampleSubmodelCollection"]["ExampleBlob"]) + + def test_without_blob_value_core_omits_blob_value_ok(self): + smc = self.submodel.get_referable("ExampleSubmodelCollection") + + out_str = json.dumps(smc, cls=WithoutBlobValueValueOnlyJsonEncoder, deep=False) + parsed_output = json.loads(out_str) + + self.assertEqual({"contentType": "application/pdf"}, parsed_output["ExampleBlob"]) + + def test_with_blob_value_empty_value_ok(self): + blob = model.Blob("EmptyBlob", "application/octet-stream", value=b"") + + out_str = json.dumps(blob, cls=ValueOnlyJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertEqual({"contentType": "application/octet-stream", "value": ""}, parsed_output) + + def test_with_blob_value_without_value_ok(self): + blob = model.Blob("BlobWithoutValue", "application/octet-stream") + + out_str = json.dumps(blob, cls=ValueOnlyJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertEqual({"contentType": "application/octet-stream"}, parsed_output) diff --git a/sdk/test/adapter/json/test_json_serialization.py b/sdk/test/adapter/json/test_json_serialization.py index b3d689d15..35aee2b4b 100644 --- a/sdk/test/adapter/json/test_json_serialization.py +++ b/sdk/test/adapter/json/test_json_serialization.py @@ -314,3 +314,46 @@ def test_stripped_asset_administration_shell(self) -> None: ) self._checkNormalAndStripped({"submodels"}, aas) + + +class WithoutBlobValueAASToJsonEncoder(AASToJsonEncoder): + with_blob_value = False + + +class TestAASToJsonEncoderBlobValue(unittest.TestCase): + def setUp(self): + self.submodel = example_aas.create_example_submodel() + self.blob = self.submodel.get_referable("ExampleSubmodelCollection.ExampleBlob") + + def test_with_blob_value_is_default_ok(self): + out_str = json.dumps(self.blob, cls=AASToJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertTrue(AASToJsonEncoder.with_blob_value) + # the value is base64 encoded b"\x01\x02\x03\x04\x05" + self.assertEqual("AQIDBAU=", parsed_output["value"]) + + def test_without_blob_value_omits_value_ok(self): + expected = json.loads(json.dumps(self.blob, cls=AASToJsonEncoder)) + expected.pop("value") + + out_str = json.dumps(self.blob, cls=WithoutBlobValueAASToJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertEqual(expected, parsed_output) + + def test_with_blob_value_empty_value_ok(self): + blob = model.Blob("EmptyBlob", "application/octet-stream", value=b"") + + out_str = json.dumps(blob, cls=AASToJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertEqual("", parsed_output["value"]) + + def test_with_blob_value_without_value_ok(self): + blob = model.Blob("BlobWithoutValue", "application/octet-stream") + + out_str = json.dumps(blob, cls=AASToJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertNotIn("value", parsed_output) From 7321c0cf867901bea766dc95d78b104ccf151c11 Mon Sep 17 00:00:00 2001 From: Joshua Benning Date: Tue, 27 Oct 2026 21:46:54 +0100 Subject: [PATCH 20/22] Realign path serialization and tests with supported types Following API Specification Part 2 --- .../json/formats/path_serialization.py | 34 +++++++++++++++---- .../json/formats/test_path_serialization.py | 32 +++++------------ 2 files changed, 37 insertions(+), 29 deletions(-) diff --git a/sdk/basyx/aas/adapter/json/formats/path_serialization.py b/sdk/basyx/aas/adapter/json/formats/path_serialization.py index 44ad2c78f..d38e459bc 100644 --- a/sdk/basyx/aas/adapter/json/formats/path_serialization.py +++ b/sdk/basyx/aas/adapter/json/formats/path_serialization.py @@ -5,17 +5,31 @@ # # SPDX-License-Identifier: MIT -from typing import Iterable, List +from typing import Iterable, List, Tuple from basyx.aas import model from ..json_serialization import AASToJsonEncoder +# Types for which the $path content modifier returns the idShort of the requested element and a list +# of idShort paths to its child elements (AAS API Part 2). +_PATH_SUPPORTED_TYPES: Tuple[type, ...] = ( + model.Submodel, + model.SubmodelElementCollection, + model.SubmodelElementList, + model.AnnotatedRelationshipElement, + model.Entity, +) + + class PathJsonEncoder(AASToJsonEncoder): """ - Custom JSONEncoder that serializes :class:`~basyx.aas.model.Referable` objects as the list of - idShortPaths reachable from them (``$path`` content modifier). + Custom JSONEncoder that serializes selected AAS objects as the list of idShortPaths reachable + from them (``$path`` content modifier). Supported inputs are :class:`~basyx.aas.model.Submodel`, + :class:`~basyx.aas.model.SubmodelElementCollection`, :class:`~basyx.aas.model.SubmodelElementList`, + :class:`~basyx.aas.model.AnnotatedRelationshipElement`, and :class:`~basyx.aas.model.Entity`. + Any other type raises :class:`TypeError`. :param deep: If True (default), all descendants on all sublevels are included. If False, only the requested element and its direct children are included. Compare to @@ -27,9 +41,12 @@ def __init__(self, *args, deep: bool = True, **kwargs): self.deep = deep def default(self, obj: object) -> object: - if isinstance(obj, model.Referable): + if isinstance(obj, _PATH_SUPPORTED_TYPES): return self._referable_to_path(obj) - return super().default(obj) + raise TypeError( + f"Object of type {type(obj).__name__} has no $path representation; " + f"supported types are: {', '.join(t.__name__ for t in _PATH_SUPPORTED_TYPES)}." + ) def _referable_to_path(self, obj: model.Referable) -> List[str]: if isinstance(obj, model.Submodel): @@ -37,6 +54,8 @@ def _referable_to_path(self, obj: model.Referable) -> List[str]: paths: List[str] = [] self._append_children(paths, obj.submodel_element, prefix=[]) return paths + # For the remaining supported types the idShort of the requested element is the root of + # every returned idShortPath. If it is missing, no path can be computed from this element. if obj.id_short is None: raise ValueError(f"{obj!r} has no idShort, so no idShortPath can be computed for it!") prefix = [obj.id_short] @@ -49,13 +68,16 @@ def _referable_to_path(self, obj: model.Referable) -> List[str]: self._append_children(paths, obj.statement, prefix) elif isinstance(obj, model.AnnotatedRelationshipElement): self._append_children(paths, obj.annotation, prefix) - # Other Referables are leaves with no further idShortPaths beyond self. return paths def _append_children( self, paths: List[str], children: Iterable[model.SubmodelElement], prefix: List[str] ) -> None: for child in children: + if child.id_short is None: + raise ValueError( + f"{child!r} has no idShort, so no idShortPath can be computed for it!" + ) child_prefix = prefix + [child.id_short] paths.append(model.Referable.build_id_short_path(child_prefix)) if self.deep: diff --git a/sdk/test/adapter/json/formats/test_path_serialization.py b/sdk/test/adapter/json/formats/test_path_serialization.py index 53006c9c9..837568b84 100644 --- a/sdk/test/adapter/json/formats/test_path_serialization.py +++ b/sdk/test/adapter/json/formats/test_path_serialization.py @@ -234,36 +234,22 @@ def test_annotated_relationship_element_without_annotation_ok(self): self.assertEqual(["ExampleAnnotatedRelationshipElement"], parsed_output) - def test_operation_variables_not_included_ok(self): + def test_operation_fail(self): operation = self.submodel.get_referable("ExampleOperation") - out_str = json.dumps(operation, cls=PathJsonEncoder) - parsed_output = json.loads(out_str) - - self.assertEqual(["ExampleOperation"], parsed_output) + with self.assertRaises(TypeError): + json.dumps(operation, cls=PathJsonEncoder) - def test_leaf_element_ok(self): + def test_leaf_data_element_fail(self): range_element = self.submodel.get_referable("ExampleSubmodelCollection.ExampleRange") - out_str = json.dumps(range_element, cls=PathJsonEncoder) - parsed_output = json.loads(out_str) - - self.assertEqual(["ExampleRange"], parsed_output) - - def test_submodel_element_list_child_fail(self): - submodel = example_aas_mandatory_attributes.create_example_submodel() - sml = submodel.get_referable("ExampleSubmodelList") - if not isinstance(sml, model.SubmodelElementList): - self.fail("Expected `ExampleSubmodelList` to be of type `SubmodelElementList`.") - sml_child = sml.value[0] - - with self.assertRaises(ValueError): - json.dumps(sml_child, cls=PathJsonEncoder) + with self.assertRaises(TypeError): + json.dumps(range_element, cls=PathJsonEncoder) - def test_element_without_id_short_fail(self): - prop = model.Property(None, model.datatypes.String) + def test_property_fail(self): + prop = model.Property("Prop", model.datatypes.String) - with self.assertRaises(ValueError): + with self.assertRaises(TypeError): json.dumps(prop, cls=PathJsonEncoder) def test_unsupported_type_fail(self): From 873c00be4b2c60cab45c2312d78acb6f71caf31e Mon Sep 17 00:00:00 2001 From: hpoeche Date: Tue, 27 Oct 2026 22:59:50 +0100 Subject: [PATCH 21/22] Fail Metadata serialization on unsupported types --- .../adapter/json/formats/metadata_serialization.py | 13 ++++++++++++- sdk/test/adapter/json/formats/__init__.py | 0 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 sdk/test/adapter/json/formats/__init__.py diff --git a/sdk/basyx/aas/adapter/json/formats/metadata_serialization.py b/sdk/basyx/aas/adapter/json/formats/metadata_serialization.py index bb0673250..1ce714c77 100644 --- a/sdk/basyx/aas/adapter/json/formats/metadata_serialization.py +++ b/sdk/basyx/aas/adapter/json/formats/metadata_serialization.py @@ -4,7 +4,7 @@ # the LICENSE file of this project. # # SPDX-License-Identifier: MIT -from typing import Dict +from typing import Callable, Dict, Type from basyx.aas import model @@ -17,6 +17,17 @@ class MetadataJsonEncoder(AASToJsonEncoder): the normal serialization format but omits certain keys. """ + @classmethod + def _get_aas_class_serializers(cls) -> Dict[Type, Callable]: + mapping = super()._get_aas_class_serializers() + + mapping.pop(model.AssetInformation, None) + mapping.pop(model.Resource, None) + mapping.pop(model.SpecificAssetId, None) + mapping.pop(model.ConceptDescription, None) + + return mapping + @classmethod def _asset_administration_shell_to_json( cls, obj: model.AssetAdministrationShell diff --git a/sdk/test/adapter/json/formats/__init__.py b/sdk/test/adapter/json/formats/__init__.py new file mode 100644 index 000000000..e69de29bb From 4bb8e0f6e39f8e5451aeea50b3105ed591c36492 Mon Sep 17 00:00:00 2001 From: hpoeche Date: Tue, 27 Oct 2026 23:09:52 +0100 Subject: [PATCH 22/22] Fix docstrings --- sdk/basyx/aas/adapter/json/formats/path_serialization.py | 8 ++++---- .../aas/adapter/json/formats/reference_serialization.py | 4 ++-- sdk/basyx/aas/adapter/json/json_serialization.py | 2 -- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/sdk/basyx/aas/adapter/json/formats/path_serialization.py b/sdk/basyx/aas/adapter/json/formats/path_serialization.py index d38e459bc..f5c44eca3 100644 --- a/sdk/basyx/aas/adapter/json/formats/path_serialization.py +++ b/sdk/basyx/aas/adapter/json/formats/path_serialization.py @@ -11,7 +11,6 @@ from ..json_serialization import AASToJsonEncoder - # Types for which the $path content modifier returns the idShort of the requested element and a list # of idShort paths to its child elements (AAS API Part 2). _PATH_SUPPORTED_TYPES: Tuple[type, ...] = ( @@ -26,9 +25,10 @@ class PathJsonEncoder(AASToJsonEncoder): """ Custom JSONEncoder that serializes selected AAS objects as the list of idShortPaths reachable - from them (``$path`` content modifier). Supported inputs are :class:`~basyx.aas.model.Submodel`, - :class:`~basyx.aas.model.SubmodelElementCollection`, :class:`~basyx.aas.model.SubmodelElementList`, - :class:`~basyx.aas.model.AnnotatedRelationshipElement`, and :class:`~basyx.aas.model.Entity`. + from them (``$path`` content modifier). Supported inputs are :class:`~basyx.aas.model.submodel.Submodel`, + :class:`~basyx.aas.model.submodel.SubmodelElementCollection`, + :class:`~basyx.aas.model.submodel.SubmodelElementList`, + :class:`~basyx.aas.model.submodel.AnnotatedRelationshipElement`, and :class:`~basyx.aas.model.submodel.Entity`. Any other type raises :class:`TypeError`. :param deep: If True (default), all descendants on all sublevels are included. If False, only diff --git a/sdk/basyx/aas/adapter/json/formats/reference_serialization.py b/sdk/basyx/aas/adapter/json/formats/reference_serialization.py index 74861c9ff..227112771 100644 --- a/sdk/basyx/aas/adapter/json/formats/reference_serialization.py +++ b/sdk/basyx/aas/adapter/json/formats/reference_serialization.py @@ -13,8 +13,8 @@ class ReferenceJsonEncoder(AASToJsonEncoder): """ - Custom JSONEncoder that serializes :class:`~basyx.aas.model.Referable` objects, which are contained - in an :class:`~basyx.aas.model.Identifiable`, as their :class:`~basyx.aas.model.ModelReference`. + Custom JSONEncoder that serializes :class:`~basyx.aas.model.base.Referable` objects, which are contained + in an :class:`~basyx.aas.model.base.Identifiable`, as their :class:`~basyx.aas.model.base.ModelReference`. """ @classmethod diff --git a/sdk/basyx/aas/adapter/json/json_serialization.py b/sdk/basyx/aas/adapter/json/json_serialization.py index 05f5bb8c1..32d1b8611 100644 --- a/sdk/basyx/aas/adapter/json/json_serialization.py +++ b/sdk/basyx/aas/adapter/json/json_serialization.py @@ -13,8 +13,6 @@ to be used with the Python standard :mod:`json` module. While the former serializes objects as defined in the specification, the latter serializes stripped objects, excluding some attributes (see https://git.rwth-aachen.de/acplt/pyi40aas/-/issues/91). -Additionally, :class:`CoreAASToJsonEncoder` serializes objects with the serialization level ``core`` of the AAS API -specification, only including the serialized object and its direct children. Each class contains a custom :meth:`~.AASToJsonEncoder.default` function which converts BaSyx Python SDK objects to simple python types for an automatic JSON serialization. To simplify the usage of this module, the :meth:`write_aas_json_file` and :meth:`object_store_to_json` are provided.