diff --git a/sdk/basyx/aas/adapter/_generic.py b/sdk/basyx/aas/adapter/_generic.py index 963a0df87..e8ecfbda1 100644 --- a/sdk/basyx/aas/adapter/_generic.py +++ b/sdk/basyx/aas/adapter/_generic.py @@ -10,10 +10,30 @@ """ import os -from typing import IO, BinaryIO, Dict, Type, Union +from typing import IO, BinaryIO, Dict, Optional, Type, Union from basyx.aas import model + +def serializable_id_short(obj: model.Referable) -> Optional[str]: + """ + Get the idShort of a :class:`~basyx.aas.model.base.Referable` as it is to be serialized. This hides the internal + idShort (prefixed with :attr:`~basyx.aas.model.submodel.SubmodelElementList.GENERATED_ID_SHORT_PREFIX`), which + the SDK generates for elements of a :class:`~basyx.aas.model.submodel.SubmodelElementList` added without an + idShort. For these, ``None`` is returned, while all other idShorts are returned as they are. + + :param obj: The Referable to get the idShort of + :return: The idShort to serialize or ``None``, if no idShort is to be serialized + """ + if ( + obj.id_short is not None + and isinstance(obj.parent, model.SubmodelElementList) + and obj.id_short.startswith(model.SubmodelElementList.GENERATED_ID_SHORT_PREFIX) + ): + return None + return obj.id_short + + # type aliases for path-like objects and IO # used by write_aas_xml_file, read_aas_xml_file, write_aas_json_file, read_aas_json_file Path = Union[str, bytes, os.PathLike] diff --git a/sdk/basyx/aas/adapter/_ordering.py b/sdk/basyx/aas/adapter/_ordering.py new file mode 100644 index 000000000..d2e64676a --- /dev/null +++ b/sdk/basyx/aas/adapter/_ordering.py @@ -0,0 +1,107 @@ +# 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 +""" +Sort keys for the canonical order of collections that are unordered according to the specification (every +multi-valued attribute not marked ``<>``). +""" +from typing import Optional, Tuple + +from basyx.aas import model + +from . import _generic + + +def reference_sort_key(obj: Optional[model.Reference]) -> Tuple: + """ + Sort key of a Reference, consisting of its type, its keys and its referredSemanticId + + :param obj: object of class Reference or ``None`` + :return: tuple which totally orders References, ``()`` for ``None`` + """ + if obj is None: + return () + return ( + _generic.REFERENCE_TYPES[obj.__class__], + tuple((_generic.KEY_TYPES[key.type], key.value) for key in obj.key), + reference_sort_key(obj.referred_semantic_id), + ) + + +def specific_asset_id_sort_key(obj: model.SpecificAssetId) -> Tuple: + """ + Sort key of a SpecificAssetId, consisting of all its attributes, since its name is not unique + + :param obj: object of class SpecificAssetId + :return: tuple which totally orders SpecificAssetIds + """ + return ( + obj.name, + obj.value, + reference_sort_key(obj.external_subject_id), + reference_sort_key(obj.semantic_id), + tuple(sorted(reference_sort_key(ref) for ref in obj.supplemental_semantic_id)), + ) + + +def embedded_data_specification_sort_key(obj: model.EmbeddedDataSpecification) -> Tuple: + """ + Sort key of an EmbeddedDataSpecification, consisting of its dataSpecification + + :param obj: object of class EmbeddedDataSpecification + :return: tuple which totally orders EmbeddedDataSpecifications + """ + return reference_sort_key(obj.data_specification) + + +def value_reference_pair_sort_key(obj: model.ValueReferencePair) -> Tuple: + """ + Sort key of a ValueReferencePair, consisting of its value and its valueId, since its value is not unique + + :param obj: object of class ValueReferencePair + :return: tuple which totally orders ValueReferencePairs + """ + return model.datatypes.xsd_repr(obj.value), reference_sort_key(obj.value_id) + + +def extension_sort_key(obj: model.Extension) -> str: + """ + Sort key of an Extension, its name, which is unique (Constraint AASd-077) + + :param obj: object of class Extension + :return: name of the Extension + """ + return obj.name + + +def qualifier_sort_key(obj: model.Qualifier) -> str: + """ + Sort key of a Qualifier, its type, which is unique (Constraint AASd-021) + + :param obj: object of class Qualifier + :return: type of the Qualifier + """ + return obj.type + + +def referable_sort_key(obj: model.Referable) -> str: + """ + Sort key of a Referable within its namespace, its idShort, which is unique (Constraint AASd-022) + + :param obj: object of class Referable + :return: idShort of the Referable, ``""`` if it has none + """ + return obj.id_short or "" + + +def identifiable_sort_key(obj: model.Identifiable) -> str: + """ + Sort key of an Identifiable, its globally unique id + + :param obj: object of class Identifiable + :return: id of the Identifiable + """ + return obj.id diff --git a/sdk/basyx/aas/adapter/json/__init__.py b/sdk/basyx/aas/adapter/json/__init__.py index d2431c211..3bca10502 100644 --- a/sdk/basyx/aas/adapter/json/__init__.py +++ b/sdk/basyx/aas/adapter/json/__init__.py @@ -15,8 +15,19 @@ object. A function :meth:`~basyx.aas.adapter.json.json_deserialization.read_aas_json_file` is provided to read all AAS objects within a JSON file and return them as BaSyx Python SDK :class:`ObjectStore `. + +``formats``: The subpackage offers JSONEncoders for the serialization formats of the AAS API specification (Part 2), +which are selected with a content modifier: +:class:`~basyx.aas.adapter.json.formats.metadata_serialization.MetadataJsonEncoder` (``$metadata``), +:class:`~basyx.aas.adapter.json.formats.value_only_serialization.ValueOnlyJsonEncoder` (``$value``), +:class:`~basyx.aas.adapter.json.formats.reference_serialization.ReferenceJsonEncoder` (``$reference``) and +:class:`~basyx.aas.adapter.json.formats.path_serialization.PathJsonEncoder` (``$path``). """ +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, @@ -43,4 +54,8 @@ "StrippedAASToJsonEncoder", "object_store_to_json", "write_aas_json_file", + "PathJsonEncoder", + "ValueOnlyJsonEncoder", + "MetadataJsonEncoder", + "ReferenceJsonEncoder", ] diff --git a/sdk/basyx/aas/adapter/json/formats/metadata_serialization.py b/sdk/basyx/aas/adapter/json/formats/metadata_serialization.py new file mode 100644 index 000000000..31894c00f --- /dev/null +++ b/sdk/basyx/aas/adapter/json/formats/metadata_serialization.py @@ -0,0 +1,149 @@ +# 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 +""" +Module for serializing Asset Administration Shell objects to the JSON format "Metadata" (content modifier +``$metadata``) +""" +from typing import Callable, Dict, Type + +from basyx.aas import model + +from ..json_serialization 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 holding the values, leaving only the metadata of the object. + + Objects without a Metadata serialization (AssetInformation, Resource, SpecificAssetId, ConceptDescription) raise + a :class:`TypeError`. + """ + + @classmethod + def _get_aas_class_serializers(cls) -> Dict[Type, Callable]: + mapping = super()._get_aas_class_serializers() + + # Without a serializer, the encoder raises a TypeError for these objects + + 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 + ) -> 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 diff --git a/sdk/basyx/aas/adapter/json/formats/path_serialization.py b/sdk/basyx/aas/adapter/json/formats/path_serialization.py new file mode 100644 index 000000000..c49d76440 --- /dev/null +++ b/sdk/basyx/aas/adapter/json/formats/path_serialization.py @@ -0,0 +1,115 @@ +# 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 +""" +Module for serializing Asset Administration Shell objects to the JSON format "Path" (content modifier ``$path``) +""" + +from typing import Iterable, List, Tuple, cast + +from basyx.aas import model +from basyx.aas.adapter import _generic, _ordering + +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 selected AAS objects as the list of idShortPaths reachable + 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`. + + The idShortPaths of children are listed in a canonical order: Elements of a SubmodelElementList in the order of + the list, all other children sorted by their idShort. + + :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. + :raises TypeError: If the object is of an unsupported type + :raises ValueError: If the object has no idShort (e.g. an element of a SubmodelElementList), so no idShortPath + can be computed for it + """ + + def __init__(self, *args, deep: bool = True, **kwargs): + super().__init__(*args, **kwargs) + self.deep = deep + + def default(self, obj: object) -> object: + if isinstance(obj, _PATH_SUPPORTED_TYPES): + return self._referable_to_path(cast(model.Referable, 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): + # 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 + # 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. + id_short = _generic.serializable_id_short(obj) + if id_short is None: + raise ValueError(f"{obj!r} has no idShort, so no idShortPath can be computed for it!") + prefix = [id_short] + 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) + return paths + + def _append_children( + self, paths: List[str], children: Iterable[model.SubmodelElement], prefix: List[str] + ) -> None: + for child in sorted(children, key=_ordering.referable_sort_key): + 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: + self._recurse(paths, child, child_prefix) + + def _append_indexed_children( + 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 self.deep: + self._recurse(paths, child, child_prefix) + + def _recurse(self, paths: List[str], element: object, prefix: List[str]) -> None: + if isinstance(element, model.SubmodelElementList): + self._append_indexed_children(paths, element.value, prefix) + elif isinstance(element, model.SubmodelElementCollection): + self._append_children(paths, element.value, prefix) + elif isinstance(element, model.Entity): + self._append_children(paths, element.statement, prefix) + elif isinstance(element, model.AnnotatedRelationshipElement): + self._append_children(paths, element.annotation, prefix) + # Leaf elements have no children to traverse into. diff --git a/sdk/basyx/aas/adapter/json/formats/reference_serialization.py b/sdk/basyx/aas/adapter/json/formats/reference_serialization.py new file mode 100644 index 000000000..8f4f19883 --- /dev/null +++ b/sdk/basyx/aas/adapter/json/formats/reference_serialization.py @@ -0,0 +1,50 @@ +# 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 +""" +Module for serializing Asset Administration Shell objects to the JSON format "Reference" (content modifier +``$reference``) +""" +from typing import Callable, Dict, Type + +from basyx.aas import model + +from ..json_serialization import AASToJsonEncoder + + +class ReferenceJsonEncoder(AASToJsonEncoder): + """ + 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`. + + Objects that are not Referable (except References and their Keys) have no Reference serialization and raise a + :class:`TypeError`. + """ + + @classmethod + def _get_aas_class_serializers(cls) -> Dict[Type, Callable]: + normal_serializers = super()._get_aas_class_serializers() + # Non-Referables are rejected, References and Keys are kept to serialize the resulting ModelReferences + 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/basyx/aas/adapter/json/formats/value_only_serialization.py b/sdk/basyx/aas/adapter/json/formats/value_only_serialization.py new file mode 100644 index 000000000..e288bc9e3 --- /dev/null +++ b/sdk/basyx/aas/adapter/json/formats/value_only_serialization.py @@ -0,0 +1,319 @@ +# 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 +""" +Module for serializing Asset Administration Shell objects to the JSON format "ValueOnly" (content modifier +``$value``) +""" +import base64 +import math +from typing import Callable, Iterable, Union + +from basyx.aas import model +from basyx.aas.adapter import _generic, _ordering + +from ..json_serialization import AASToJsonEncoder + +# SubmodelElements without a ValueOnly representation. They are omitted from their parent and raise a TypeError, if +# serialized directly. +TYPES_WITHOUT_VALUE_ONLY = (model.Capability, model.Operation) +# SubmodelElements whose ValueOnly representation consists only of their children +CONTAINER_TYPES = (model.SubmodelElementCollection, model.SubmodelElementList) + + +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. + * Non-finite ``xs:double`` and ``xs:float`` values (``NaN``, ``INF``, ``-INF``) are serialized as strings in + their XSD representation, since JSON numbers cannot represent them. + + SubmodelElements without a value (e.g. a Property whose value is ``None``) are omitted from their parent, as are + Operations and Capabilities, which have no ValueOnly representation. Elements of a SubmodelElementList are always + kept, so that their indices stay intact. + + Like in :class:`~basyx.aas.adapter.json.json_serialization.AASToJsonEncoder`, unordered collections are serialized + in a canonical order: The JSON objects with idShorts as names are sorted by idShort and the ``specificAssetIds`` + of an Entity are sorted by all their attributes. The value of a SubmodelElementList keeps its order. + + :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 + 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_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, + 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 _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: + """ + Determines whether a SubmodelElement has a value, so that it is included in the ValueOnly representation of + its parent + + :param obj: The SubmodelElement to check + :return: ``False`` if the element has no ValueOnly representation or none of its value attributes is set. + SubmodelElementCollections and SubmodelElementLists have a value if any of their children has one, + Entities also if any of their statements has one. + """ + 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) + 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 + + @classmethod + def _submodel_to_value_only(cls, obj: model.Submodel) -> dict[str, object]: + return cls._submodel_element_iterable_to_value_only(obj.submodel_element) + + @classmethod + def _submodel_element_list_to_value_only( + cls, obj: model.SubmodelElementList + ) -> list[object]: + # Unlike the children of other containers, elements without a value are kept, since their index is their + # identity within the list + 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 sorted(it, key=_ordering.referable_sort_key) + 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_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"] = 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 + 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"] = sorted(obj.specific_asset_id, key=_ordering.specific_asset_id_sort_key) + 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 + + # ############################################################# + # 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]: + # Sorted again by idShort, since the callers append SubmodelElementCollections and SubmodelElementLists + # without a value at the end + return {id_short: cls._remove_children(element) for id_short, element in sorted(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 necessary + 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/basyx/aas/adapter/json/json_serialization.py b/sdk/basyx/aas/adapter/json/json_serialization.py index 155aced55..5e5e96d5a 100644 --- a/sdk/basyx/aas/adapter/json/json_serialization.py +++ b/sdk/basyx/aas/adapter/json/json_serialization.py @@ -35,18 +35,16 @@ Callable, ContextManager, Dict, - Iterable, List, Optional, TextIO, - Tuple, Type, get_args, ) from basyx.aas import model -from .. import _generic +from .. import _generic, _ordering from .._generic import JSON_AAS_TOP_LEVEL_KEYS_TO_TYPES @@ -64,12 +62,31 @@ 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``. + + Collections which are unordered according to the specification (every multi-valued attribute not marked + ``<>``) are serialized in a canonical order, so that equal objects always result in identical JSON + strings. Only the keys of a :class:`~basyx.aas.model.base.Reference` and the value of a + :class:`~basyx.aas.model.submodel.SubmodelElementList` keep their order. + :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. + Defaults 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) + self.deep = deep @classmethod def _get_aas_class_serializers(cls) -> Dict[Type, Callable]: @@ -105,6 +122,41 @@ def _get_aas_class_serializers(cls) -> Dict[Type, Callable]: } return mapping + @classmethod + def _get_aas_class_no_children_serializers(cls) -> Dict[Type, Callable]: + """ + Returns the serialization functions used with ``deep=False`` instead of those of + :meth:`_get_aas_class_serializers`. Only classes that contain children have an entry. + + :return: Dict mapping each class with children to the function serializing it without the children of its + children + """ + 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]: + """ + Returns the JSON key holding the children of each class that contains children. Used by + :meth:`_remove_children` to strip the children of a child element with ``deep=False``. + + :return: Dict mapping each class with children to the JSON key of its children + """ + 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 +164,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): @@ -128,9 +186,30 @@ def _abstract_classes_to_json(cls, obj: object) -> Dict[str, object]: :return: dict with the serialized attributes of the abstract classes this object inherits from """ data: Dict[str, object] = {} + if isinstance(obj, model.Referable): + try: + ref_type = model.resolve_referable_class_in_key_types(obj) + except StopIteration as e: + raise TypeError( + "Object of type {} is Referable but does not inherit from a known AAS type".format( + obj.__class__.__name__ + ) + ) from e + data["modelType"] = ref_type.__name__ + id_short = _generic.serializable_id_short(obj) + if id_short: + data["idShort"] = id_short + if obj.display_name: + data["displayName"] = obj.display_name + if obj.category: + data["category"] = obj.category + if obj.description: + data["description"] = obj.description + if isinstance(obj, model.HasExtension) and not cls.stripped: if obj.extension: - data["extensions"] = list(obj.extension) + data["extensions"] = sorted(obj.extension, key=_ordering.extension_sort_key) + if isinstance(obj, model.HasDataSpecification) and not cls.stripped: if obj.embedded_data_specifications: data["embeddedDataSpecifications"] = [ @@ -138,27 +217,12 @@ def _abstract_classes_to_json(cls, obj: object) -> Dict[str, object]: "dataSpecification": spec.data_specification, "dataSpecificationContent": spec.data_specification_content, } - for spec in obj.embedded_data_specifications + for spec in sorted( + obj.embedded_data_specifications, + key=lambda s: _ordering.embedded_data_specification_sort_key(s), + ) ] - if isinstance(obj, model.Referable): - if obj.id_short and not isinstance(obj.parent, model.SubmodelElementList): - data["idShort"] = obj.id_short - if obj.display_name: - data["displayName"] = obj.display_name - if obj.category: - data["category"] = obj.category - if obj.description: - data["description"] = obj.description - try: - ref_type = model.resolve_referable_class_in_key_types(obj) - except StopIteration as e: - raise TypeError( - "Object of type {} is Referable but does not inherit from a known AAS type".format( - obj.__class__.__name__ - ) - ) from e - data["modelType"] = ref_type.__name__ if isinstance(obj, model.Identifiable): data["id"] = obj.id if obj.administration: @@ -167,13 +231,18 @@ def _abstract_classes_to_json(cls, obj: object) -> Dict[str, object]: if obj.semantic_id: data["semanticId"] = obj.semantic_id if obj.supplemental_semantic_id: - data["supplementalSemanticIds"] = list(obj.supplemental_semantic_id) + data["supplementalSemanticIds"] = sorted( + obj.supplemental_semantic_id, key=_ordering.reference_sort_key + ) + if isinstance(obj, model.HasKind): if obj.kind is model.ModellingKind.TEMPLATE: data["kind"] = _generic.MODELLING_KIND[obj.kind] + if isinstance(obj, model.Qualifiable) and not cls.stripped: if obj.qualifier: - data["qualifiers"] = list(obj.qualifier) + data["qualifiers"] = sorted(obj.qualifier, key=_ordering.qualifier_sort_key) + return data # ############################################################# @@ -184,7 +253,7 @@ def _abstract_classes_to_json(cls, obj: object) -> Dict[str, object]: def _lang_string_set_to_json( cls, obj: model.LangStringSet ) -> List[Dict[str, object]]: - return [{"language": k, "text": v} for k, v in obj.items()] + return [{"language": k, "text": v} for k, v in sorted(obj.items())] @classmethod def _key_to_json(cls, obj: model.Key) -> Dict[str, object]: @@ -283,7 +352,7 @@ def _extension_to_json(cls, obj: model.Extension) -> Dict[str, object]: model.datatypes.xsd_repr(obj.value) if obj.value is not None else None ) if obj.refers_to: - data["refersTo"] = list(obj.refers_to) + data["refersTo"] = sorted(obj.refers_to, key=_ordering.reference_sort_key) if obj.value_type: data["valueType"] = model.datatypes.XSD_TYPE_NAMES[obj.value_type] data["name"] = obj.name @@ -313,7 +382,11 @@ def _value_list_to_json(cls, obj: model.ValueList) -> Dict[str, object]: :param obj: object of class ValueList :return: dict with the serialized attributes of this object """ - return {"valueReferencePairs": list(obj)} + return { + "valueReferencePairs": sorted( + obj, key=_ordering.value_reference_pair_sort_key + ) + } # ############################################################ # transformation functions to serialize classes from model.aas @@ -351,7 +424,9 @@ def _asset_information_to_json( if obj.global_asset_id: data["globalAssetId"] = obj.global_asset_id if obj.specific_asset_id: - data["specificAssetIds"] = list(obj.specific_asset_id) + data["specificAssetIds"] = sorted( + obj.specific_asset_id, key=_ordering.specific_asset_id_sort_key + ) if obj.asset_type: data["assetType"] = obj.asset_type if obj.default_thumbnail: @@ -370,7 +445,7 @@ def _concept_description_to_json( """ data = cls._abstract_classes_to_json(obj) if obj.is_case_of: - data["isCaseOf"] = list(obj.is_case_of) + data["isCaseOf"] = sorted(obj.is_case_of, key=_ordering.reference_sort_key) return data @classmethod @@ -431,7 +506,7 @@ def _asset_administration_shell_to_json( if obj.asset_information: data["assetInformation"] = obj.asset_information if not cls.stripped and obj.submodel: - data["submodels"] = list(obj.submodel) + data["submodels"] = sorted(obj.submodel, key=_ordering.reference_sort_key) return data # ################################################################# @@ -450,7 +525,9 @@ def _submodel_to_json( """ data = cls._abstract_classes_to_json(obj) if not cls.stripped and obj.submodel_element != set(): - data["submodelElements"] = list(obj.submodel_element) + data["submodelElements"] = sorted( + obj.submodel_element, key=_ordering.referable_sort_key + ) return data @classmethod @@ -525,7 +602,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 @@ -585,7 +662,7 @@ def _submodel_element_collection_to_json( """ data = cls._abstract_classes_to_json(obj) if not cls.stripped and len(obj.value) > 0: - data["value"] = list(obj.value) + data["value"] = sorted(obj.value, key=_ordering.referable_sort_key) return data @classmethod @@ -644,7 +721,9 @@ def _annotated_relationship_element_to_json( """ data = cls._relationship_element_to_json(obj) if not cls.stripped and obj.annotation: - data["annotations"] = list(obj.annotation) + data["annotations"] = sorted( + obj.annotation, key=_ordering.referable_sort_key + ) return data @classmethod @@ -678,7 +757,10 @@ def _operation_to_json(cls, obj: model.Operation) -> Dict[str, object]: ("inoutputVariables", obj.in_output_variable), ): if nss: - data[tag] = [cls._operation_variable_to_json(obj) for obj in nss] + data[tag] = [ + cls._operation_variable_to_json(variable) + for variable in sorted(nss, key=_ordering.referable_sort_key) + ] return data @classmethod @@ -703,13 +785,15 @@ def _entity_to_json(cls, obj: model.Entity) -> Dict[str, object]: """ data = cls._abstract_classes_to_json(obj) if not cls.stripped and obj.statement: - data["statements"] = list(obj.statement) + data["statements"] = sorted(obj.statement, key=_ordering.referable_sort_key) if obj.entity_type is not None: data["entityType"] = _generic.ENTITY_TYPES[obj.entity_type] if obj.global_asset_id is not None: data["globalAssetId"] = obj.global_asset_id if obj.specific_asset_id: - data["specificAssetIds"] = list(obj.specific_asset_id) + data["specificAssetIds"] = sorted( + obj.specific_asset_id, key=_ordering.specific_asset_id_sort_key + ) return data @classmethod @@ -750,6 +834,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): """ @@ -776,35 +959,30 @@ def _select_encoder( return AASToJsonEncoder if not stripped else StrippedAASToJsonEncoder -def _create_dict( - data: model.AbstractObjectStore, - keys_to_types: Iterable[Tuple[str, Type]] = JSON_AAS_TOP_LEVEL_KEYS_TO_TYPES, -) -> Dict[str, List[model.Identifiable]]: +def _create_dict(data: model.AbstractObjectStore) -> Dict[str, List[model.Identifiable]]: """ Categorizes objects from an AbstractObjectStore into a dictionary based on their types. This function iterates over the objects in the provided AbstractObjectStore and groups them into lists - based on their types, as defined in the `keys_to_types` mapping. The resulting dictionary contains - keys corresponding to the names in `keys_to_types` and values as lists of objects of the respective types. + based on their types, as defined in ``JSON_AAS_TOP_LEVEL_KEYS_TO_TYPES``. The resulting dictionary contains + the top-level keys of the JSON format and values as lists of objects of the respective types. + The keys are ordered as in ``JSON_AAS_TOP_LEVEL_KEYS_TO_TYPES`` and each list is sorted by the id of the objects, + so that the order does not depend on the iteration order of the AbstractObjectStore. :param data: An AbstractObjectStore containing objects to be categorized. - :param keys_to_types: An iterable of tuples where each tuple contains: - - A string key representing the category name. - - A type to match objects against. :return: A dictionary where keys are category names and values are lists of objects of the corresponding types. """ - objects: Dict[str, List[model.Identifiable]] = {} + objects: Dict[str, List[model.Identifiable]] = {name: [] for name, _ in JSON_AAS_TOP_LEVEL_KEYS_TO_TYPES} for obj in data: # Iterate through the mapping of category names to expected types - for name, expected_type in keys_to_types: + for name, expected_type in JSON_AAS_TOP_LEVEL_KEYS_TO_TYPES: # Check if the object matches the expected type if isinstance(obj, expected_type): # Add the object to the appropriate category in the dictionary - objects.setdefault(name, []) objects[name].append(obj) break # Exit the inner loop once a match is found - return objects + return {name: sorted(objs, key=_ordering.identifiable_sort_key) for name, objs in objects.items() if objs} def object_store_to_json( diff --git a/sdk/basyx/aas/adapter/xml/xml_serialization.py b/sdk/basyx/aas/adapter/xml/xml_serialization.py index 8570add66..c849c8d50 100644 --- a/sdk/basyx/aas/adapter/xml/xml_serialization.py +++ b/sdk/basyx/aas/adapter/xml/xml_serialization.py @@ -110,7 +110,7 @@ def abstract_classes_to_xml(tag: str, obj: object) -> etree._Element: if isinstance(obj, model.Referable): if obj.category: elm.append(_generate_element(name=NS_AAS + "category", text=obj.category)) - if obj.id_short and not isinstance(obj.parent, model.SubmodelElementList): + if _generic.serializable_id_short(obj): elm.append(_generate_element(name=NS_AAS + "idShort", text=obj.id_short)) if obj.display_name: elm.append( diff --git a/sdk/basyx/aas/model/submodel.py b/sdk/basyx/aas/model/submodel.py index bd4c8754a..b89dc97a7 100644 --- a/sdk/basyx/aas/model/submodel.py +++ b/sdk/basyx/aas/model/submodel.py @@ -814,8 +814,13 @@ class SubmodelElementList(SubmodelElement, base.UniqueIdShortNamespace, Generic[ supplemental semantic ID of the element. (inherited from :class:`~basyx.aas.model.base.HasSemantics`) :ivar embedded_data_specifications: List of Embedded data specification. + + :cvar GENERATED_ID_SHORT_PREFIX: Prefix of the idShort generated for elements added without an idShort. It is only + used internally and must never be serialized. """ + GENERATED_ID_SHORT_PREFIX = "generated_submodel_list_hack_" + def __init__( self, id_short: Optional[base.NameType], @@ -897,14 +902,14 @@ def _generate_id_short(self, new: _SE) -> None: # a unique attribute for child-elements (which contradicts the definition of a Namespace). if new.id_short is None: new.id_short = ( - "generated_submodel_list_hack_" + self.GENERATED_ID_SHORT_PREFIX + uuid.uuid1(clock_seq=self._uuid_seq).hex ) self._uuid_seq += 1 def _unset_id_short(self, old: _SE) -> None: if old.id_short is not None and old.id_short.startswith( - "generated_submodel_list_hack_" + self.GENERATED_ID_SHORT_PREFIX ): old.id_short = None 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 diff --git a/sdk/test/adapter/json/formats/test_core_serialization.py b/sdk/test/adapter/json/formats/test_core_serialization.py new file mode 100644 index 000000000..0069dc4ab --- /dev/null +++ b/sdk/test/adapter/json/formats/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/formats/test_metadata_serialization.py b/sdk/test/adapter/json/formats/test_metadata_serialization.py new file mode 100644 index 000000000..51a8d5229 --- /dev/null +++ b/sdk/test/adapter/json/formats/test_metadata_serialization.py @@ -0,0 +1,296 @@ +# 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) + + def test_submodel_element_list_elements_idShort_ok(self) -> None: + """Verify, that adding SE to a SML does not mess with the idShorts""" + prop1 = model.Property(id_short=None, value_type=model.datatypes.String, value="WithoutIdShort") + prop2 = model.Property(id_short="exampleIdShort", value_type=model.datatypes.String, value="WithIdShort") + model.SubmodelElementList( + id_short="exampleList", + type_value_list_element=model.Property, + value_type_list_element=model.datatypes.String, + value=[prop1, prop2], + ) + + data1 = json.loads(json.dumps(prop1, cls=MetadataJsonEncoder)) + data2 = json.loads(json.dumps(prop2, cls=MetadataJsonEncoder)) + + self.assertNotIn("idShort", data1) + self.assertEqual("exampleIdShort", data2["idShort"]) + + # ----- 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]) diff --git a/sdk/test/adapter/json/formats/test_path_serialization.py b/sdk/test/adapter/json/formats/test_path_serialization.py new file mode 100644 index 000000000..c27b00c10 --- /dev/null +++ b/sdk/test/adapter/json/formats/test_path_serialization.py @@ -0,0 +1,347 @@ +# 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 PathJsonEncoder +from basyx.aas.examples.data import example_aas, example_aas_mandatory_attributes + + +class TestPathJsonEncoder(unittest.TestCase): + def setUp(self): + self.submodel = example_aas.create_example_submodel() + + def test_submodel_deep_ok(self): + out_str = json.dumps(self.submodel, cls=PathJsonEncoder) + parsed_output = json.loads(out_str) + + 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_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) + + 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) + + self.assertEqual(deep_output, parsed_output) + + def test_submodel_without_id_short_ok(self): + submodel = example_aas_mandatory_attributes.create_example_submodel() + + 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_empty_submodel_ok(self): + submodel = example_aas_mandatory_attributes.create_example_empty_submodel() + + out_str = json.dumps(submodel, cls=PathJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertEqual([], parsed_output) + + def test_submodel_element_collection_without_id_short_fail(self): + smc = model.SubmodelElementCollection(None, value=[ + model.Property("example", value_type=model.datatypes.String, value="Test") + ]) + + with self.assertRaises(ValueError): + json.dumps(smc, cls=PathJsonEncoder) + + 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) + + 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) + + 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) + + 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") + + 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_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) + + self.assertEqual(["ExampleSubmodelList2"], parsed_output) + + def test_submodel_element_list_elements_without_id_short_fail(self): + smc = model.SubmodelElementCollection( + None, value=[model.Property("example", value_type=model.datatypes.String, value="Test")] + ) + model.SubmodelElementList( + "ExampleSubmodelList", + type_value_list_element=model.SubmodelElementCollection, + value=[smc], + ) + + with self.assertRaises(ValueError): + json.dumps(smc, cls=PathJsonEncoder) + + def test_submodel_element_list_elements_with_id_short_ok(self): + smc = model.SubmodelElementCollection( + "ExampleCollection", value=[model.Property("example", value_type=model.datatypes.String, value="Test")] + ) + model.SubmodelElementList( + "ExampleSubmodelList", + type_value_list_element=model.SubmodelElementCollection, + value=[smc], + ) + + out_str = json.dumps(smc, cls=PathJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertEqual(["ExampleCollection", "ExampleCollection.example"], parsed_output) + + 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=PathJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertCountEqual([ + "ExampleEntity", + "ExampleEntity.ExampleProperty", + "ExampleEntity.ExampleProperty2", + ], parsed_output) + + def test_submodel_with_entity_deep_ok(self): + submodel = example_aas.create_example_bill_of_material_submodel() + + out_str = json.dumps(submodel, cls=PathJsonEncoder) + parsed_output = json.loads(out_str) + + 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") + + 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_fail(self): + operation = self.submodel.get_referable("ExampleOperation") + + with self.assertRaises(TypeError): + json.dumps(operation, cls=PathJsonEncoder) + + def test_leaf_data_element_fail(self): + range_element = self.submodel.get_referable("ExampleSubmodelCollection.ExampleRange") + + with self.assertRaises(TypeError): + json.dumps(range_element, cls=PathJsonEncoder) + + def test_property_fail(self): + prop = model.Property("Prop", model.datatypes.String) + + with self.assertRaises(TypeError): + 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) + + +class TestPathJsonEncoderOrder(unittest.TestCase): + def test_submodel_children_sorted_by_id_short_ok(self): + submodel = model.Submodel("https://example.org/Submodel", submodel_element=[ + model.SubmodelElementCollection("b", [ + model.Property("d", model.datatypes.String), + model.Property("c", model.datatypes.String), + ]), + model.Property("a", model.datatypes.String), + ]) + + out_str = json.dumps(submodel, cls=PathJsonEncoder) + + self.assertEqual('["a", "b", "b.c", "b.d"]', out_str) + + def test_annotations_sorted_by_id_short_ok(self): + relationship = model.AnnotatedRelationshipElement( + "Relationship", + first=model.ExternalReference((model.Key(model.KeyTypes.GLOBAL_REFERENCE, "first"),)), + second=model.ExternalReference((model.Key(model.KeyTypes.GLOBAL_REFERENCE, "second"),)), + annotation=[ + model.Property("b", model.datatypes.String), + model.Property("a", model.datatypes.String), + ], + ) + + out_str = json.dumps(relationship, cls=PathJsonEncoder) + + self.assertEqual('["Relationship", "Relationship.a", "Relationship.b"]', out_str) + + def test_entity_statements_sorted_by_id_short_ok(self): + entity = model.Entity("Entity", model.EntityType.CO_MANAGED_ENTITY, statement=[ + model.Property("b", model.datatypes.String), + model.Property("a", model.datatypes.String), + ]) + + out_str = json.dumps(entity, cls=PathJsonEncoder) + + self.assertEqual('["Entity", "Entity.a", "Entity.b"]', out_str) diff --git a/sdk/test/adapter/json/formats/test_reference_serialization.py b/sdk/test/adapter/json/formats/test_reference_serialization.py new file mode 100644 index 000000000..da4668851 --- /dev/null +++ b/sdk/test/adapter/json/formats/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 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/sdk/test/adapter/json/formats/test_value_only_serialization.py b/sdk/test/adapter/json/formats/test_value_only_serialization.py new file mode 100644 index 000000000..fe8345481 --- /dev/null +++ b/sdk/test/adapter/json/formats/test_value_only_serialization.py @@ -0,0 +1,694 @@ +# 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 decimal +import json +import math +import unittest + +from basyx.aas import model +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" + + +class TestValueOnlyJsonEncoder(unittest.TestCase): + def setUp(self): + self.submodel = example_aas.create_example_submodel() + + # ------------------------------------------------------------------ Submodel + + 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({"ManufacturerName": "ACPLT", "InstanceId": "978-8234-234-342"}, parsed_output) + + def test_submodel_omits_operation_and_capability_ok(self): + out_str = json.dumps(self.submodel, cls=ValueOnlyJsonEncoder) + parsed_output = json.loads(out_str) + + 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.assertEqual({"FilledProperty": "exampleValue"}, parsed_output) + + 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(submodel, cls=ValueOnlyJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertEqual({"FilledProperty": "exampleValue"}, parsed_output) + + def test_empty_submodel_ok(self): + submodel = example_aas_mandatory_attributes.create_example_empty_submodel() + + out_str = json.dumps(submodel, cls=ValueOnlyJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertEqual({}, parsed_output) + + # ------------------------------------------------------------------ SubmodelElementCollection / -List + + 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.assertEqual(["exampleValue", "exampleValue"], parsed_output) + + 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(sml, cls=ValueOnlyJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertEqual(["first", None, "third"], parsed_output) + + 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=ValueOnlyJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertEqual([], parsed_output) + + # ------------------------------------------------------------------ Property data type to value mapping + + def test_property_ok(self): + submodel = example_aas.create_example_asset_identification_submodel() + prop = submodel.get_referable("ManufacturerName") + + out_str = json.dumps(prop, cls=ValueOnlyJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertEqual("ACPLT", parsed_output) + + def test_property_without_value_ok(self): + prop = model.Property("EmptyProperty", model.datatypes.String) + + out_str = json.dumps(prop, cls=ValueOnlyJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertIsNone(parsed_output) + + 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(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) + + 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) + + # the value is base64 encoded b"\x01\x02\x03\x04\x05" + self.assertEqual({"contentType": "application/pdf", "value": "AQIDBAU="}, parsed_output) + + 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"}, + ], + }, parsed_output) + + def test_relationship_element_ok(self): + relationship_element = self.submodel.get_referable("ExampleRelationshipElement") + + 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(submodel, cls=ValueOnlyJsonEncoder, deep=False) + parsed_output = json.loads(out_str) + + self.assertEqual({ + "FilledProperty": "exampleValue", + "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) + + +class TestValueOnlyJsonEncoderOrder(unittest.TestCase): + def test_submodel_elements_sorted_by_id_short_ok(self): + submodel = model.Submodel("https://example.org/Submodel", submodel_element=[ + model.Property("c", model.datatypes.String, "c"), + model.Property("a", model.datatypes.String, "a"), + model.Property("b", model.datatypes.String, "b"), + ]) + + out_str = json.dumps(submodel, cls=ValueOnlyJsonEncoder) + + self.assertEqual('{"a": "a", "b": "b", "c": "c"}', out_str) + + def test_submodel_element_collection_value_sorted_by_id_short_ok(self): + collection = model.SubmodelElementCollection("Collection", [ + model.Property("b", model.datatypes.String, "b"), + model.Property("a", model.datatypes.String, "a"), + ]) + + out_str = json.dumps(collection, cls=ValueOnlyJsonEncoder) + + self.assertEqual('{"a": "a", "b": "b"}', out_str) + + def test_annotations_sorted_by_id_short_ok(self): + relationship = model.AnnotatedRelationshipElement( + "Relationship", + first=model.ExternalReference((model.Key(model.KeyTypes.GLOBAL_REFERENCE, "first"),)), + second=model.ExternalReference((model.Key(model.KeyTypes.GLOBAL_REFERENCE, "second"),)), + annotation=[ + model.Property("b", model.datatypes.String, "b"), + model.Property("a", model.datatypes.String, "a"), + ], + ) + + out_str = json.dumps(relationship, cls=ValueOnlyJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertEqual(["a", "b"], list(parsed_output["annotations"])) + + def test_entity_statements_sorted_by_id_short_ok(self): + entity = model.Entity("Entity", model.EntityType.CO_MANAGED_ENTITY, statement=[ + model.Property("b", model.datatypes.String, "b"), + model.Property("a", model.datatypes.String, "a"), + ]) + + out_str = json.dumps(entity, cls=ValueOnlyJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertEqual(["a", "b"], list(parsed_output["statements"])) + + def test_entity_specific_asset_ids_sorted_ok(self): + entity = model.Entity("Entity", model.EntityType.SELF_MANAGED_ENTITY, specific_asset_id=[ + model.SpecificAssetId("b", "1"), + model.SpecificAssetId("a", "2"), + model.SpecificAssetId("a", "1"), + ]) + + out_str = json.dumps(entity, cls=ValueOnlyJsonEncoder) + parsed_output = json.loads(out_str) + + self.assertEqual( + [("a", "1"), ("a", "2"), ("b", "1")], + [(asset_id["name"], asset_id["value"]) for asset_id in parsed_output["specificAssetIds"]], + ) + + def test_submodel_core_containers_without_value_sorted_by_id_short_ok(self): + submodel = model.Submodel("https://example.org/Submodel", submodel_element=[ + model.SubmodelElementCollection("a", [model.Operation("Operation")]), + model.Property("b", model.datatypes.String, "b"), + ]) + + out_str = json.dumps(submodel, cls=ValueOnlyJsonEncoder, deep=False) + + self.assertEqual('{"a": {}, "b": "b"}', out_str) + + def test_entity_core_containers_without_value_sorted_by_id_short_ok(self): + entity = model.Entity("Entity", model.EntityType.CO_MANAGED_ENTITY, statement=[ + model.SubmodelElementCollection("a", [model.Operation("Operation")]), + model.Property("b", model.datatypes.String, "b"), + ]) + + out_str = json.dumps(entity, cls=ValueOnlyJsonEncoder, deep=False) + parsed_output = json.loads(out_str) + + self.assertEqual(["a", "b"], list(parsed_output["statements"])) diff --git a/sdk/test/adapter/json/test_json_serialization.py b/sdk/test/adapter/json/test_json_serialization.py index b3d689d15..405348912 100644 --- a/sdk/test/adapter/json/test_json_serialization.py +++ b/sdk/test/adapter/json/test_json_serialization.py @@ -14,6 +14,7 @@ from basyx.aas.adapter.json import ( AASToJsonEncoder, StrippedAASToJsonEncoder, + object_store_to_json, write_aas_json_file, ) from basyx.aas.examples.data import ( @@ -89,6 +90,27 @@ def test_entity_serializes_specific_asset_ids(self) -> None: self.assertEqual(1, len(data["specificAssetIds"])) self.assertEqual("test_name", data["specificAssetIds"][0]["name"]) + def test_submodel_list_elements_idShort_ok(self) -> None: + """Verify, that adding SE to a SML does not mess with their idShorts in any serialization""" + prop1 = model.Property(id_short=None, value_type=model.datatypes.String, value="WithoutIdShort") + prop2 = model.Property(id_short="exampleIdShort", value_type=model.datatypes.String, value="WithIdShort") + sml = model.SubmodelElementList( + id_short="exampleList", + type_value_list_element=model.Property, + value_type_list_element=model.datatypes.String, + value=[prop1, prop2], + ) + + data1 = json.loads(json.dumps(prop1, cls=AASToJsonEncoder)) + data2 = json.loads(json.dumps(prop2, cls=AASToJsonEncoder)) + data_sml = json.loads(json.dumps(sml, cls=AASToJsonEncoder)) + + self.assertNotIn("idShort", data1) + self.assertEqual("exampleIdShort", data2["idShort"]) + + self.assertNotIn("idShort", data_sml["value"][0].keys()) + self.assertEqual("exampleIdShort", data_sml["value"][1]["idShort"]) + class JsonSerializationSchemaTest(unittest.TestCase): @classmethod @@ -314,3 +336,232 @@ 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) + + +def _external_reference(*values: str) -> model.ExternalReference: + return model.ExternalReference( + tuple(model.Key(model.KeyTypes.GLOBAL_REFERENCE, value) for value in values) + ) + + +def _submodel_reference(identifier: str) -> model.ModelReference: + return model.ModelReference( + (model.Key(model.KeyTypes.SUBMODEL, identifier),), model.Submodel + ) + + +class TestAASToJsonEncoderOrder(unittest.TestCase): + """ + Collections that are unordered according to the specification must be serialized in a canonical order, so that + equal objects always produce identical JSON strings, independent of insertion order and hash randomization. + The elements are always inserted in reverse order of the expected output. + """ + + def test_referables_start_with_modelType_ok(self) -> None: + referables = [ + model.AssetAdministrationShell( + model.AssetInformation(global_asset_id="urn:asset"), "urn:aas" + ), + model.Submodel("urn:sm"), + model.ConceptDescription("urn:cd"), + model.Property("prop", model.datatypes.String), + model.MultiLanguageProperty("mlp"), + model.Range("range", model.datatypes.Int), + model.Blob("blob", "application/octet-stream"), + model.File("file", "text/plain"), + model.ReferenceElement("ref"), + model.SubmodelElementCollection("smc"), + model.SubmodelElementList("sml", model.Capability), + model.RelationshipElement("rel"), + model.AnnotatedRelationshipElement("arel"), + model.Operation("op"), + model.Capability("cap"), + model.Entity("entity", model.EntityType.CO_MANAGED_ENTITY), + model.BasicEventElement( + "event", + _submodel_reference("urn:sm"), + model.Direction.OUTPUT, + model.StateOfEvent.ON, + ), + ] + + data = [json.loads(json.dumps(r, cls=AASToJsonEncoder)) for r in referables] + + self.assertEqual( + [(type(r).__name__, "modelType") for r in referables], + [(type(r).__name__, next(iter(d))) for r, d in zip(referables, data)], + ) + + def test_extensions_sorted_by_name_ok(self) -> None: + submodel = model.Submodel( + "urn:sm", + extension=[model.Extension(name) for name in ["e2", "e1", "e0"]], + ) + + data = json.loads(json.dumps(submodel, cls=AASToJsonEncoder)) + + self.assertEqual(["e0", "e1", "e2"], [e["name"] for e in data["extensions"]]) + + def test_embedded_data_specifications_sorted_by_data_specification_ok(self) -> None: + content = model.DataSpecificationIEC61360( + model.PreferredNameTypeIEC61360({"en": "name"}) + ) + submodel = model.Submodel( + "urn:sm", + embedded_data_specifications=[ + model.EmbeddedDataSpecification(_external_reference(value), content) + for value in ["urn:ds2", "urn:ds1", "urn:ds0"] + ], + ) + + data = json.loads(json.dumps(submodel, cls=AASToJsonEncoder)) + + self.assertEqual( + ["urn:ds0", "urn:ds1", "urn:ds2"], + [ + spec["dataSpecification"]["keys"][0]["value"] + for spec in data["embeddedDataSpecifications"] + ], + ) + + + def test_lang_string_sets_sorted_by_language_ok(self) -> None: + prop = model.Property( + "prop", + model.datatypes.String, + display_name=model.MultiLanguageNameType({"fr": "n", "en": "n", "de": "n"}), + description=model.MultiLanguageTextType({"fr": "t", "en": "t", "de": "t"}), + ) + + data = json.loads(json.dumps(prop, cls=AASToJsonEncoder)) + + self.assertEqual( + ["de", "en", "fr"], [s["language"] for s in data["displayName"]] + ) + self.assertEqual( + ["de", "en", "fr"], [s["language"] for s in data["description"]] + ) + + def test_qualifiers_sorted_by_type_ok(self) -> None: + submodel = model.Submodel( + "urn:sm", + qualifier=[ + model.Qualifier(type_, model.datatypes.String) + for type_ in ["q2", "q1", "q0"] + ], + ) + + data = json.loads(json.dumps(submodel, cls=AASToJsonEncoder)) + + self.assertEqual(["q0", "q1", "q2"], [q["type"] for q in data["qualifiers"]]) + + def test_entity_specific_asset_ids_sorted_ok(self) -> None: + entity = model.Entity( + "entity", + model.EntityType.SELF_MANAGED_ENTITY, + specific_asset_id=[ + model.SpecificAssetId(name, "v") for name in ["n2", "n1", "n0"] + ], + ) + + data = json.loads(json.dumps(entity, cls=AASToJsonEncoder)) + + self.assertEqual( + ["n0", "n1", "n2"], [s["name"] for s in data["specificAssetIds"]] + ) + + def test_asset_administration_shell_submodels_sorted_ok(self) -> None: + aas = model.AssetAdministrationShell( + model.AssetInformation(global_asset_id="urn:asset"), + "urn:aas", + submodel={_submodel_reference(i) for i in ["urn:sm2", "urn:sm1", "urn:sm0"]}, + ) + + data = json.loads(json.dumps(aas, cls=AASToJsonEncoder)) + + self.assertEqual( + ["urn:sm0", "urn:sm1", "urn:sm2"], + [ref["keys"][0]["value"] for ref in data["submodels"]], + ) + + def test_submodel_elements_sorted_by_id_short_ok(self) -> None: + submodel = model.Submodel( + "urn:sm", + submodel_element=[ + model.Property(id_short, model.datatypes.String) + for id_short in ["p2", "p1", "p0"] + ], + ) + + data = json.loads(json.dumps(submodel, cls=AASToJsonEncoder)) + + self.assertEqual( + ["p0", "p1", "p2"], [e["idShort"] for e in data["submodelElements"]] + ) + + def test_submodel_element_collection_sorted_by_id_short_ok(self) -> None: + collection = model.SubmodelElementCollection( + "smc", + value=[ + model.Property(id_short, model.datatypes.String) + for id_short in ["p2", "p1", "p0"] + ], + ) + + data = json.loads(json.dumps(collection, cls=AASToJsonEncoder)) + + self.assertEqual(["p0", "p1", "p2"], [e["idShort"] for e in data["value"]]) + + def test_object_store_identifiables_sorted_by_id_ok(self) -> None: + store: model.DictIdentifiableStore[model.Identifiable] = model.DictIdentifiableStore( + [model.Submodel(identifier) for identifier in ["urn:sm2", "urn:sm1", "urn:sm0"]] + ) + + data = json.loads(object_store_to_json(store)) + + self.assertEqual( + ["urn:sm0", "urn:sm1", "urn:sm2"], [sm["id"] for sm in data["submodels"]] + ) diff --git a/sdk/test/adapter/xml/test_xml_serialization.py b/sdk/test/adapter/xml/test_xml_serialization.py index 0b5616743..1d6f706cc 100644 --- a/sdk/test/adapter/xml/test_xml_serialization.py +++ b/sdk/test/adapter/xml/test_xml_serialization.py @@ -60,6 +60,34 @@ def test_random_object_serialization(self) -> None: test_file = io.BytesIO() write_aas_xml_file(file=test_file, data=test_data) + def test_submodel_list_elements_idShort_ok(self) -> None: + """Verify, that adding SE to a SML does not mess with their idShorts in any serialization""" + prop1 = model.Property(id_short=None, value_type=model.datatypes.String, value="WithoutIdShort") + prop2 = model.Property(id_short="exampleIdShort", value_type=model.datatypes.String, value="WithIdShort") + sml = model.SubmodelElementList( + id_short="exampleList", + type_value_list_element=model.Property, + value_type_list_element=model.datatypes.String, + value=[prop1, prop2], + ) + + xml1 = xml_serialization.object_to_xml_element(prop1) + xml2 = xml_serialization.object_to_xml_element(prop2) + xml_sml = xml_serialization.object_to_xml_element(sml) + + self.assertIsNone(xml1.find(xml_serialization.NS_AAS + "idShort")) + self.assertEqual("exampleIdShort", xml2.findtext(xml_serialization.NS_AAS + "idShort")) + + sml_items = xml_sml.find(xml_serialization.NS_AAS + "value") + assert sml_items is not None + self.assertIsNone( + sml_items[0].find(xml_serialization.NS_AAS + "idShort") + ) + self.assertEqual( + "exampleIdShort", + sml_items[1].findtext(xml_serialization.NS_AAS + "idShort"), + ) + class XMLSerializationSchemaTest(unittest.TestCase): @classmethod diff --git a/server/README.md b/server/README.md index c09d565a1..a6c59cc56 100644 --- a/server/README.md +++ b/server/README.md @@ -115,25 +115,35 @@ The server can also be run directly on the host system without Docker, NGINX and The server can be accessed at http://localhost:8080/api/v3.1/ from your host system. +## Serialization Modifiers +All serialization modifiers of the specification are implemented for the Submodel Repository routes (`$reference` +also for the Asset Administration Shell Repository routes): + +- Content (`$metadata`, `$value`, `$reference`, `$path`) +- Level (`?level=deep`, `?level=core`) +- Extent (`?extent=WithBLOBValue`, `?extent=WithoutBLOBValue`) + +All combinations are supported with `Accept: application/json`. With `Accept: application/xml`, the server supports +the Normal serialization with `?extent=WithBLOBValue` and `?level=deep` (the default), as well as `$reference`. All +other combinations are rejected with `406 Not Acceptable`. Note that the specification only defines JSON for these +routes, so XML support is an extension of this server. + ## Currently Unimplemented Several features and routes are currently not supported: 1. Correlation ID: Not implemented because it was deemed unnecessary for this server. -2. Extent Parameter (`withBlobValue/withoutBlobValue`): - Not implemented due to the lack of support in JSON/XML serialization. - -3. Route `/shells/{aasIdentifier}/asset-information/thumbnail`: Not implemented because the specification lacks clarity. +2. Route `/shells/{aasIdentifier}/asset-information/thumbnail`: Not implemented because the specification lacks clarity. -4. Serialization and Description Routes: +3. Serialization and Description Routes: - `/serialization` - `/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. +4. PATCH Routes: + - All `PATCH` routes, including `/…/$metadata` and `/…/$value`, are currently not implemented. -6. Operation Invocation Routes: The following routes are not implemented because operation invocation +5. Operation Invocation Routes: The following routes are not implemented because operation invocation is not yet supported by the `basyx-python-sdk`: - `POST /submodels/{submodelIdentifier}/submodel-elements/{idShortPath}/invoke` - `POST /submodels/{submodelIdentifier}/submodel-elements/{idShortPath}/invoke/$value` diff --git a/server/app/adapter/__init__.py b/server/app/adapter/__init__.py index 10c99aa69..5d497b3e4 100644 --- a/server/app/adapter/__init__.py +++ b/server/app/adapter/__init__.py @@ -1 +1,2 @@ from .jsonization import * +from .modifiers import * diff --git a/server/app/adapter/modifiers.py b/server/app/adapter/modifiers.py new file mode 100644 index 000000000..e744ece3a --- /dev/null +++ b/server/app/adapter/modifiers.py @@ -0,0 +1,82 @@ +# 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): + """ + Serialization format selected by the content modifier. ``NORMAL`` is used, if the URL has no ``$`` suffix. + """ + + NORMAL = "normal" + METADATA = "metadata" + VALUE = "value" + REFERENCE = "reference" + PATH = "path" + + +@dataclass(frozen=True) +class Modifiers: + """ + The serialization modifiers of a request + + :ivar content: The requested serialization format + :ivar is_deep: ``True`` for ``level=deep`` (default), ``False`` for ``level=core`` + :ivar is_with_blob: ``True`` for ``extent=WithBLOBValue``, ``False`` for ``extent=WithoutBLOBValue`` (default) + """ + + content: Content + 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 + 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 _is_deep_request(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 _is_with_blob_request(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 927dfafc0..37e19ecae 100644 --- a/server/app/interfaces/base.py +++ b/server/app/interfaces/base.py @@ -16,7 +16,9 @@ import werkzeug.routing import werkzeug.utils from basyx.aas import model +from basyx.aas.adapter import _ordering from basyx.aas.adapter._generic import XML_NS_MAP +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 @@ -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 @@ -94,40 +102,95 @@ def __init__( self, obj: Optional[ResponseData] = None, paging_metadata: Optional[PagingMetadata] = None, - stripped: bool = False, + modifiers: Optional[Modifiers] = None, *args, **kwargs, ): super().__init__(*args, **kwargs) + if modifiers is not None: + self.assert_supports_modifiers(modifiers) if obj is None: self.status_code = 204 else: - self.data = self.serialize(obj, paging_metadata, stripped) + self.data = self.serialize(obj, paging_metadata, modifiers) @abc.abstractmethod - def serialize(self, obj: ResponseData, paging_metadata: Optional[PagingMetadata], stripped: bool) -> str: + def serialize( + self, + obj: ResponseData, + paging_metadata: Optional[PagingMetadata], + modifiers: Optional[Modifiers] + ) -> str: pass + @classmethod + @abc.abstractmethod + def assert_supports_modifiers(cls, mods: Modifiers) -> None: + """ + Ensures the response format supports the requested serialization modifiers. Can be used to fast-fail on + unsupported combinations of modifiers, even before object, retrieval. + """ + class JsonResponse(APIResponse): def __init__(self, *args, content_type="application/json", **kwargs): super().__init__(*args, **kwargs, content_type=content_type) - def serialize(self, obj: ResponseData, paging_metadata: Optional[PagingMetadata], stripped: bool) -> str: + @classmethod + def assert_supports_modifiers(cls, mods: Modifiers) -> None: + # JSON supports all serialization formats and modifiers + return + + def serialize( + self, + obj: ResponseData, + paging_metadata: Optional[PagingMetadata], + modifiers: Optional[Modifiers] + ) -> str: + if modifiers is not None: + encoder_cls, encoder_kwargs = pick_encoder(modifiers) + else: + encoder_cls = ResultToJsonEncoder + encoder_kwargs = {} 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=(",", ":"), **encoder_kwargs) class XmlResponse(APIResponse): def __init__(self, *args, content_type="application/xml", **kwargs): super().__init__(*args, **kwargs, content_type=content_type) - def serialize(self, obj: ResponseData, paging_metadata: Optional[PagingMetadata], stripped: bool) -> str: + @classmethod + def assert_supports_modifiers(cls, mods: Modifiers) -> None: + """ + XML only supports the Normal and Reference serialization, Normal requires ``extent=WithBLOBValue`` and + ``level=deep``. Supporting XML is a BaSyx Python SDK extension, as the specification only defines JSON. + """ + if mods.content is Content.REFERENCE: + return + if mods.content is not Content.NORMAL: + raise werkzeug.exceptions.NotAcceptable( + f"The requested content type is not supported by /${mods.content}. Use 'application/json' instead." + ) + if not mods.is_deep: + raise werkzeug.exceptions.NotAcceptable( + "The requested content type is not supported with ?level=core. Use 'application/json' instead." + ) + if not mods.is_with_blob: + raise werkzeug.exceptions.NotAcceptable( + "The requested content type is not supported with ?extent=WithoutBlobValue. " + "Use 'application/json' instead." + ) + + def serialize( + self, + obj: ResponseData, + paging_metadata: Optional[PagingMetadata], + modifiers: Optional[Modifiers] + ) -> str: root_elem = etree.Element("response", nsmap=XML_NS_MAP) if paging_metadata is not None and paging_metadata.cursor is not None: root_elem.set("cursor", str(paging_metadata.cursor)) @@ -135,11 +198,16 @@ def serialize(self, obj: ResponseData, paging_metadata: Optional[PagingMetadata] result_elem = self.result_to_xml(obj, **XML_NS_MAP) for child in result_elem: root_elem.append(child) - elif isinstance(obj, list): + elif isinstance(obj, list) or isinstance(obj, SubmodelElementsWrapper): + # There is no Reference encoder for XML, so Referables are converted to their ModelReference beforehand + if modifiers is not None and modifiers.content is Content.REFERENCE: + obj = [model.ModelReference.from_referable(item) for item in obj if isinstance(item, model.Referable)] for item in obj: item_elem = xml_serialization.object_to_xml_element(item) root_elem.append(item_elem) else: + if modifiers is not None and modifiers.content is Content.REFERENCE: + obj = model.ModelReference.from_referable(obj) if isinstance(obj, model.Referable) else None obj_elem = xml_serialization.object_to_xml_element(obj) for child in obj_elem: root_elem.append(child) @@ -184,7 +252,26 @@ def __init__(self, *args, content_type="text/xml", **kwargs): super().__init__(*args, **kwargs, content_type=content_type) -class ResultToJsonEncoder(ServerAASToJsonEncoder): +class SubmodelElementsWrapper(Iterable[model.SubmodelElement]): + """ + Marks an iterable of ``SubmodelElement``s as the result of a ``.../submodel-elements`` route, so that each result + encoder can apply the representation of its serialization format to the listing as a whole. + """ + + def __init__(self, elements: Iterable[model.SubmodelElement]): + self.elements = elements + + def __iter__(self): + return iter(self.elements) + + +class ResultJsonMixin(json.JSONEncoder): + """ + Serialization of the server's response objects. Combined with an encoder of the requested serialization format, + which handles everything else. It has to precede that encoder in the bases, as not all of them call + ``super().default()``. + """ + @classmethod def _result_to_json(cls, result: Result) -> Dict[str, object]: return {"success": result.success, "messages": result.messages} @@ -205,6 +292,9 @@ def _paging_metadata_to_json(cls, metadata: PagingMetadata) -> Dict[str, object] json_result["cursor"] = str(metadata.cursor) return json_result + def _submodel_elements_wrapper_to_json(self, obj: SubmodelElementsWrapper) -> object: + return list(obj) + def default(self, obj: object) -> object: if isinstance(obj, Result): return self._result_to_json(obj) @@ -214,11 +304,74 @@ def default(self, obj: object) -> object: return str(obj) if isinstance(obj, PagingMetadata): return self._paging_metadata_to_json(obj) + if isinstance(obj, SubmodelElementsWrapper): + return self._submodel_elements_wrapper_to_json(obj) return super().default(obj) -class StrippedResultToJsonEncoder(ResultToJsonEncoder): - stripped = True +class ResultToJsonEncoder(ResultJsonMixin, ServerAASToJsonEncoder): + pass + + +class NoBlobValueResultToJsonEncoder(ResultToJsonEncoder): + with_blob_value = False + + +class ResultMetadataJsonEncoder(ResultJsonMixin, MetadataJsonEncoder): + pass + + +class ResultReferenceJsonEncoder(ResultJsonMixin, ReferenceJsonEncoder): + pass + + +class ResultValueOnlyJsonEncoder(ResultJsonMixin, ValueOnlyJsonEncoder): + """ + ValueOnly serialization. A ``.../submodel-elements`` listing is serialized as one ValueOnly object with the + idShorts as names, like the ValueOnly representation of a Submodel (``GetSubmodelElementsValueResult``). + """ + + def _submodel_elements_wrapper_to_json(self, obj: SubmodelElementsWrapper) -> object: + return self._submodel_element_iterable_to_value_only(obj) + + +class NoBlobValueResultValueOnlyJsonEncoder(ResultValueOnlyJsonEncoder): + with_blob_value = False + + +class ResultPathJsonEncoder(ResultJsonMixin, PathJsonEncoder): + + def iter_paths(self, elements: Iterable[model.SubmodelElement]) -> Iterator[str]: + """ + Lazily yields the idShortPaths of the given elements and their children, one top-level element at a time, so + that pagination does not need to compute the paths behind the requested page. + """ + for element in sorted(elements, key=_ordering.referable_sort_key): + paths: List[str] = [] + self._append_children(paths, [element], prefix=[]) + yield from paths + + +def pick_encoder(mods: Modifiers) -> Tuple[Type[ResultJsonMixin], Dict[str, Any]]: + """Pick a JSON encoder class + encoder-constructor kwargs 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__``. + """ + encoder: Type[ResultJsonMixin] + match mods.content: + case Content.NORMAL: + encoder = ResultToJsonEncoder if mods.is_with_blob else NoBlobValueResultToJsonEncoder + return encoder, {"deep": mods.is_deep} + case Content.METADATA: + return ResultMetadataJsonEncoder, {} + case Content.VALUE: + encoder = ResultValueOnlyJsonEncoder if mods.is_with_blob else NoBlobValueResultValueOnlyJsonEncoder + return encoder, {"deep": mods.is_deep} + case Content.REFERENCE: + return ResultReferenceJsonEncoder, {} + case Content.PATH: + return ResultPathJsonEncoder, {"deep": mods.is_deep} class BaseWSGIApp: @@ -239,7 +392,9 @@ def _get_slice(cls, request: Request, iterator: Iterable[T]) -> Tuple[Iterator[T NonNegativeInteger(int(cursor_str) - 1), ) # cursor is 1-indexed except ValueError: - raise BadRequest("Limit can not be negative, cursor must be positive!") + raise BadRequest("Limit must be a positive integer, cursor must be positive!") + if limit < 1: + raise BadRequest("Limit must be a positive integer, cursor must be positive!") start_index = cursor end_index = cursor + limit items = list(itertools.islice(iterator, start_index, end_index + 1)) @@ -483,6 +638,11 @@ def _convert_single_json_item(cls, data: Any, expect_type: Type[T], stripped: bo def is_stripped_request(request: Request) -> bool: + """ + Determines whether a request body is to be parsed as stripped object (``level=core``). Only used for parsing + request bodies, responses are serialized according to :func:`~app.adapter.modifiers.parse_modifiers`. + """ + # TODO: replace by parse_modifiers, see #650 level = request.args.get("level") if level not in {"deep", "core", None}: raise BadRequest(f"Level {level} is not a valid level!") diff --git a/server/app/interfaces/registry.py b/server/app/interfaces/registry.py index 7d832fe63..4fc12324a 100644 --- a/server/app/interfaces/registry.py +++ b/server/app/interfaces/registry.py @@ -310,15 +310,13 @@ def get_all_submodel_descriptors( self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs ) -> Response: submodel_descriptors, paging_metadata = self._get_all_submodel_descriptors(request) - return response_t( - list(submodel_descriptors), paging_metadata=paging_metadata, stripped=is_stripped_request(request) - ) + return response_t(list(submodel_descriptors), paging_metadata=paging_metadata) def get_submodel_descriptor_by_id( self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs ) -> Response: submodel_descriptor = self._get_submodel_descriptor(url_args) - return response_t(submodel_descriptor, stripped=is_stripped_request(request)) + return response_t(submodel_descriptor) def post_submodel_descriptor( self, request: Request, url_args: Dict, response_t: Type[APIResponse], map_adapter: MapAdapter diff --git a/server/app/interfaces/repository.py b/server/app/interfaces/repository.py index fc7b4b0a0..28e504c26 100644 --- a/server/app/interfaces/repository.py +++ b/server/app/interfaces/repository.py @@ -9,6 +9,7 @@ """ import io +import itertools import json from typing import Callable, Dict, Iterable, Iterator, List, Optional, Tuple, Type, Union @@ -17,17 +18,30 @@ import werkzeug.utils from basyx.aas import model from basyx.aas.adapter import aasx +from basyx.aas.adapter._generic import serializable_id_short +from basyx.aas.adapter.json.formats.path_serialization import _PATH_SUPPORTED_TYPES +from basyx.aas.adapter.json.formats.value_only_serialization import TYPES_WITHOUT_VALUE_ONLY from werkzeug import Request, Response from werkzeug.datastructures import FileStorage from werkzeug.exceptions import BadRequest, Conflict, NotFound from werkzeug.routing import MapAdapter, Rule, Submount from app._config import API_BASE_PATH -from app.interfaces.base import PagingMetadata +from app.adapter import ( + Content, + Modifiers, + parse_modifiers, +) +from app.interfaces.base import PagingMetadata, SubmodelElementsWrapper 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, ObjectStoreWSGIApp, T, is_stripped_request +from .base import APIResponse, HTTPApiDecoder, ObjectStoreWSGIApp, ResultPathJsonEncoder, T, is_stripped_request SUPPORTED_PROFILES: ServiceDescription = ServiceDescription( [ @@ -38,6 +52,11 @@ ] ) +# The specification is ambiguous on whether Operation and Capability have a Metadata serialization. +# We treat them as having none: they are omitted from ``submodel-elements`` listings and rejected with 400 +# when requested directly. +TYPES_WITHOUT_METADATA = (model.Capability, model.Operation) + class WSGIApp(ObjectStoreWSGIApp): def __init__( @@ -121,23 +140,21 @@ def __init__( Submount( "/submodels", [ - 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("/$path", methods=["GET"], endpoint=self.not_implemented), + Rule("/", methods=["GET"], endpoint=self.get_submodel_all), 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.not_implemented), 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"], @@ -152,17 +169,10 @@ def __init__( "/submodel-elements", [ Rule( - "/$metadata", + "/", methods=["GET"], - endpoint=self.get_submodel_submodel_elements_metadata, + endpoint=self.get_submodel_submodel_elements ), - Rule( - "/$reference", - methods=["GET"], - endpoint=self.get_submodel_submodel_elements_reference, - ), - Rule("/$value", methods=["GET"], endpoint=self.not_implemented), - Rule("/$path", methods=["GET"], endpoint=self.not_implemented), Rule( "/", methods=["GET"], @@ -192,25 +202,18 @@ def __init__( "/", [ Rule( - "/$metadata", + "/", methods=["GET"], - endpoint=self.get_submodel_submodel_elements_id_short_path_metadata, + endpoint=self.get_submodel_submodel_elements_id_short_path ), Rule( "/$metadata", methods=["PATCH"], endpoint=self.not_implemented, ), - Rule( - "/$reference", - methods=["GET"], - endpoint=self.get_submodel_submodel_elements_id_short_path_reference, - ), - Rule("/$value", methods=["GET"], endpoint=self.not_implemented), Rule( "/$value", methods=["PATCH"], endpoint=self.not_implemented ), - Rule("/$path", methods=["GET"], endpoint=self.not_implemented), Rule( "/attachment", methods=["GET"], @@ -349,7 +352,11 @@ def __init__( ], ) ], - converters={"base64url": IdentifierToBase64URLConverter, "id_short_path": IdShortPathConverter}, + converters={ + "base64url": IdentifierToBase64URLConverter, + "content_modifier": ContentModifierConverter, + "id_short_path": IdShortPathConverter, + }, strict_slashes=False, ) @@ -486,6 +493,13 @@ def _get_shell(self, url_args: Dict) -> model.AssetAdministrationShell: return self._get_obj_ts(url_args["aas_id"], model.AssetAdministrationShell) def _get_submodels(self, request: Request) -> Tuple[Iterator[model.Submodel], Optional[PagingMetadata]]: + paginated_submodels, paging_metadata = self._get_slice(request, self._filter_submodels(request)) + return paginated_submodels, paging_metadata + + def _filter_submodels(self, request: Request) -> Iterator[model.Submodel]: + """ + Returns all Submodels matching the ``idShort`` and ``semanticId`` query parameters, without pagination. + """ submodels: Iterator[model.Submodel] = self._get_all_obj_of_type(model.Submodel) id_short = request.args.get("idShort") if id_short is not None: @@ -498,18 +512,25 @@ def _get_submodels(self, request: Request) -> Tuple[Iterator[model.Submodel], Op False, ) submodels = filter(lambda sm: sm.semantic_id == spec_semantic_id, submodels) - paginated_submodels, paging_metadata = self._get_slice(request, submodels) - return paginated_submodels, paging_metadata + return submodels def _get_submodel(self, url_args: Dict) -> model.Submodel: return self._get_obj_ts(url_args["submodel_id"], model.Submodel) def _get_submodel_submodel_elements( - self, request: Request, url_args: Dict + self, request: Request, url_args: Dict, mods: Modifiers ) -> Tuple[Iterator[model.SubmodelElement], Optional[PagingMetadata]]: submodel = self._get_submodel(url_args) - paginated_submodel_elements: Iterator[model.SubmodelElement] - paginated_submodel_elements, paging_metadata = self._get_slice(request, submodel.submodel_element) + + # Elements without a representation in the requested format are filtered before slicing, so that every page + # contains ``limit`` elements + elements: Iterable[model.SubmodelElement] = iter(submodel.submodel_element) + if mods.content is Content.VALUE: + elements = (item for item in elements if not isinstance(item, TYPES_WITHOUT_VALUE_ONLY)) + elif mods.content is Content.METADATA: + elements = (item for item in elements if not isinstance(item, TYPES_WITHOUT_METADATA)) + + paginated_submodel_elements, paging_metadata = self._get_slice(request, elements) return paginated_submodel_elements, paging_metadata def _get_submodel_submodel_elements_id_short_path(self, url_args: Dict) -> model.SubmodelElement: @@ -529,6 +550,9 @@ def get_description(self, request: Request, url_args: Dict, response_t: Type[API # ------ AAS REPO ROUTES ------- def get_aas_all(self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs) -> Response: + mods = parse_modifiers(request) + if not mods.is_deep: + raise BadRequest("Parameter 'level=core' is not allowed for AssetAdministrationShells.") aashells, paging_metadata = self._get_shells(request) return response_t(list(aashells), paging_metadata=paging_metadata) @@ -546,19 +570,24 @@ def post_aas( def get_aas_all_reference( self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs ) -> Response: + mods = parse_modifiers(request, Content.REFERENCE) + response_t.assert_supports_modifiers(mods) aashells, paging_metadata = self._get_shells(request) - references: list[model.ModelReference] = [model.ModelReference.from_referable(aas) for aas in aashells] - return response_t(references, paging_metadata=paging_metadata) + return response_t(list(aashells), paging_metadata=paging_metadata, modifiers=mods) # --------- AAS ROUTES --------- def get_aas(self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs) -> Response: + mods = parse_modifiers(request) + if not mods.is_deep: + raise BadRequest("Parameter 'level=core' is not allowed for AssetAdministrationShells.") aas = self._get_shell(url_args) return response_t(aas) def get_aas_reference(self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs) -> Response: + mods = parse_modifiers(request, Content.REFERENCE) + response_t.assert_supports_modifiers(mods) aas = self._get_shell(url_args) - reference = model.ModelReference.from_referable(aas) - return response_t(reference) + return response_t(aas, modifiers=mods) def put_aas(self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs) -> Response: aas = self._get_shell(url_args) @@ -576,6 +605,9 @@ def delete_aas(self, request: Request, url_args: Dict, response_t: Type[APIRespo def get_aas_asset_information( self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs ) -> Response: + mods = parse_modifiers(request) + if not mods.is_deep: + raise BadRequest("Parameter 'level=core' is not allowed for AssetInformation.") aas = self._get_shell(url_args) return response_t(aas.asset_information) @@ -590,6 +622,9 @@ def put_aas_asset_information( def get_aas_submodel_refs( self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs ) -> Response: + mods = parse_modifiers(request) + if not mods.is_deep: + raise BadRequest("Parameter 'level=core' is not allowed for Submodel References.") aas = self._get_shell(url_args) submodel_refs: Iterator[model.ModelReference[model.Submodel]] sorted_submodel_refs = sorted(aas.submodel, key=lambda ref: ref.key[0].value) @@ -666,8 +701,23 @@ def aas_submodel_refs_redirect( # ------ SUBMODEL REPO ROUTES ------- def get_submodel_all(self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs) -> Response: + mods = parse_modifiers(request, url_args.get("content")) + response_t.assert_supports_modifiers(mods) + + # In $path serialization Submodels are mapped 1:n to path items + # so pagination is needed on the idShortPath encoding, not the Submodels + if mods.content is Content.PATH: + return self.get_submodel_all_path(mods, request, response_t) submodels, paging_metadata = self._get_submodels(request) - return response_t(list(submodels), paging_metadata=paging_metadata, stripped=is_stripped_request(request)) + return response_t(list(submodels), paging_metadata=paging_metadata, modifiers=mods) + + def get_submodel_all_path(self, mods: Modifiers, request: Request, response_t: Type[APIResponse]) -> Response: + encoder = ResultPathJsonEncoder(deep=mods.is_deep) + paths = itertools.chain.from_iterable( + encoder.iter_paths(submodel.submodel_element) for submodel in self._filter_submodels(request) + ) + paginated_paths, paging_metadata = self._get_slice(request, paths) # slice on IdShortPaths + return response_t(list(paginated_paths), paging_metadata=paging_metadata, modifiers=mods) def post_submodel( self, request: Request, url_args: Dict, response_t: Type[APIResponse], map_adapter: MapAdapter @@ -680,23 +730,6 @@ def post_submodel( created_resource_url = map_adapter.build(self.get_submodel, {"submodel_id": submodel.id}, force_external=True) return response_t(submodel, status=201, headers={"Location": created_resource_url}) - def get_submodel_all_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!") - submodels, paging_metadata = self._get_submodels(request) - return response_t(list(submodels), paging_metadata=paging_metadata, stripped=True) - - def get_submodel_all_reference( - self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs - ) -> Response: - submodels, paging_metadata = self._get_submodels(request) - references: list[model.ModelReference] = [ - model.ModelReference.from_referable(submodel) for submodel in submodels - ] - return response_t(references, paging_metadata=paging_metadata, stripped=is_stripped_request(request)) - # --------- SUBMODEL ROUTES --------- def delete_submodel(self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs) -> Response: @@ -704,23 +737,10 @@ 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")) + response_t.assert_supports_modifiers(mods) 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_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) @@ -731,50 +751,49 @@ def put_submodel(self, request: Request, url_args: Dict, response_t: Type[APIRes def get_submodel_submodel_elements( self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs ) -> Response: - submodel_elements, paging_metadata = self._get_submodel_submodel_elements(request, url_args) - return response_t( - list(submodel_elements), paging_metadata=paging_metadata, stripped=is_stripped_request(request) - ) - - def get_submodel_submodel_elements_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_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_reference( - self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs + mods = parse_modifiers(request, url_args.get("content")) + response_t.assert_supports_modifiers(mods) + + # In $path serialization SubmodelElements are mapped 1:n to path items + # so pagination is needed on the idShortPath encoding, not the SubmodelElements + if mods.content is Content.PATH: + return self.get_submodel_submodel_elements_path(mods, request, response_t, url_args) + submodel_elements, paging_metadata = self._get_submodel_submodel_elements(request, url_args, mods) + return response_t(SubmodelElementsWrapper(submodel_elements), paging_metadata=paging_metadata, modifiers=mods) + + def get_submodel_submodel_elements_path( + self, mods: Modifiers, request: Request, response_t: type[APIResponse], url_args: dict ) -> Response: - submodel_elements, paging_metadata = self._get_submodel_submodel_elements(request, url_args) - references: list[model.ModelReference] = [ - model.ModelReference.from_referable(element) for element in list(submodel_elements) - ] - return response_t(references, paging_metadata=paging_metadata, stripped=is_stripped_request(request)) + submodel_elements = self._get_submodel(url_args).submodel_element + paths = ResultPathJsonEncoder(deep=mods.is_deep).iter_paths(submodel_elements) + paginated_paths, paging_metadata = self._get_slice(request, paths) # slice on IdShortPaths + return response_t(list(paginated_paths), paging_metadata=paging_metadata, modifiers=mods) def get_submodel_submodel_elements_id_short_path( self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs ) -> Response: + mods = parse_modifiers(request, url_args.get("content")) + response_t.assert_supports_modifiers(mods) submodel_element = self._get_submodel_submodel_elements_id_short_path(url_args) - return response_t(submodel_element, stripped=is_stripped_request(request)) - def get_submodel_submodel_elements_id_short_path_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_element = self._get_submodel_submodel_elements_id_short_path(url_args) - if isinstance(submodel_element, model.Capability) or isinstance(submodel_element, model.Operation): - raise BadRequest(f"{submodel_element.id_short} does not allow the content modifier metadata!") - return response_t(submodel_element, stripped=True) + if mods.content is Content.VALUE: + if isinstance(submodel_element, TYPES_WITHOUT_VALUE_ONLY): + raise BadRequest(f"SubmodelElement {url_args['id_shorts']} has no ValueOnly representation!") + elif mods.content is Content.PATH: + # Leaf elements have no Path representation. + if not isinstance(submodel_element, _PATH_SUPPORTED_TYPES): + raise BadRequest(f"SubmodelElement {url_args['id_shorts']} has no Path representation!") + # The idShortPaths of $path start with the idShort of the requested element, so elements without an + # idShort (children of a SubmodelElementList) have no Path representation. + if serializable_id_short(submodel_element) is None: + raise BadRequest( + f"SubmodelElement {url_args['id_shorts']} has no idShort, so it has no Path representation!" + ) + elif mods.content is Content.METADATA: + if isinstance(submodel_element, TYPES_WITHOUT_METADATA): + raise BadRequest(f"SubmodelElement {url_args['id_shorts']} has no Metadata representation!") - def get_submodel_submodel_elements_id_short_path_reference( - self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs - ) -> Response: - submodel_element = self._get_submodel_submodel_elements_id_short_path(url_args) - reference = model.ModelReference.from_referable(submodel_element) - return response_t(reference, stripped=is_stripped_request(request)) + return response_t(submodel_element, modifiers=mods) def post_submodel_submodel_elements_id_short_path( self, request: Request, url_args: Dict, response_t: Type[APIResponse], map_adapter: MapAdapter @@ -799,10 +818,9 @@ def post_submodel_submodel_elements_id_short_path( ) self.object_store.commit(self._get_submodel(url_args)) submodel = self._get_submodel(url_args) - id_short_path = url_args.get("id_shorts", []) created_resource_url = map_adapter.build( self.get_submodel_submodel_elements_id_short_path, - {"submodel_id": submodel.id, "id_shorts": id_short_path + [new_submodel_element.id_short]}, + {"submodel_id": submodel.id, "id_shorts": new_submodel_element.get_id_short_path_as_a_list()}, force_external=True, ) return response_t(new_submodel_element, status=201, headers={"Location": created_resource_url}) @@ -975,11 +993,12 @@ def delete_submodel_submodel_element_qualifiers( def get_concept_description_all( self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs ) -> Response: + mods = parse_modifiers(request) + if not mods.is_deep: + raise BadRequest("Parameter 'level=core' is not allowed for ConceptDescriptions.") concept_descriptions: Iterator[model.ConceptDescription] = self._get_all_obj_of_type(model.ConceptDescription) concept_descriptions, paging_metadata = self._get_slice(request, concept_descriptions) - return response_t( - list(concept_descriptions), paging_metadata=paging_metadata, stripped=is_stripped_request(request) - ) + return response_t(list(concept_descriptions), paging_metadata=paging_metadata) def post_concept_description( self, request: Request, url_args: Dict, response_t: Type[APIResponse], map_adapter: MapAdapter @@ -999,8 +1018,11 @@ def post_concept_description( def get_concept_description( self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs ) -> Response: + mods = parse_modifiers(request) + if not mods.is_deep: + raise BadRequest("Parameter 'level=core' is not allowed for ConceptDescriptions.") concept_description = self._get_concept_description(url_args) - return response_t(concept_description, stripped=is_stripped_request(request)) + return response_t(concept_description) def put_concept_description( self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs 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. diff --git a/server/test/interfaces/format_utils.py b/server/test/interfaces/format_utils.py index 078aede90..e9eb4537f 100644 --- a/server/test/interfaces/format_utils.py +++ b/server/test/interfaces/format_utils.py @@ -125,6 +125,10 @@ def identifier(self, node: Any) -> str: def reference_target(self, node: Any) -> str: """The value of the last key of a Reference node (a single-reference response or a collection item).""" + @abc.abstractmethod + def reference_keys(self, node: Any) -> list[tuple[str, str]]: + """The ``(type, value)`` pairs of all keys of a Reference node, in document order.""" + @abc.abstractmethod def field(self, node: Any, name: str) -> Optional[str]: """The text of a direct scalar child ``name`` of ``node`` (JSON member / ``aas:``-prefixed XML element).""" @@ -162,6 +166,9 @@ def identifier(self, node: Any) -> str: def reference_target(self, node: Any) -> str: return node["keys"][-1]["value"] + def reference_keys(self, node: Any) -> list[tuple[str, str]]: + return [(key["type"], key["value"]) for key in node["keys"]] + def field(self, node: Any, name: str) -> Optional[str]: return node.get(name) @@ -206,6 +213,12 @@ def reference_target(self, node: Any) -> str: assert values, "no keys in reference node" return values[-1].text + def reference_keys(self, node: Any) -> list[tuple[str, str]]: + return [ + (key.findtext("aas:type", namespaces=XML_NS_MAP), key.findtext("aas:value", namespaces=XML_NS_MAP)) + for key in node.findall("aas:keys/aas:key", namespaces=XML_NS_MAP) + ] + def field(self, node: Any, name: str) -> Optional[str]: return node.findtext(f"aas:{name}", namespaces=XML_NS_MAP) diff --git a/server/test/interfaces/repository/helpers.py b/server/test/interfaces/repository/helpers.py index 4644b5046..2d8613667 100644 --- a/server/test/interfaces/repository/helpers.py +++ b/server/test/interfaces/repository/helpers.py @@ -5,7 +5,7 @@ # # SPDX-License-Identifier: MIT -from typing import TypeVar +from typing import Any, Callable, TypeVar from unittest import mock from app.interfaces import repository @@ -17,7 +17,7 @@ from basyx.aas.model import Identifiable from werkzeug.test import Client -from ..format_utils import AssertResponseMixin +from ..format_utils import AssertResponseMixin, FormatClient T = TypeVar('T') @@ -40,6 +40,39 @@ def setUp(self) -> None: self.object_store.clear() self.file_store.reset_mock() + def assert_extent_toggles_blob_value( + self, format_client: FormatClient, path: str, locate_blob: Callable[[Any], Any], + ) -> None: + """ + Ensures the BLOB value is only serialized with ``extent=WithBLOBValue``. ``WithoutBLOBValue`` is the default. + + :param locate_blob: Returns the Blob node from the parsed response body + """ + with self.subTest(extent="WithBLOBValue"): + response = format_client.get(f"{path}?extent=WithBLOBValue") + self.assert_ok(response) + blob = locate_blob(format_client.parse_object(response)) + self.assertIn("value", blob) + + for omit_query in ("?extent=WithoutBLOBValue", ""): + with self.subTest(extent=omit_query or "default"): + response = format_client.get(f"{path}{omit_query}") + self.assert_ok(response) + blob = locate_blob(format_client.parse_object(response)) + self.assertNotIn("value", blob) + + def assert_xml_requires_blob_value(self, format_client: FormatClient, path: str) -> None: + """ + Ensures Normal XML responses are only served with ``extent=WithBLOBValue`` and ``level=deep``, as the XML + serialization can neither omit BLOB values nor children. Every other combination must be rejected with + ``406 Not Acceptable``. + """ + for query in ("", "?extent=WithoutBLOBValue", "?extent=WithBLOBValue&level=core"): + with self.subTest(query=query or "default"): + self.assert_error(format_client.get(f"{path}{query}"), 406) + with self.subTest(query="?extent=WithBLOBValue"): + self.assert_ok(format_client.get(f"{path}?extent=WithBLOBValue")) + @classmethod def two_shells_store(cls): store = model.DictIdentifiableStore() diff --git a/server/test/interfaces/repository/submodels/__init__.py b/server/test/interfaces/repository/submodels/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/server/test/interfaces/repository/submodels/test_metadata.py b/server/test/interfaces/repository/submodels/test_metadata.py new file mode 100644 index 000000000..5d13742e6 --- /dev/null +++ b/server/test/interfaces/repository/submodels/test_metadata.py @@ -0,0 +1,327 @@ +# 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 +""" +Endpoint tests for the ``/submodels`` ``$metadata`` content modifier. + +Covers: + * ``GET /submodels/$metadata`` + * ``GET /submodels//$metadata`` + * ``GET /submodels//submodel-elements/$metadata`` + * ``GET /submodels//submodel-elements//$metadata`` + +``$metadata`` is only supported as JSON. XML requests must be rejected with ``406 Not Acceptable``. + +The specification is ambiguous on whether Operation and Capability have a Metadata serialization. We treat them as +having none: they are omitted from ``submodel-elements`` listings and requesting them directly yields ``400``. +""" + +from app.util.converters import base64url_encode +from basyx.aas import model +from basyx.aas.examples.data.example_aas_missing_attributes import create_example_submodel + +from ...format_utils import ( + FormatClient, + inject_format_clients, + with_json_client, + with_xml_client, +) +from ..helpers import RepositoryEndpointTestBase + +# idShorts of top-level submodel elements of ``create_example_submodel()`` without a Metadata +# representation: they must not appear in a ``$metadata`` response. +METADATA_LESS_ID_SHORTS = ("ExampleOperation", "ExampleCapability") + +NESTED_PROPERTY = "ExampleSubmodelCollection.ExampleProperty" +UNKNOWN_SUBMODEL_ID = "https://example.org/unknown" + + +class MetadataEndpointTestBase(RepositoryEndpointTestBase): + """ + Shared assertions on the query parameters of the ``$metadata`` routes. + """ + + def assert_rejects_level(self, format_client: FormatClient, path: str) -> None: + """ + ``level`` is not applicable to ``$metadata``, thus every value must be rejected. + """ + for level in ("deep", "core"): + with self.subTest(level=level): + self.assert_error(format_client.get(f"{path}?level={level}"), 400) + + def assert_extent_handling(self, format_client: FormatClient, path: str) -> None: + """ + ``$metadata`` never contains BLOB values, thus only ``extent=WithoutBLOBValue`` is accepted. + """ + with self.subTest(extent="WithBLOBValue"): + self.assert_error(format_client.get(f"{path}?extent=WithBLOBValue"), 400) + with self.subTest(extent="WithoutBLOBValue"): + self.assert_ok(format_client.get(f"{path}?extent=WithoutBLOBValue")) + + +@inject_format_clients +class SubmodelsMetadataEndpointsTest(MetadataEndpointTestBase): + """ + Endpoint tests for the ``$metadata`` modifier on the Submodel resource (``/submodels[/]``). + """ + + SECOND_ID = "https://example.org/Test_Submodel_Second" + + def two_submodels_store(self) -> model.DictIdentifiableStore: + store: model.DictIdentifiableStore = model.DictIdentifiableStore() + store.add(create_example_submodel()) + second = create_example_submodel() + second.id = self.SECOND_ID + second.id_short = "SecondSubmodel" + store.add(second) + return store + + # ------------------------------------------------------------------ GET /submodels/$metadata + + @with_json_client + def test_submodels_metadata_get(self, format_client: FormatClient): + self.object_store.update(self.two_submodels_store()) + + response = format_client.get("/submodels/$metadata") + + self.assert_ok(response) + submodels = format_client.parse_collection(response) + self.assertEqual(2, len(submodels)) + for submodel in submodels: + self.assertNotIn("submodelElements", submodel) + + @with_json_client + def test_submodels_metadata_get_supports_pagination(self, format_client: FormatClient): + self.object_store.update(self.two_submodels_store()) + + pages = format_client.get_paginated("/submodels/$metadata", limit=1, max_pages=2) + + self.assertEqual([1, 1], [len(page) for page in pages]) + for submodel in (submodel for page in pages for submodel in page): + self.assertNotIn("submodelElements", submodel) + + @with_json_client + def test_submodels_metadata_get_rejects_level(self, format_client: FormatClient): + self.object_store.add(create_example_submodel()) + + self.assert_rejects_level(format_client, "/submodels/$metadata") + + @with_json_client + def test_submodels_metadata_get_extent(self, format_client: FormatClient): + self.object_store.add(create_example_submodel()) + + self.assert_extent_handling(format_client, "/submodels/$metadata") + + @with_xml_client + def test_submodels_metadata_get_xml_not_acceptable(self, format_client: FormatClient): + self.object_store.update(self.two_submodels_store()) + + response = format_client.get("/submodels/$metadata") + + self.assert_error(response, 406) + + # ------------------------------------------------------------------ GET /submodels//$metadata + + @with_json_client + def test_submodel_metadata_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)}/$metadata") + + self.assert_ok(response) + submodel = format_client.parse_object(response) + self.assertEqual(example_submodel.id, format_client.identifier(submodel)) + self.assertNotIn("submodelElements", submodel) + + @with_json_client + def test_submodel_metadata_get_rejects_level(self, format_client: FormatClient): + example_submodel = create_example_submodel() + self.object_store.add(example_submodel) + + self.assert_rejects_level(format_client, f"/submodels/{base64url_encode(example_submodel.id)}/$metadata") + + @with_json_client + def test_submodel_metadata_get_extent(self, format_client: FormatClient): + example_submodel = create_example_submodel() + self.object_store.add(example_submodel) + + self.assert_extent_handling(format_client, f"/submodels/{base64url_encode(example_submodel.id)}/$metadata") + + @with_json_client + def test_submodel_metadata_get_not_found(self, format_client: FormatClient): + response = format_client.get( + f"/submodels/{base64url_encode(UNKNOWN_SUBMODEL_ID)}/$metadata" + ) + + self.assert_error(response, 404) + + @with_xml_client + def test_submodel_metadata_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)}/$metadata") + + self.assert_error(response, 406) + + +@inject_format_clients +class SubmodelElementsMetadataEndpointsTest(MetadataEndpointTestBase): + """ + Endpoint tests for the ``$metadata`` modifier on the ``submodel-elements`` subtree. + """ + + def add_example_submodel(self) -> model.Submodel: + submodel = create_example_submodel() + self.object_store.add(submodel) + return submodel + + @staticmethod + def elements_path(submodel_id: str, id_short_path: str = "") -> str: + base = f"/submodels/{base64url_encode(submodel_id)}/submodel-elements" + return f"{base}/{id_short_path}" if id_short_path else base + + @staticmethod + def metadata_element_count(submodel: model.Submodel) -> int: + """ + Number of top-level submodel elements that have a Metadata representation. + """ + return sum(1 for e in submodel.submodel_element if e.id_short not in METADATA_LESS_ID_SHORTS) + + def assert_metadata_less_omitted(self, format_client: FormatClient, elements: list) -> None: + """ + Ensures elements without a Metadata representation (Operation, Capability) are omitted. + """ + emitted = {format_client.field(e, "idShort") for e in elements} + for id_short in METADATA_LESS_ID_SHORTS: + self.assertNotIn(id_short, emitted) + + def assert_metadata_collection(self, elements: list) -> None: + """ + Ensures the elements are serialized as Metadata, checked by the omitted ``value`` of the collection. + """ + collections = [e for e in elements if e["modelType"] == "SubmodelElementCollection"] + self.assertTrue(collections, "Expected a SubmodelElementCollection in the response") + for collection in collections: + self.assertNotIn("value", collection) + + # ------------------------------------------------------------------ GET .../submodel-elements/$metadata + + @with_json_client + def test_submodel_elements_metadata_get(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + response = format_client.get(f"{self.elements_path(submodel.id)}/$metadata") + + self.assert_ok(response) + elements = format_client.parse_collection(response) + self.assertEqual(self.metadata_element_count(submodel), len(elements)) + self.assert_metadata_less_omitted(format_client, elements) + self.assert_metadata_collection(elements) + + @with_json_client + def test_submodel_elements_metadata_get_supports_pagination(self, format_client: FormatClient): + submodel = self.add_example_submodel() + limit = 3 + total = self.metadata_element_count(submodel) + expected_page_sizes = [min(limit, total - start) for start in range(0, total, limit)] + + pages = format_client.get_paginated( + f"{self.elements_path(submodel.id)}/$metadata", limit=limit, max_pages=len(expected_page_sizes) + ) + + self.assertEqual(expected_page_sizes, [len(page) for page in pages]) + elements = [element for page in pages for element in page] + self.assert_metadata_less_omitted(format_client, elements) + self.assert_metadata_collection(elements) + + @with_json_client + def test_submodel_elements_metadata_get_rejects_level(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + self.assert_rejects_level(format_client, f"{self.elements_path(submodel.id)}/$metadata") + + @with_json_client + def test_submodel_elements_metadata_get_extent(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + self.assert_extent_handling(format_client, f"{self.elements_path(submodel.id)}/$metadata") + + @with_json_client + def test_submodel_elements_metadata_get_submodel_not_found(self, format_client: FormatClient): + response = format_client.get(f"{self.elements_path(UNKNOWN_SUBMODEL_ID)}/$metadata") + + self.assert_error(response, 404) + + @with_xml_client + def test_submodel_elements_metadata_get_xml_not_acceptable(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + response = format_client.get(f"{self.elements_path(submodel.id)}/$metadata") + + self.assert_error(response, 406) + + # ------------------------------------------------------------------ GET ...//$metadata + + @with_json_client + def test_submodel_element_metadata_get(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + response = format_client.get(f"{self.elements_path(submodel.id, NESTED_PROPERTY)}/$metadata") + + self.assert_ok(response) + element = format_client.parse_object(response) + self.assertEqual("ExampleProperty", format_client.field(element, "idShort")) + # The Metadata serialization omits the value of a Property + self.assertNotIn("value", element) + + @with_json_client + def test_submodel_element_metadata_get_rejects_unsupported_types(self, format_client: FormatClient): + """ + Operation and Capability are treated as having no Metadata serialization, so the server + needs to reject both with ``400``. + """ + submodel = self.add_example_submodel() + + for id_short in METADATA_LESS_ID_SHORTS: + with self.subTest(id_short=id_short): + response = format_client.get(f"{self.elements_path(submodel.id, id_short)}/$metadata") + self.assert_error(response, 400) + + @with_json_client + def test_submodel_element_metadata_get_rejects_level(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + self.assert_rejects_level(format_client, f"{self.elements_path(submodel.id, NESTED_PROPERTY)}/$metadata") + + @with_json_client + def test_submodel_element_metadata_get_extent(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + self.assert_extent_handling(format_client, f"{self.elements_path(submodel.id, NESTED_PROPERTY)}/$metadata") + + @with_json_client + def test_submodel_element_metadata_get_not_found(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + response = format_client.get(f"{self.elements_path(submodel.id, 'Unknown')}/$metadata") + + self.assert_error(response, 404) + + @with_json_client + def test_submodel_element_metadata_get_submodel_not_found(self, format_client: FormatClient): + response = format_client.get(f"{self.elements_path(UNKNOWN_SUBMODEL_ID, NESTED_PROPERTY)}/$metadata") + + self.assert_error(response, 404) + + @with_xml_client + def test_submodel_element_metadata_get_xml_not_acceptable(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + response = format_client.get(f"{self.elements_path(submodel.id, NESTED_PROPERTY)}/$metadata") + + self.assert_error(response, 406) diff --git a/server/test/interfaces/repository/submodels/test_path.py b/server/test/interfaces/repository/submodels/test_path.py new file mode 100644 index 000000000..e6a39170d --- /dev/null +++ b/server/test/interfaces/repository/submodels/test_path.py @@ -0,0 +1,406 @@ +# 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 +""" +Endpoint tests for the ``/submodels`` ``$path`` content modifier. + +Covers: + * ``GET /submodels/$path`` + * ``GET /submodels//$path`` + * ``GET /submodels//submodel-elements/$path`` + * ``GET /submodels//submodel-elements//$path`` + +The structural correctness of the idShortPath output is covered by the SDK's :class:`PathJsonEncoder` +unit tests and is not re-asserted here. +""" + +from app.util.converters import base64url_encode +from basyx.aas import model +from basyx.aas.examples.data import example_aas +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 ( + FormatClient, + inject_format_clients, + with_json_client, + with_xml_client, +) +from ..helpers import RepositoryEndpointTestBase + +NESTED_COLLECTION = "ExampleSubmodelCollection" +NESTED_LIST = "ExampleSubmodelCollection.ExampleSubmodelList" +NESTED_PROPERTY = "ExampleSubmodelCollection.ExampleProperty" +NESTED_RANGE = "ExampleSubmodelCollection.ExampleRange" +NESTED_ANNOTATED_REL = "ExampleAnnotatedRelationshipElement" +LIST_ID_SHORT = "List" + + +@inject_format_clients +class SubmodelsPathEndpointsTest(RepositoryEndpointTestBase): + """ + Endpoint tests for the ``$path`` modifier on the Submodel resource (``/submodels[/]``). + + ``$path`` serialization is JSON-only; XML variants are added only where the server must respond + with ``406 Not Acceptable``. + """ + + # 14 idShortPaths of the example submodel + 4 of the bill of material submodel + TWO_SUBMODELS_PATH_COUNT = 18 + + def two_submodels_store(self) -> model.DictIdentifiableStore: + store: model.DictIdentifiableStore = model.DictIdentifiableStore() + store.add(create_example_submodel()) + store.add(create_example_bill_of_material_submodel()) + return store + + # ------------------------------------------------------------------ GET /submodels/$path + + @with_json_client + def test_submodels_path_get(self, format_client: FormatClient): + """``result`` is a flat list of idShortPath strings of all Submodels (``GetPathItemsResult``).""" + self.object_store.update(self.two_submodels_store()) + + response = format_client.get("/submodels/$path") + + self.assert_ok(response) + paths = format_client.parse_collection(response) + self.assertEqual(self.TWO_SUBMODELS_PATH_COUNT, len(paths)) + for path in paths: + self.assertIsInstance(path, str) + self.assertIn("ExampleSubmodelCollection.ExampleBlob", paths) + self.assertIn("ExampleEntity.ExampleProperty", paths) + + @with_json_client + def test_submodels_path_get_supports_pagination(self, format_client: FormatClient): + """``limit`` counts idShortPath strings, not Submodels.""" + self.object_store.update(self.two_submodels_store()) + + pages = format_client.get_paginated("/submodels/$path", limit=5, max_pages=4) + + self.assertEqual([5, 5, 5, 3], [len(page) for page in pages]) + for page in pages: + for path in page: + self.assertIsInstance(path, str) + + @with_json_client + def test_submodels_path_get_rejects_invalid_level(self, format_client: FormatClient): + self.object_store.add(create_example_submodel()) + + response = format_client.get("/submodels/$path?level=bogus") + + self.assert_error(response, 400) + + @with_json_client + def test_submodels_path_get_rejects_extent(self, format_client: FormatClient): + self.object_store.add(create_example_submodel()) + + response = format_client.get("/submodels/$path?extent=withBlobValue") + + self.assert_error(response, 400) + + @with_xml_client + def test_submodels_path_get_xml_not_acceptable(self, format_client: FormatClient): + self.object_store.add(create_example_submodel()) + + response = format_client.get("/submodels/$path") + + self.assert_error(response, 406) + + # ------------------------------------------------------------------ GET /submodels//$path + + @with_json_client + def test_submodel_path_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)}/$path") + + self.assert_ok(response) + paths = format_client.parse_object(response) + self.assertIsInstance(paths, list) + self.assertGreater(len(paths), 0) + + @with_json_client + def test_submodel_path_get_level_core_is_shallower_than_deep(self, format_client: FormatClient): + """The ``level`` modifier is propagated to the encoder: core returns strictly fewer paths than deep.""" + example_submodel = create_example_submodel() + self.object_store.add(example_submodel) + base = f"/submodels/{base64url_encode(example_submodel.id)}/$path" + + deep = format_client.parse_object(format_client.get(base)) + core = format_client.parse_object(format_client.get(f"{base}?level=core")) + + self.assertLess(len(core), len(deep)) + + @with_json_client + def test_submodel_path_get_rejects_invalid_level(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)}/$path?level=bogus" + ) + + self.assert_error(response, 400) + + @with_json_client + def test_submodel_path_get_rejects_extent(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)}/$path?extent=withBlobValue" + ) + + self.assert_error(response, 400) + + @with_json_client + def test_submodel_path_get_not_found(self, format_client: FormatClient): + response = format_client.get( + f"/submodels/{base64url_encode('https://example.org/unknown')}/$path" + ) + + self.assert_error(response, 404) + + @with_xml_client + def test_submodel_path_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)}/$path") + + self.assert_error(response, 406) + + +@inject_format_clients +class SubmodelElementsPathEndpointsTest(RepositoryEndpointTestBase): + """ + Endpoint tests for the ``$path`` modifier on the ``submodel-elements`` subtree. + """ + + # 6 top-level elements + 2 annotations + 6 collection children of the example submodel + EXAMPLE_SUBMODEL_PATH_COUNT = 14 + + def add_example_submodel(self) -> model.Submodel: + submodel = create_example_submodel() + self.object_store.add(submodel) + return submodel + + def add_bill_of_material_submodel(self) -> model.Submodel: + submodel = create_example_bill_of_material_submodel() + self.object_store.add(submodel) + return submodel + + @staticmethod + def elements_path(submodel_id: str, id_short_path: str = "") -> str: + base = f"/submodels/{base64url_encode(submodel_id)}/submodel-elements" + return f"{base}/{id_short_path}" if id_short_path else base + + # ------------------------------------------------------------------ GET .../submodel-elements/$path + + @with_json_client + def test_submodel_elements_path_get(self, format_client: FormatClient): + """``result`` is a flat list of idShortPath strings (``GetPathItemsResult``).""" + submodel = self.add_example_submodel() + + response = format_client.get(f"{self.elements_path(submodel.id)}/$path") + + self.assert_ok(response) + paths = format_client.parse_collection(response) + self.assertEqual(self.EXAMPLE_SUBMODEL_PATH_COUNT, len(paths)) + for path in paths: + self.assertIsInstance(path, str) + self.assertIn(NESTED_COLLECTION, paths) + self.assertIn(NESTED_PROPERTY, paths) + + @with_json_client + def test_submodel_elements_path_get_supports_pagination(self, format_client: FormatClient): + """``limit`` counts idShortPath strings, not top-level SubmodelElements.""" + submodel = self.add_example_submodel() + + pages = format_client.get_paginated(f"{self.elements_path(submodel.id)}/$path", limit=3, max_pages=5) + + self.assertEqual([3, 3, 3, 3, 2], [len(page) for page in pages]) + + @with_json_client + def test_submodel_elements_path_get_rejects_limit_below_one(self, format_client: FormatClient): + """ + ``$path`` slices the idShortPaths instead of the SubmodelElements, so ``limit`` is checked separately here. + """ + submodel = self.add_example_submodel() + + for limit in (0, -1): + with self.subTest(limit=limit): + response = format_client.get(f"{self.elements_path(submodel.id)}/$path?limit={limit}") + self.assert_error(response, 400) + + @with_json_client + def test_submodel_elements_path_get_rejects_invalid_level(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + response = format_client.get(f"{self.elements_path(submodel.id)}/$path?level=bogus") + + self.assert_error(response, 400) + + @with_json_client + def test_submodel_elements_path_get_rejects_extent(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + response = format_client.get(f"{self.elements_path(submodel.id)}/$path?extent=withBlobValue") + + self.assert_error(response, 400) + + @with_xml_client + def test_submodel_elements_path_get_xml_not_acceptable(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + response = format_client.get(f"{self.elements_path(submodel.id)}/$path") + + self.assert_error(response, 406) + + # ------------------------------------------------------------------ GET ...//$path + + @with_json_client + def test_submodel_element_path_get_collection(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + response = format_client.get(f"{self.elements_path(submodel.id, NESTED_COLLECTION)}/$path") + + self.assert_ok(response) + paths = format_client.parse_object(response) + self.assertIsInstance(paths, list) + self.assertIn(NESTED_COLLECTION, paths) + + @with_json_client + def test_submodel_element_path_get_list_uses_index_notation(self, format_client: FormatClient): + """ + SML children are addressed by ``[]``. Every idShortPath starts with the idShort of the requested + element, not with the top-level element of the Submodel (IDTA-01001, Format "Path"). + """ + # Only the full example submodel contains a SubmodelElementList + submodel = example_aas.create_example_submodel() + self.object_store.add(submodel) + + response = format_client.get(f"{self.elements_path(submodel.id, NESTED_LIST)}/$path") + + self.assert_ok(response) + paths = format_client.parse_object(response) + self.assertEqual(["ExampleSubmodelList", "ExampleSubmodelList[0]", "ExampleSubmodelList[1]"], paths) + + def add_list_submodel(self) -> model.Submodel: + """ + Adds a Submodel with a SubmodelElementList of two SubmodelElementCollections: ``List[0]`` has no idShort, + ``List[1]`` has the idShort ``Named``. + """ + submodel = model.Submodel("https://example.org/Test_Submodel_List", submodel_element=[ + model.SubmodelElementList( + LIST_ID_SHORT, + type_value_list_element=model.SubmodelElementCollection, + value=[ + model.SubmodelElementCollection( + None, value=[model.Property("Unnamed", model.datatypes.String, "x")] + ), + model.SubmodelElementCollection( + "Named", value=[model.Property("Child", model.datatypes.String, "y")] + ), + ], + ) + ]) + self.object_store.add(submodel) + return submodel + + @with_json_client + def test_submodel_element_path_get_list_item_without_id_short_returns_400(self, format_client: FormatClient): + """ + Every idShortPath starts with the idShort of the requested element, so an element without an idShort has no + Path representation. + """ + submodel = self.add_list_submodel() + + response = format_client.get(f"{self.elements_path(submodel.id, f'{LIST_ID_SHORT}[0]')}/$path") + + self.assert_error(response, 400) + + @with_json_client + def test_submodel_element_path_get_list_item_with_id_short(self, format_client: FormatClient): + """ + A child of a SubmodelElementList that has an idShort has a Path representation. + """ + submodel = self.add_list_submodel() + + response = format_client.get(f"{self.elements_path(submodel.id, f'{LIST_ID_SHORT}[1]')}/$path") + + self.assert_ok(response) + self.assertEqual(["Named", "Named.Child"], format_client.parse_object(response)) + + @with_json_client + def test_submodel_element_path_get_entity(self, format_client: FormatClient): + submodel = self.add_bill_of_material_submodel() + + response = format_client.get(f"{self.elements_path(submodel.id, 'ExampleEntity')}/$path") + + self.assert_ok(response) + paths = format_client.parse_object(response) + self.assertIsInstance(paths, list) + self.assertIn("ExampleEntity", paths) + + @with_json_client + def test_submodel_element_path_get_annotated_relationship_element(self, format_client: FormatClient): + """ + The specification is ambiguous here: the content modifier table lists AnnotatedRelationshipElement for + ``$path``, but the applicability table doesn't. We assume the applicability table missed the update, so the + server returns the paths of the element and its annotations. + """ + submodel = self.add_example_submodel() + + response = format_client.get(f"{self.elements_path(submodel.id, NESTED_ANNOTATED_REL)}/$path") + + self.assert_ok(response) + paths = format_client.parse_object(response) + self.assertIsInstance(paths, list) + self.assertIn(NESTED_ANNOTATED_REL, paths) + + @with_json_client + def test_submodel_element_path_get_rejects_unsupported_types(self, format_client: FormatClient): + """ + $path is defined for Submodel, SubmodelElementCollection, SubmodelElementList, Entity and + AnnotatedRelationshipElement only. Every other SubmodelElement subtype must + be rejected by the server with ``400 Bad Request``. + + The specification is ambiguous for leaf elements: the applicability table and its note require ``400``, while + an annex example returns ``200`` with the path of the leaf itself. We follow the applicability table. + """ + submodel = self.add_example_submodel() + + for id_short_path in (NESTED_PROPERTY, NESTED_RANGE, "ExampleOperation", "ExampleBasicEventElement"): + with self.subTest(id_short_path=id_short_path): + response = format_client.get(f"{self.elements_path(submodel.id, id_short_path)}/$path") + self.assert_error(response, 400) + + @with_json_client + def test_submodel_element_path_get_not_found(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + response = format_client.get(f"{self.elements_path(submodel.id, 'Unknown')}/$path") + + self.assert_error(response, 404) + + @with_json_client + def test_submodel_element_path_get_submodel_not_found(self, format_client: FormatClient): + response = format_client.get( + f"{self.elements_path('https://example.org/unknown', NESTED_COLLECTION)}/$path" + ) + + self.assert_error(response, 404) + + @with_xml_client + def test_submodel_element_path_get_xml_not_acceptable(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + response = format_client.get(f"{self.elements_path(submodel.id, NESTED_COLLECTION)}/$path") + + self.assert_error(response, 406) diff --git a/server/test/interfaces/repository/submodels/test_reference.py b/server/test/interfaces/repository/submodels/test_reference.py new file mode 100644 index 000000000..07766ce4f --- /dev/null +++ b/server/test/interfaces/repository/submodels/test_reference.py @@ -0,0 +1,420 @@ +# 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 +""" +Endpoint tests for the ``/submodels`` ``$reference`` content modifier. + +Covers: + * ``GET /submodels/$reference`` + * ``GET /submodels//$reference`` + * ``GET /submodels//submodel-elements/$reference`` + * ``GET /submodels//submodel-elements//$reference`` + +The OpenAPI only defines JSON responses for ``$reference``, the server additionally supports XML. +""" + +import json + +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_missing_attributes import create_example_submodel + +from ...format_utils import ( + FormatClient, + inject_format_clients, + with_json_client, + with_xml_client, +) +from ..helpers import RepositoryEndpointTestBase + +NESTED_COLLECTION = "ExampleSubmodelCollection" +NESTED_PROPERTY = "ExampleSubmodelCollection.ExampleProperty" +UNKNOWN_SUBMODEL_ID = "https://example.org/unknown" + + +class ReferenceEndpointTestBase(RepositoryEndpointTestBase): + """ + Shared assertions on the response and the query parameters of the ``$reference`` routes. + """ + + def assert_model_reference(self, format_client: FormatClient, node: object, keys: list[tuple[str, str]]) -> None: + """ + Ensures the node is a ``ModelReference`` with exactly the given ``(type, value)`` keys. + """ + self.assertEqual("ModelReference", format_client.field(node, "type")) + self.assertEqual(keys, format_client.reference_keys(node)) + + def assert_level_handling(self, format_client: FormatClient, path: str) -> None: + """ + The combination of ``level=deep`` and ``$reference`` is not allowed, thus only ``level=core`` is accepted. + """ + with self.subTest(level="deep"): + self.assert_error(format_client.get(f"{path}?level=deep"), 400) + with self.subTest(level="invalid"): + self.assert_error(format_client.get(f"{path}?level=invalid"), 400) + with self.subTest(level="core"): + self.assert_ok(format_client.get(f"{path}?level=core")) + + +@inject_format_clients +class SubmodelsReferenceEndpointsTest(ReferenceEndpointTestBase): + """ + Endpoint tests for the ``$reference`` modifier on the Submodel resource (``/submodels[/]``). + """ + + FIRST_ID = "https://example.org/Test_Submodel_Missing" + SECOND_ID = "https://example.org/Test_Submodel_Second" + + def two_submodels_store(self) -> model.DictIdentifiableStore: + store: model.DictIdentifiableStore = model.DictIdentifiableStore() + store.add(create_example_submodel()) + second = create_example_submodel() + second.id = self.SECOND_ID + second.id_short = "SecondSubmodel" + store.add(second) + return store + + # ------------------------------------------------------------------ GET /submodels/$reference + + @with_json_client + @with_xml_client + def test_submodels_reference_get(self, format_client: FormatClient): + self.object_store.update(self.two_submodels_store()) + + response = format_client.get("/submodels/$reference") + + self.assert_ok(response) + references = format_client.parse_collection(response) + self.assertCountEqual( + [[("Submodel", self.FIRST_ID)], [("Submodel", self.SECOND_ID)]], + [format_client.reference_keys(reference) for reference in references], + ) + for reference in references: + self.assertEqual("ModelReference", format_client.field(reference, "type")) + + @with_json_client + @with_xml_client + def test_submodels_reference_get_empty(self, format_client: FormatClient): + response = format_client.get("/submodels/$reference") + + self.assert_ok(response) + self.assertEqual([], format_client.parse_collection(response)) + + @with_json_client + @with_xml_client + def test_submodels_reference_get_supports_pagination(self, format_client: FormatClient): + self.object_store.update(self.two_submodels_store()) + + pages = format_client.get_paginated("/submodels/$reference", limit=1, max_pages=2) + + self.assertEqual([1, 1], [len(page) for page in pages]) + self.assertEqual( + {self.FIRST_ID, self.SECOND_ID}, + {format_client.reference_target(reference) for page in pages for reference in page}, + ) + + @with_json_client + @with_xml_client + def test_submodels_reference_get_filter_by_id_short(self, format_client: FormatClient): + self.object_store.update(self.two_submodels_store()) + + response = format_client.get("/submodels/$reference?idShort=SecondSubmodel") + + self.assert_ok(response) + self.assertEqual( + [self.SECOND_ID], + [format_client.reference_target(reference) for reference in format_client.parse_collection(response)], + ) + + @with_json_client + @with_xml_client + def test_submodels_reference_get_filter_by_semantic_id(self, format_client: FormatClient): + submodel = create_example_submodel() + other_submodel = create_example_submodel() + other_submodel.id = self.SECOND_ID + other_submodel.semantic_id = model.ExternalReference( + (model.Key(model.KeyTypes.GLOBAL_REFERENCE, "https://example.org/other"),) + ) + self.object_store.add(submodel) + self.object_store.add(other_submodel) + semantic_id = base64url_encode(json.dumps(submodel.semantic_id, cls=AASToJsonEncoder)) + + response = format_client.get(f"/submodels/$reference?semanticId={semantic_id}") + + self.assert_ok(response) + self.assertEqual( + [submodel.id], + [format_client.reference_target(reference) for reference in format_client.parse_collection(response)], + ) + + @with_json_client + @with_xml_client + def test_submodels_reference_get_level(self, format_client: FormatClient): + self.object_store.add(create_example_submodel()) + + self.assert_level_handling(format_client, "/submodels/$reference") + + @with_json_client + @with_xml_client + def test_submodels_reference_get_rejects_extent_with_blob_value(self, format_client: FormatClient): + self.object_store.add(create_example_submodel()) + + response = format_client.get("/submodels/$reference?extent=WithBLOBValue") + + self.assert_error(response, 400) + + # ------------------------------------------------------------------ GET /submodels//$reference + + @with_json_client + @with_xml_client + def test_submodel_reference_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)}/$reference") + + self.assert_ok(response) + self.assert_model_reference( + format_client, format_client.parse_object(response), [("Submodel", example_submodel.id)] + ) + + @with_json_client + @with_xml_client + def test_submodel_reference_get_level(self, format_client: FormatClient): + example_submodel = create_example_submodel() + self.object_store.add(example_submodel) + + self.assert_level_handling(format_client, f"/submodels/{base64url_encode(example_submodel.id)}/$reference") + + @with_json_client + @with_xml_client + def test_submodel_reference_get_rejects_extent_with_blob_value(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)}/$reference?extent=WithBLOBValue" + ) + + self.assert_error(response, 400) + + @with_json_client + @with_xml_client + def test_submodel_reference_get_not_found(self, format_client: FormatClient): + response = format_client.get( + f"/submodels/{base64url_encode(UNKNOWN_SUBMODEL_ID)}/$reference" + ) + + self.assert_error(response, 404) + + +@inject_format_clients +class SubmodelElementsReferenceEndpointsTest(ReferenceEndpointTestBase): + """ + Endpoint tests for the ``$reference`` modifier on the ``submodel-elements`` subtree. + """ + + def add_example_submodel(self) -> model.Submodel: + submodel = create_example_submodel() + self.object_store.add(submodel) + return submodel + + @staticmethod + def elements_path(submodel_id: str, id_short_path: str = "") -> str: + base = f"/submodels/{base64url_encode(submodel_id)}/submodel-elements" + return f"{base}/{id_short_path}" if id_short_path else base + + # ------------------------------------------------------------------ GET .../submodel-elements/$reference + + @with_json_client + @with_xml_client + def test_submodel_elements_reference_get(self, format_client: FormatClient): + submodel = self.add_example_submodel() + # Child elements are ignored, thus only the top-level elements are expected + expected_keys = [ + [("Submodel", submodel.id), (type(element).__name__, element.id_short)] + for element in submodel.submodel_element + ] + + response = format_client.get(f"{self.elements_path(submodel.id)}/$reference") + + self.assert_ok(response) + references = format_client.parse_collection(response) + self.assertCountEqual(expected_keys, [format_client.reference_keys(reference) for reference in references]) + for reference in references: + self.assertEqual("ModelReference", format_client.field(reference, "type")) + + @with_json_client + @with_xml_client + def test_submodel_elements_reference_get_empty(self, format_client: FormatClient): + submodel = model.Submodel("https://example.org/Test_Submodel_Empty") + self.object_store.add(submodel) + + response = format_client.get(f"{self.elements_path(submodel.id)}/$reference") + + self.assert_ok(response) + self.assertEqual([], format_client.parse_collection(response)) + + @with_json_client + @with_xml_client + def test_submodel_elements_reference_get_supports_pagination(self, format_client: FormatClient): + submodel = self.add_example_submodel() + limit = 3 + total = len(submodel.submodel_element) + expected_page_sizes = [min(limit, total - start) for start in range(0, total, limit)] + + pages = format_client.get_paginated( + f"{self.elements_path(submodel.id)}/$reference", limit=limit, max_pages=len(expected_page_sizes) + ) + + self.assertEqual(expected_page_sizes, [len(page) for page in pages]) + self.assertEqual( + {element.id_short for element in submodel.submodel_element}, + {format_client.reference_target(reference) for page in pages for reference in page}, + ) + + @with_json_client + @with_xml_client + def test_submodel_elements_reference_get_level(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + self.assert_level_handling(format_client, f"{self.elements_path(submodel.id)}/$reference") + + @with_json_client + @with_xml_client + def test_submodel_elements_reference_get_rejects_extent_with_blob_value(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + response = format_client.get(f"{self.elements_path(submodel.id)}/$reference?extent=WithBLOBValue") + + self.assert_error(response, 400) + + @with_json_client + @with_xml_client + def test_submodel_elements_reference_get_submodel_not_found(self, format_client: FormatClient): + response = format_client.get(f"{self.elements_path(UNKNOWN_SUBMODEL_ID)}/$reference") + + self.assert_error(response, 404) + + # ------------------------------------------------------------------ GET ...//$reference + + @with_json_client + @with_xml_client + def test_submodel_element_reference_get(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + response = format_client.get(f"{self.elements_path(submodel.id, NESTED_PROPERTY)}/$reference") + + self.assert_ok(response) + self.assert_model_reference( + format_client, + format_client.parse_object(response), + [ + ("Submodel", submodel.id), + ("SubmodelElementCollection", NESTED_COLLECTION), + ("Property", "ExampleProperty"), + ], + ) + + @with_json_client + @with_xml_client + def test_submodel_element_reference_get_collection_ignores_children(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + response = format_client.get(f"{self.elements_path(submodel.id, NESTED_COLLECTION)}/$reference") + + self.assert_ok(response) + self.assert_model_reference( + format_client, + format_client.parse_object(response), + [("Submodel", submodel.id), ("SubmodelElementCollection", NESTED_COLLECTION)], + ) + + @with_json_client + @with_xml_client + def test_submodel_element_reference_get_capability(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + response = format_client.get(f"{self.elements_path(submodel.id, 'ExampleCapability')}/$reference") + + self.assert_ok(response) + self.assert_model_reference( + format_client, + format_client.parse_object(response), + [("Submodel", submodel.id), ("Capability", "ExampleCapability")], + ) + + @with_json_client + @with_xml_client + def test_submodel_element_reference_get_operation(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + response = format_client.get(f"{self.elements_path(submodel.id, 'ExampleOperation')}/$reference") + + self.assert_ok(response) + self.assert_model_reference( + format_client, + format_client.parse_object(response), + [("Submodel", submodel.id), ("Operation", "ExampleOperation")], + ) + + @with_json_client + @with_xml_client + def test_submodel_element_reference_get_list_item_uses_index_as_key(self, format_client: FormatClient): + submodel = create_example_submodel() + submodel.submodel_element.add( + model.SubmodelElementList( + "ExampleList", + model.Property, + [model.Property(None, model.datatypes.String, "item")], + value_type_list_element=model.datatypes.String, + ) + ) + self.object_store.add(submodel) + + response = format_client.get(f"{self.elements_path(submodel.id, 'ExampleList[0]')}/$reference") + + self.assert_ok(response) + self.assert_model_reference( + format_client, + format_client.parse_object(response), + [("Submodel", submodel.id), ("SubmodelElementList", "ExampleList"), ("Property", "0")], + ) + + @with_json_client + @with_xml_client + def test_submodel_element_reference_get_level(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + self.assert_level_handling(format_client, f"{self.elements_path(submodel.id, NESTED_PROPERTY)}/$reference") + + @with_json_client + @with_xml_client + def test_submodel_element_reference_get_rejects_extent_with_blob_value(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + response = format_client.get( + f"{self.elements_path(submodel.id, NESTED_PROPERTY)}/$reference?extent=WithBLOBValue" + ) + + self.assert_error(response, 400) + + @with_json_client + @with_xml_client + def test_submodel_element_reference_get_not_found(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + response = format_client.get(f"{self.elements_path(submodel.id, 'DoesNotExist')}/$reference") + + self.assert_error(response, 404) + + @with_json_client + @with_xml_client + def test_submodel_element_reference_get_submodel_not_found(self, format_client: FormatClient): + response = format_client.get(f"{self.elements_path(UNKNOWN_SUBMODEL_ID, NESTED_PROPERTY)}/$reference") + + self.assert_error(response, 404) diff --git a/server/test/interfaces/repository/submodels/test_value.py b/server/test/interfaces/repository/submodels/test_value.py new file mode 100644 index 000000000..7fb08d70b --- /dev/null +++ b/server/test/interfaces/repository/submodels/test_value.py @@ -0,0 +1,342 @@ +# 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 +""" +Endpoint tests for the ``/submodels`` ``$value`` content modifier (ValueOnly-Serialization). + +Covers: + * ``GET /submodels/$value`` + * ``GET /submodels//$value`` + * ``GET /submodels//submodel-elements/$value`` + * ``GET /submodels//submodel-elements//$value`` + +ValueOnly-Serialization is JSON-only; XML variants are only used for the ``406 Not Acceptable`` cases. +""" +from typing import cast + +from app.util.converters import base64url_encode +from basyx.aas import model +from basyx.aas.examples.data.example_aas_missing_attributes import create_example_submodel + +from ...format_utils import ( + FormatClient, + inject_format_clients, + with_json_client, + with_xml_client, +) +from ..helpers import RepositoryEndpointTestBase + +# idShorts of top-level submodel elements of ``create_example_submodel()`` without a ValueOnly +# representation: they must not appear in a ``$value`` response. +VALUE_LESS_ID_SHORTS = ("ExampleOperation", "ExampleCapability") +# idShorts of the top-level submodel elements of ``create_example_submodel()`` with a ValueOnly representation. +VALUE_ID_SHORTS = { + "ExampleAnnotatedRelationshipElement", + "ExampleBasicEventElement", + "ExampleRelationshipElement", + "ExampleSubmodelCollection", +} + +NESTED_COLLECTION = "ExampleSubmodelCollection" +NESTED_PROPERTY = "ExampleSubmodelCollection.ExampleProperty" +NESTED_BLOB = "ExampleSubmodelCollection.ExampleBlob" +UNKNOWN_SUBMODEL_ID = "https://example.org/unknown" + + +class ValueEndpointTestBase(RepositoryEndpointTestBase): + """ + Shared assertions on the query parameters of the ``$value`` routes. + """ + + def assert_rejects_invalid_level(self, format_client: FormatClient, path: str) -> None: + """ + ``level`` accepts ``deep`` and ``core`` any other value must be rejected with 400. + """ + response = format_client.get(f"{path}?level=bogus") + self.assert_error(response, 400) + + def assert_level_handling(self, format_client: FormatClient, path: str) -> None: + """ + Both ``level=deep`` (default) and ``level=core`` must be accepted + """ + with self.subTest(level="deep"): + self.assert_ok(format_client.get(f"{path}?level=deep")) + with self.subTest(level="core"): + self.assert_ok(format_client.get(f"{path}?level=core")) + + +@inject_format_clients +class SubmodelsValueEndpointsTest(ValueEndpointTestBase): + """ + Endpoint tests for the ``$value`` modifier on the Submodel resource (``/submodels[/]``). + """ + + SECOND_ID = "https://example.org/Test_Submodel_Second" + + def two_submodels_store(self) -> model.DictIdentifiableStore: + store: model.DictIdentifiableStore = model.DictIdentifiableStore() + store.add(create_example_submodel()) + second = create_example_submodel() + second.id = self.SECOND_ID + second.id_short = "SecondSubmodel" + store.add(second) + return store + + # ------------------------------------------------------------------ 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) + submodels = format_client.parse_collection(response) + self.assertEqual(2, len(submodels)) + # Spot-check ValueOnly shape: each item is a mapping, and elements that lack a ValueOnly + # representation (Operation, Capability) are omitted. + for submodel in submodels: + self.assertIsInstance(submodel, dict) + for id_short in VALUE_LESS_ID_SHORTS: + self.assertNotIn(id_short, submodel) + + @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]) + + @with_json_client + def test_submodels_value_get_level_handling(self, format_client: FormatClient): + self.object_store.add(create_example_submodel()) + + self.assert_level_handling(format_client, "/submodels/$value") + + @with_json_client + def test_submodels_value_get_rejects_invalid_level(self, format_client: FormatClient): + self.object_store.add(create_example_submodel()) + + self.assert_rejects_invalid_level(format_client, "/submodels/$value") + + @with_xml_client + def test_submodels_value_get_xml_not_acceptable(self, format_client: FormatClient): + self.object_store.add(create_example_submodel()) + + response = format_client.get("/submodels/$value") + + self.assert_error(response, 406) + + # ------------------------------------------------------------------ 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) + # Spot-check: body is a mapping, with elements that lack a ValueOnly representation + # (Operation, Capability) omitted. + value = format_client.parse_object(response) + self.assertIsInstance(value, dict) + for id_short in VALUE_LESS_ID_SHORTS: + self.assertNotIn(id_short, value) + + @with_json_client + def test_submodel_value_get_level_handling(self, format_client: FormatClient): + example_submodel = create_example_submodel() + self.object_store.add(example_submodel) + + self.assert_level_handling(format_client, f"/submodels/{base64url_encode(example_submodel.id)}/$value") + + @with_json_client + def test_submodel_value_get_rejects_invalid_level(self, format_client: FormatClient): + example_submodel = create_example_submodel() + self.object_store.add(example_submodel) + + self.assert_rejects_invalid_level( + format_client, f"/submodels/{base64url_encode(example_submodel.id)}/$value" + ) + + @with_json_client + def test_submodel_value_get_extent_toggles_blob_value(self, format_client: FormatClient): + example_submodel = create_example_submodel() + self.object_store.add(example_submodel) + path = f"/submodels/{base64url_encode(example_submodel.id)}/$value" + + self.assert_extent_toggles_blob_value( + format_client, path, locate_blob=lambda body: body[NESTED_COLLECTION]["ExampleBlob"] + ) + + @with_json_client + def test_submodel_value_get_not_found(self, format_client: FormatClient): + response = format_client.get(f"/submodels/{base64url_encode(UNKNOWN_SUBMODEL_ID)}/$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) + + +@inject_format_clients +class SubmodelElementsValueEndpointsTest(ValueEndpointTestBase): + """ + Endpoint tests for the ``$value`` modifier on the ``submodel-elements`` subtree. + """ + + def add_example_submodel(self) -> model.Submodel: + submodel = create_example_submodel() + self.object_store.add(submodel) + return submodel + + @staticmethod + def elements_path(submodel_id: str, id_short_path: str = "") -> str: + base = f"/submodels/{base64url_encode(submodel_id)}/submodel-elements" + return f"{base}/{id_short_path}" if id_short_path else base + + # ------------------------------------------------------------------ GET .../submodel-elements/$value + + @with_json_client + def test_submodel_elements_value_get(self, format_client: FormatClient): + """ + ``result`` is one ``SubmodelValue`` object with the idShorts as names (``GetSubmodelElementsValueResult``). + Elements without a ValueOnly representation (Operation, Capability) are omitted. + """ + submodel = self.add_example_submodel() + + response = format_client.get(f"{self.elements_path(submodel.id)}/$value") + + self.assert_ok(response) + result = format_client.parse_object(response)["result"] + self.assertIsInstance(result, dict) + self.assertEqual(VALUE_ID_SHORTS, set(result)) + + @with_json_client + def test_submodel_elements_value_get_supports_pagination(self, format_client: FormatClient): + """ + ``limit`` counts SubmodelElements with a ValueOnly representation. Each page is one ``SubmodelValue`` object, + whose names are returned by ``parse_collection``. + """ + submodel = self.add_example_submodel() + + pages = format_client.get_paginated(f"{self.elements_path(submodel.id)}/$value", limit=3, max_pages=2) + + self.assertEqual([3, 1], [len(page) for page in pages]) + self.assertEqual(VALUE_ID_SHORTS, {id_short for page in pages for id_short in page}) + + @with_json_client + def test_submodel_elements_value_get_level_handling(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + self.assert_level_handling(format_client, f"{self.elements_path(submodel.id)}/$value") + + @with_json_client + def test_submodel_elements_value_get_rejects_invalid_level(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + self.assert_rejects_invalid_level(format_client, f"{self.elements_path(submodel.id)}/$value") + + @with_json_client + def test_submodel_elements_value_get_submodel_not_found(self, format_client: FormatClient): + response = format_client.get(f"{self.elements_path(UNKNOWN_SUBMODEL_ID)}/$value") + + self.assert_error(response, 404) + + @with_xml_client + def test_submodel_elements_value_get_xml_not_acceptable(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + response = format_client.get(f"{self.elements_path(submodel.id)}/$value") + + self.assert_error(response, 406) + + # ------------------------------------------------------------------ GET ...//$value + + @with_json_client + def test_submodel_element_value_get(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + response = format_client.get(f"{self.elements_path(submodel.id, NESTED_PROPERTY)}/$value") + + self.assert_ok(response) + # Spot-check: a Property's ValueOnly representation is the raw value (not a wrapping object). + self.assertEqual("exampleValue", format_client.parse_object(response)) + + @with_json_client + def test_submodel_element_value_get_none(self, format_client: FormatClient): + submodel = self.add_example_submodel() + cast(model.Property, submodel.get_referable(NESTED_PROPERTY)).value = None + self.object_store.commit(submodel) + + response = format_client.get(f"{self.elements_path(submodel.id, NESTED_PROPERTY)}/$value") + self.assert_ok(response) + self.assertIsNone(format_client.parse_object(response)) + + def test_submodel_element_value_get_rejects_unsupported_types(self): + """ + ValueOnly is not defined for Operation and Capability so the server + needs to reject both with ``400``. + """ + submodel = self.add_example_submodel() + + for id_short in ("ExampleOperation", "ExampleCapability"): + with self.subTest(id_short=id_short): + response = self.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_level_handling(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + self.assert_level_handling( + format_client, f"{self.elements_path(submodel.id, NESTED_COLLECTION)}/$value" + ) + + @with_json_client + def test_submodel_element_value_get_rejects_invalid_level(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + self.assert_rejects_invalid_level( + format_client, f"{self.elements_path(submodel.id, NESTED_PROPERTY)}/$value" + ) + + @with_json_client + def test_submodel_element_value_get_blob_extent_toggles_value(self, format_client: FormatClient): + submodel = self.add_example_submodel() + path = f"{self.elements_path(submodel.id, NESTED_BLOB)}/$value" + + self.assert_extent_toggles_blob_value(format_client, path, locate_blob=lambda body: body) + + @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, 'Unknown')}/$value") + + self.assert_error(response, 404) + + @with_json_client + def test_submodel_element_value_get_submodel_not_found(self, format_client: FormatClient): + response = format_client.get(f"{self.elements_path(UNKNOWN_SUBMODEL_ID, NESTED_PROPERTY)}/$value") + + self.assert_error(response, 404) + + @with_xml_client + def test_submodel_element_value_get_xml_not_acceptable(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + response = format_client.get(f"{self.elements_path(submodel.id, NESTED_PROPERTY)}/$value") + + self.assert_error(response, 406) diff --git a/server/test/interfaces/repository/test_concept_description.py b/server/test/interfaces/repository/test_concept_description.py index 77eea1e4f..970c042a5 100644 --- a/server/test/interfaces/repository/test_concept_description.py +++ b/server/test/interfaces/repository/test_concept_description.py @@ -90,6 +90,15 @@ def test_concept_descriptions_get_supports_pagination(self, format_client: Forma self.assertEqual([1, 1], [len(page) for page in pages]) + @with_json_client + @with_xml_client + def test_concept_descriptions_get_rejects_level(self, format_client: FormatClient): + self.object_store.update(self.two_concept_descriptions_store()) + + response = format_client.get("/concept-descriptions?level=core") + + self.assert_error(response, 400) + # ------------------------------------------------------------------ POST /concept-descriptions @with_json_client @@ -155,6 +164,15 @@ def test_concept_description_get_wrong_type_returns_404(self, format_client: For self.assert_error(response, 404) + @with_json_client + @with_xml_client + def test_concept_description_get_rejects_level(self, format_client: FormatClient): + self.object_store.update(self.two_concept_descriptions_store()) + + response = format_client.get(f"/concept-descriptions/{base64url_encode(self.EXAMPLE_ID)}?level=core") + + self.assert_error(response, 400) + # ------------------------------------------------------------------ PUT /concept-descriptions/ @with_json_client diff --git a/server/test/interfaces/repository/test_shells.py b/server/test/interfaces/repository/test_shells.py index 9ad41f694..ce55af5d2 100644 --- a/server/test/interfaces/repository/test_shells.py +++ b/server/test/interfaces/repository/test_shells.py @@ -47,6 +47,9 @@ class ShellsEndpointsTest(RepositoryEndpointTestBase): Bodies are written once against the format-agnostic ``format_client`` helper. For each test two variants are generated where the :class:`~..format_utils.JsonFormatClient` and :class:`~..format_utils.XmlFormatClient` are injected respectively. + + The OpenAPI specification only defines ``application/json`` for these routes. Supporting ``application/xml`` + (including ``$reference``) is a BaSyx Python SDK extension. """ # ------------------------------------------------------------------ GET /shells @@ -70,6 +73,15 @@ def test_shells_get_supports_pagination(self, format_client: FormatClient): self.assertEqual([1, 1], [len(page) for page in pages]) + @with_json_client + @with_xml_client + def test_shells_get_rejects_level(self, format_client: FormatClient): + self.object_store.update(self.two_shells_store()) + + response = format_client.get("/shells?level=core") + + self.assert_error(response, 400) + # ------------------------------------------------------------------ GET /shells?idShort=...&assetIds=... @staticmethod @@ -295,14 +307,23 @@ def test_shells_post_conflict(self, format_client: FormatClient): @with_xml_client def test_shells_reference_get(self, format_client: FormatClient): self.object_store.update(self.two_shells_store()) - example_shell = next(iter(self.object_store)) + expected_keys = [[("AssetAdministrationShell", shell.id)] for shell in self.object_store] response = format_client.get("/shells/$reference") self.assert_ok(response) references = format_client.parse_collection(response) - self.assertEqual(2, len(references)) - self.assertIn(example_shell.id, [format_client.reference_target(ref) for ref in references]) + self.assertCountEqual(expected_keys, [format_client.reference_keys(reference) for reference in references]) + for reference in references: + self.assertEqual("ModelReference", format_client.field(reference, "type")) + + @with_json_client + @with_xml_client + def test_shells_reference_get_empty(self, format_client: FormatClient): + response = format_client.get("/shells/$reference") + + self.assert_ok(response) + self.assertEqual([], format_client.parse_collection(response)) @with_json_client @with_xml_client @@ -312,6 +333,55 @@ def test_shells_reference_get_supports_pagination(self, format_client: FormatCli pages = format_client.get_paginated("/shells/$reference", limit=1, max_pages=2) self.assertEqual([1, 1], [len(page) for page in pages]) + self.assertEqual( + {shell.id for shell in self.object_store}, + {format_client.reference_target(reference) for page in pages for reference in page}, + ) + + @with_json_client + @with_xml_client + def test_shells_reference_get_filter_by_id_short(self, format_client: FormatClient): + self.object_store.update(self.shells_for_filtering_store()) + + response = format_client.get("/shells/$reference?idShort=Beta") + + self.assert_ok(response) + self.assertEqual( + ["https://example.org/shell-beta"], + [format_client.reference_target(reference) for reference in format_client.parse_collection(response)], + ) + + @with_json_client + @with_xml_client + def test_shells_reference_get_filter_by_asset_id(self, format_client: FormatClient): + self.object_store.update(self.shells_for_filtering_store()) + asset_id = _encode_global_asset_id("https://example.org/asset-beta") + + response = format_client.get(f"/shells/$reference?assetIds={asset_id}") + + self.assert_ok(response) + self.assertEqual( + ["https://example.org/shell-beta"], + [format_client.reference_target(reference) for reference in format_client.parse_collection(response)], + ) + + @with_json_client + @with_xml_client + def test_shells_reference_get_rejects_level(self, format_client: FormatClient): + self.object_store.update(self.two_shells_store()) + + for level in ("deep", "invalid"): + with self.subTest(level=level): + self.assert_error(format_client.get(f"/shells/$reference?level={level}"), 400) + + @with_json_client + @with_xml_client + def test_shells_reference_get_rejects_extent_with_blob_value(self, format_client: FormatClient): + self.object_store.update(self.two_shells_store()) + + response = format_client.get("/shells/$reference?extent=WithBLOBValue") + + self.assert_error(response, 400) # ------------------------------------------------------------------ GET /shells/ @@ -333,6 +403,16 @@ def test_shell_get_not_found(self, format_client: FormatClient): self.assert_error(response, 404) + @with_json_client + @with_xml_client + def test_shell_get_rejects_level(self, format_client: FormatClient): + example_shell = create_example_asset_administration_shell() + self.object_store.add(example_shell) + + response = format_client.get(f"/shells/{base64url_encode(example_shell.id)}?level=core") + + self.assert_error(response, 400) + # ------------------------------------------------------------------ GET /shells//$reference @with_json_client @@ -344,7 +424,38 @@ def test_shell_reference_get(self, format_client: FormatClient): response = format_client.get(f"/shells/{base64url_encode(example_shell.id)}/$reference") self.assert_ok(response) - self.assertEqual(example_shell.id, format_client.reference_target(format_client.parse_object(response))) + reference = format_client.parse_object(response) + self.assertEqual("ModelReference", format_client.field(reference, "type")) + self.assertEqual([("AssetAdministrationShell", example_shell.id)], format_client.reference_keys(reference)) + + @with_json_client + @with_xml_client + def test_shell_reference_get_rejects_level(self, format_client: FormatClient): + example_shell = create_example_asset_administration_shell() + self.object_store.add(example_shell) + + for level in ("deep", "invalid"): + with self.subTest(level=level): + self.assert_error( + format_client.get(f"/shells/{base64url_encode(example_shell.id)}/$reference?level={level}"), 400 + ) + + @with_json_client + @with_xml_client + def test_shell_reference_get_rejects_extent_with_blob_value(self, format_client: FormatClient): + example_shell = create_example_asset_administration_shell() + self.object_store.add(example_shell) + + response = format_client.get(f"/shells/{base64url_encode(example_shell.id)}/$reference?extent=WithBLOBValue") + + self.assert_error(response, 400) + + @with_json_client + @with_xml_client + def test_shell_reference_get_not_found(self, format_client: FormatClient): + response = format_client.get(f"/shells/{base64url_encode('https://example.org/unknown')}/$reference") + + self.assert_error(response, 404) # ------------------------------------------------------------------ PUT /shells/ @@ -410,6 +521,16 @@ def test_shell_asset_information_get(self, format_client: FormatClient): format_client.field(format_client.parse_object(response), "globalAssetId"), ) + @with_json_client + @with_xml_client + def test_shell_asset_information_get_rejects_level(self, format_client: FormatClient): + example_shell = create_example_asset_administration_shell() + self.object_store.add(example_shell) + + response = format_client.get(f"/shells/{base64url_encode(example_shell.id)}/asset-information?level=core") + + self.assert_error(response, 400) + # ------------------------------------------------------------------ PUT /shells//asset-information @with_json_client @@ -470,6 +591,16 @@ def test_shell_submodel_refs_get_supports_pagination(self, format_client: Format self.assertEqual([1, 1], [len(page) for page in pages]) + @with_json_client + @with_xml_client + def test_shell_submodel_refs_get_rejects_level(self, format_client: FormatClient): + example_shell = create_example_asset_administration_shell() + self.object_store.add(example_shell) + + response = format_client.get(f"/shells/{base64url_encode(example_shell.id)}/submodel-refs?level=core") + + self.assert_error(response, 400) + # ------------------------------------------------------------------ POST /shells//submodel-refs @with_json_client diff --git a/server/test/interfaces/repository/test_submodels.py b/server/test/interfaces/repository/test_submodels.py index 622b80600..5c2531de2 100644 --- a/server/test/interfaces/repository/test_submodels.py +++ b/server/test/interfaces/repository/test_submodels.py @@ -12,6 +12,7 @@ from app.util.converters import base64url_encode from basyx.aas import model from basyx.aas.adapter.json import AASToJsonEncoder +from basyx.aas.examples.data import example_aas from basyx.aas.examples.data.example_aas_missing_attributes import create_example_submodel from ..format_utils import ( @@ -22,6 +23,26 @@ ) from .helpers import RepositoryEndpointTestBase +# ExampleSubmodelCollection (a nested namespace) and one of its children, carried by +# ``create_example_submodel()``; used as ready-made id_short paths in the tests below. +NESTED_COLLECTION = "ExampleSubmodelCollection" +NESTED_PROPERTY = "ExampleSubmodelCollection.ExampleProperty" +NESTED_BLOB = "ExampleSubmodelCollection.ExampleBlob" +NESTED_FILE = "ExampleSubmodelCollection.ExampleFile" +# SubmodelElementList inside ExampleSubmodelCollection, only carried by ``example_aas.create_example_submodel()``. +NESTED_LIST_ID_SHORT = "ExampleSubmodelList" +EXAMPLE_QUALIFIER_TYPE = "http://example.org/Qualifier/ExampleQualifier" + + +def _child(elements: list, id_short: str) -> dict: + """Returns the element with the given idShort from a list of SubmodelElements in JSON format.""" + return next(element for element in elements if element["idShort"] == id_short) + + +def _example_blob(submodel_elements: list) -> dict: + """Returns ``ExampleSubmodelCollection.ExampleBlob`` from the SubmodelElements of ``create_example_submodel()``.""" + return _child(_child(submodel_elements, NESTED_COLLECTION)["value"], "ExampleBlob") + def _encode_reference(reference: model.Reference) -> str: return base64url_encode(json.dumps(reference, cls=AASToJsonEncoder)) @@ -56,7 +77,7 @@ def two_submodels_store(self) -> model.DictIdentifiableStore: return store def _get_submodel_ids(self, format_client: FormatClient, query: str) -> set: - response = format_client.get(f"/submodels?{query}") + response = format_client.get(f"/submodels?{query}&extent=WithBLOBValue") self.assert_ok(response) return {format_client.identifier(node) for node in format_client.parse_collection(response)} @@ -67,7 +88,7 @@ def _get_submodel_ids(self, format_client: FormatClient, query: str) -> set: def test_submodels_get(self, format_client: FormatClient): self.object_store.update(self.two_submodels_store()) - response = format_client.get("/submodels") + response = format_client.get("/submodels?extent=WithBLOBValue") self.assert_ok(response) self.assertEqual(2, len(format_client.parse_collection(response))) @@ -75,7 +96,7 @@ def test_submodels_get(self, format_client: FormatClient): @with_json_client @with_xml_client def test_submodels_get_empty(self, format_client: FormatClient): - response = format_client.get("/submodels") + response = format_client.get("/submodels?extent=WithBLOBValue") self.assert_ok(response) self.assertEqual(0, len(format_client.parse_collection(response))) @@ -85,10 +106,56 @@ def test_submodels_get_empty(self, format_client: FormatClient): def test_submodels_get_supports_pagination(self, format_client: FormatClient): self.object_store.update(self.two_submodels_store()) - pages = format_client.get_paginated("/submodels", limit=1, max_pages=2) + pages = format_client.get_paginated("/submodels?extent=WithBLOBValue", limit=1, max_pages=2) self.assertEqual([1, 1], [len(page) for page in pages]) + @with_json_client + def test_submodels_get_extent_toggles_blob_value(self, format_client: FormatClient): + self.object_store.add(create_example_submodel()) + + self.assert_extent_toggles_blob_value( + format_client, "/submodels", locate_blob=lambda body: _example_blob(body["result"][0]["submodelElements"]) + ) + + @with_xml_client + def test_submodels_get_xml_requires_blob_value(self, format_client: FormatClient): + self.object_store.add(create_example_submodel()) + + self.assert_xml_requires_blob_value(format_client, "/submodels") + + @with_xml_client + def test_submodels_get_xml_rejects_level_core(self, format_client: FormatClient): + response = format_client.get("/submodels?level=core") + + self.assert_error(response, 406) + + @with_json_client + def test_submodels_get_level_core(self, format_client: FormatClient): + """ + ``level=core`` keeps the direct children of each Submodel but omits their children. + """ + self.object_store.update(self.two_submodels_store()) + + response = format_client.get("/submodels?level=core") + + self.assert_ok(response) + submodels = format_client.parse_collection(response) + self.assertEqual(2, len(submodels)) + for submodel in submodels: + self.assertNotIn("value", _child(submodel["submodelElements"], NESTED_COLLECTION)) + + @with_json_client + def test_submodels_get_rejects_limit_below_one(self, format_client: FormatClient): + """ + ``limit=0`` would return an empty page whose cursor points to the same start, so ``limit`` must be positive. + """ + self.object_store.update(self.two_submodels_store()) + + for limit in (0, -1): + with self.subTest(limit=limit): + self.assert_error(format_client.get(f"/submodels?limit={limit}"), 400) + # ------------------------------------------------------------------ GET /submodels?idShort=...&semanticId=... @with_json_client @@ -160,110 +227,88 @@ def test_submodels_post_conflict(self, format_client: FormatClient): self.assert_error(response, 409) - # ------------------------------------------------------------------ GET /submodels/$metadata + # ------------------------------------------------------------------ GET /submodels/ @with_json_client @with_xml_client - def test_submodels_metadata_get(self, format_client: FormatClient): - self.object_store.update(self.two_submodels_store()) + def test_submodel_get_success(self, format_client: FormatClient): + example_submodel = create_example_submodel() + self.object_store.add(example_submodel) - response = format_client.get("/submodels/$metadata") + response = format_client.get(f"/submodels/{base64url_encode(example_submodel.id)}?extent=WithBLOBValue") self.assert_ok(response) - self.assertEqual(2, len(format_client.parse_collection(response))) + self.assertEqual(example_submodel.id, format_client.identifier(format_client.parse_object(response))) @with_json_client @with_xml_client - def test_submodels_metadata_get_supports_pagination(self, format_client: FormatClient): - self.object_store.update(self.two_submodels_store()) - - pages = format_client.get_paginated("/submodels/$metadata", limit=1, max_pages=2) - - self.assertEqual([1, 1], [len(page) for page in pages]) - - def test_submodels_metadata_get_rejects_level(self): - self.object_store.add(create_example_submodel()) - - response = self.client.get("/submodels/$metadata?level=deep") - - self.assert_error(response, 400) + def test_submodel_get_not_found(self, format_client: FormatClient): + response = format_client.get( + f"/submodels/{base64url_encode('https://example.org/unknown')}?extent=WithBLOBValue" + ) - # ------------------------------------------------------------------ GET /submodels/$reference + self.assert_error(response, 404) - @with_json_client @with_xml_client - def test_submodels_reference_get(self, format_client: FormatClient): - self.object_store.update(self.two_submodels_store()) - - response = format_client.get("/submodels/$reference") + def test_submodel_get_not_found_xml_without_extent_returns_406(self, format_client: FormatClient): + """ + The modifiers are checked before the Submodel is looked up, so clients learn that XML without BLOB values is + not supported, even if the Submodel doesn't exist. + """ + response = format_client.get(f"/submodels/{base64url_encode('https://example.org/unknown')}") - self.assert_ok(response) - references = format_client.parse_collection(response) - self.assertEqual(2, len(references)) - self.assertEqual( - {"https://example.org/Test_Submodel_Missing", self.SECOND_ID}, - {format_client.reference_target(ref) for ref in references}, - ) + self.assert_error(response, 406) @with_json_client - @with_xml_client - def test_submodels_reference_get_supports_pagination(self, format_client: FormatClient): - self.object_store.update(self.two_submodels_store()) - - pages = format_client.get_paginated("/submodels/$reference", limit=1, max_pages=2) - - self.assertEqual([1, 1], [len(page) for page in pages]) + def test_submodel_get_extent_toggles_blob_value(self, format_client: FormatClient): + example_submodel = create_example_submodel() + self.object_store.add(example_submodel) - # ------------------------------------------------------------------ GET /submodels/ + self.assert_extent_toggles_blob_value( + format_client, + f"/submodels/{base64url_encode(example_submodel.id)}", + locate_blob=lambda body: _example_blob(body["submodelElements"]), + ) - @with_json_client @with_xml_client - def test_submodel_get_success(self, format_client: FormatClient): + def test_submodel_get_xml_requires_blob_value(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)}") - - self.assert_ok(response) - self.assertEqual(example_submodel.id, format_client.identifier(format_client.parse_object(response))) + self.assert_xml_requires_blob_value(format_client, f"/submodels/{base64url_encode(example_submodel.id)}") - @with_json_client @with_xml_client - def test_submodel_get_not_found(self, format_client: FormatClient): - response = format_client.get(f"/submodels/{base64url_encode('https://example.org/unknown')}") - - self.assert_error(response, 404) - - def test_submodel_get_stripped_omits_submodel_elements(self): + def test_submodel_get_xml_rejects_level_core(self, format_client: FormatClient): example_submodel = create_example_submodel() self.object_store.add(example_submodel) - path = f"/submodels/{base64url_encode(example_submodel.id)}" - full = self.client.get(path) - stripped = self.client.get(f"{path}?level=core") + response = format_client.get(f"/submodels/{base64url_encode(example_submodel.id)}?level=core") - self.assert_ok(full) - self.assert_ok(stripped) - self.assertIn("submodelElements", full.get_data(as_text=True)) - self.assertNotIn("submodelElements", stripped.get_data(as_text=True)) + self.assert_error(response, 406) - def test_submodel_get_invalid_level_returns_400(self): + @with_json_client + def test_submodel_get_level_core(self, format_client: FormatClient): + """ + ``level=core`` keeps the direct children of the Submodel but omits their children. + """ example_submodel = create_example_submodel() self.object_store.add(example_submodel) - response = self.client.get(f"/submodels/{base64url_encode(example_submodel.id)}?level=bogus") + response = format_client.get(f"/submodels/{base64url_encode(example_submodel.id)}?level=core") - self.assert_error(response, 400) + self.assert_ok(response) + submodel_elements = format_client.parse_object(response)["submodelElements"] + self.assertEqual(len(example_submodel.submodel_element), len(submodel_elements)) + self.assertNotIn("value", _child(submodel_elements, NESTED_COLLECTION)) - def test_submodel_get_extent_not_implemented(self): + @with_json_client + def test_submodel_get_invalid_level_returns_400(self, format_client: FormatClient): example_submodel = create_example_submodel() self.object_store.add(example_submodel) - response = self.client.get( - f"/submodels/{base64url_encode(example_submodel.id)}?extent=withBlobValue" - ) + response = format_client.get(f"/submodels/{base64url_encode(example_submodel.id)}?level=bogus") - self.assert_error(response, 501) + self.assert_error(response, 400) # ------------------------------------------------------------------ PUT /submodels/ @@ -315,80 +360,6 @@ def test_submodel_delete_not_found(self, format_client: FormatClient): self.assert_error(response, 404) - # ------------------------------------------------------------------ GET /submodels//$metadata - - @with_json_client - @with_xml_client - def test_submodel_metadata_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)}/$metadata") - - self.assert_ok(response) - self.assertEqual( - example_submodel.id, format_client.identifier(format_client.parse_object(response)) - ) - - def test_submodel_metadata_get_omits_submodel_elements(self): - example_submodel = create_example_submodel() - self.object_store.add(example_submodel) - - response = self.client.get(f"/submodels/{base64url_encode(example_submodel.id)}/$metadata") - - self.assert_ok(response) - self.assertNotIn("submodelElements", response.get_data(as_text=True)) - - def test_submodel_metadata_get_rejects_level(self): - example_submodel = create_example_submodel() - self.object_store.add(example_submodel) - - response = self.client.get( - f"/submodels/{base64url_encode(example_submodel.id)}/$metadata?level=core" - ) - - self.assert_error(response, 400) - - def test_submodel_metadata_get_not_found(self): - response = self.client.get( - f"/submodels/{base64url_encode('https://example.org/unknown')}/$metadata" - ) - - self.assert_error(response, 404) - - # ------------------------------------------------------------------ GET /submodels//$reference - - @with_json_client - @with_xml_client - def test_submodel_reference_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)}/$reference") - - self.assert_ok(response) - self.assertEqual( - example_submodel.id, format_client.reference_target(format_client.parse_object(response)) - ) - - @with_json_client - @with_xml_client - def test_submodel_reference_get_not_found(self, format_client: FormatClient): - response = format_client.get( - f"/submodels/{base64url_encode('https://example.org/unknown')}/$reference" - ) - - self.assert_error(response, 404) - - -# ExampleSubmodelCollection (a nested namespace) and one of its children, carried by -# ``create_example_submodel()``; used as ready-made id_short paths in the tests below. -NESTED_COLLECTION = "ExampleSubmodelCollection" -NESTED_PROPERTY = "ExampleSubmodelCollection.ExampleProperty" -NESTED_BLOB = "ExampleSubmodelCollection.ExampleBlob" -NESTED_FILE = "ExampleSubmodelCollection.ExampleFile" -EXAMPLE_QUALIFIER_TYPE = "http://example.org/Qualifier/ExampleQualifier" - @inject_format_clients class SubmodelElementsEndpointsTest(RepositoryEndpointTestBase): @@ -410,6 +381,14 @@ def add_example_submodel(self) -> model.Submodel: self.object_store.add(submodel) return submodel + def add_full_example_submodel(self) -> model.Submodel: + """ + Adds the full example submodel, which nests a SubmodelElementList inside ``ExampleSubmodelCollection``. + """ + submodel = example_aas.create_example_submodel() + self.object_store.add(submodel) + return submodel + def _stored_submodel(self, submodel_id: str) -> model.Submodel: submodel = self.object_store.get(submodel_id) assert isinstance(submodel, model.Submodel) @@ -450,7 +429,7 @@ def _nested_blob(self, submodel: model.Submodel, id_short_path: str) -> model.Bl def test_submodel_elements_get(self, format_client: FormatClient): submodel = self.add_example_submodel() - response = format_client.get(self.elements_path(submodel.id)) + response = format_client.get(f"{self.elements_path(submodel.id)}?extent=WithBLOBValue") self.assert_ok(response) self.assertEqual(self.TOP_LEVEL_COUNT, len(format_client.parse_collection(response))) @@ -460,7 +439,7 @@ def test_submodel_elements_get(self, format_client: FormatClient): def test_submodel_elements_get_pagination_limit(self, format_client: FormatClient): submodel = self.add_example_submodel() - response = format_client.get(f"{self.elements_path(submodel.id)}?limit=2") + response = format_client.get(f"{self.elements_path(submodel.id)}?limit=2&extent=WithBLOBValue") self.assert_ok(response) self.assertEqual(2, len(format_client.parse_collection(response))) @@ -470,17 +449,55 @@ def test_submodel_elements_get_pagination_limit(self, format_client: FormatClien def test_submodel_elements_get_supports_pagination(self, format_client: FormatClient): submodel = self.add_example_submodel() - pages = format_client.get_paginated(self.elements_path(submodel.id), limit=3, max_pages=2) + pages = format_client.get_paginated( + f"{self.elements_path(submodel.id)}?extent=WithBLOBValue", limit=3, max_pages=2 + ) self.assertEqual([3, 3], [len(page) for page in pages]) @with_json_client @with_xml_client def test_submodel_elements_get_submodel_not_found(self, format_client: FormatClient): - response = format_client.get(self.elements_path("https://example.org/unknown")) + response = format_client.get(f"{self.elements_path('https://example.org/unknown')}?extent=WithBLOBValue") self.assert_error(response, 404) + @with_json_client + def test_submodel_elements_get_extent_toggles_blob_value(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + self.assert_extent_toggles_blob_value( + format_client, self.elements_path(submodel.id), locate_blob=lambda body: _example_blob(body["result"]) + ) + + @with_xml_client + def test_submodel_elements_get_xml_requires_blob_value(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + self.assert_xml_requires_blob_value(format_client, self.elements_path(submodel.id)) + + @with_xml_client + def test_submodel_elements_get_xml_rejects_level_core(self, format_client: FormatClient): + submodel = self.add_full_example_submodel() + + response = format_client.get(f"{self.elements_path(submodel.id)}?level=core") + + self.assert_error(response, 406) + + @with_json_client + def test_submodel_elements_get_level_core(self, format_client: FormatClient): + """ + ``level=core`` keeps the direct children of each SubmodelElement but omits their children. + """ + submodel = self.add_full_example_submodel() + + response = format_client.get(f"{self.elements_path(submodel.id)}?level=core") + + self.assert_ok(response) + collection = _child(format_client.parse_collection(response), NESTED_COLLECTION) + self.assertIn("value", collection) + self.assertNotIn("value", _child(collection["value"], NESTED_LIST_ID_SHORT)) + # ------------------------------------------------------------------ POST .../submodel-elements @with_json_client @@ -517,116 +534,101 @@ def test_submodel_elements_post_submodel_not_found(self, format_client: FormatCl self.assert_error(response, 404) - # ------------------------------------------------------------------ GET .../submodel-elements/$metadata + # ------------------------------------------------------------------ GET .../submodel-elements/ @with_json_client @with_xml_client - def test_submodel_elements_metadata_get(self, format_client: FormatClient): + def test_submodel_element_get_top_level(self, format_client: FormatClient): submodel = self.add_example_submodel() - response = format_client.get(f"{self.elements_path(submodel.id)}/$metadata") + response = format_client.get(f"{self.elements_path(submodel.id, 'ExampleCapability')}?extent=WithBLOBValue") self.assert_ok(response) - self.assertEqual(self.TOP_LEVEL_COUNT, len(format_client.parse_collection(response))) + self.assertEqual( + "ExampleCapability", format_client.field(format_client.parse_object(response), "idShort") + ) @with_json_client @with_xml_client - def test_submodel_elements_metadata_get_supports_pagination(self, format_client: FormatClient): - submodel = self.add_example_submodel() - - pages = format_client.get_paginated(f"{self.elements_path(submodel.id)}/$metadata", limit=3, max_pages=2) - - self.assertEqual([3, 3], [len(page) for page in pages]) - - def test_submodel_elements_metadata_get_rejects_level(self): + def test_submodel_element_get_nested(self, format_client: FormatClient): submodel = self.add_example_submodel() - response = self.client.get(f"{self.elements_path(submodel.id)}/$metadata?level=deep") + response = format_client.get(f"{self.elements_path(submodel.id, NESTED_PROPERTY)}?extent=WithBLOBValue") - self.assert_error(response, 400) - - # ------------------------------------------------------------------ GET .../submodel-elements/$reference + self.assert_ok(response) + self.assertEqual( + "ExampleProperty", format_client.field(format_client.parse_object(response), "idShort") + ) @with_json_client @with_xml_client - def test_submodel_elements_reference_get(self, format_client: FormatClient): + def test_submodel_element_get_not_found(self, format_client: FormatClient): submodel = self.add_example_submodel() - response = format_client.get(f"{self.elements_path(submodel.id)}/$reference") + response = format_client.get(f"{self.elements_path(submodel.id, 'DoesNotExist')}?extent=WithBLOBValue") - self.assert_ok(response) - references = format_client.parse_collection(response) - self.assertEqual(self.TOP_LEVEL_COUNT, len(references)) - self.assertIn( - "ExampleCapability", {format_client.reference_target(ref) for ref in references} - ) + self.assert_error(response, 404) @with_json_client @with_xml_client - def test_submodel_elements_reference_get_supports_pagination(self, format_client: FormatClient): + def test_submodel_element_get_nested_not_found(self, format_client: FormatClient): submodel = self.add_example_submodel() - pages = format_client.get_paginated(f"{self.elements_path(submodel.id)}/$reference", limit=3, max_pages=2) - - self.assertEqual([3, 3], [len(page) for page in pages]) + response = format_client.get( + f"{self.elements_path(submodel.id, f'{NESTED_COLLECTION}.DoesNotExist')}?extent=WithBLOBValue" + ) - # ------------------------------------------------------------------ GET .../submodel-elements/ + self.assert_error(response, 404) @with_json_client - @with_xml_client - def test_submodel_element_get_top_level(self, format_client: FormatClient): + def test_submodel_element_get_extent_toggles_blob_value(self, format_client: FormatClient): submodel = self.add_example_submodel() - response = format_client.get(self.elements_path(submodel.id, "ExampleCapability")) - - self.assert_ok(response) - self.assertEqual( - "ExampleCapability", format_client.field(format_client.parse_object(response), "idShort") + self.assert_extent_toggles_blob_value( + format_client, self.elements_path(submodel.id, NESTED_BLOB), locate_blob=lambda body: body ) - @with_json_client @with_xml_client - def test_submodel_element_get_nested(self, format_client: FormatClient): + def test_submodel_element_get_xml_requires_blob_value(self, format_client: FormatClient): submodel = self.add_example_submodel() - response = format_client.get(self.elements_path(submodel.id, NESTED_PROPERTY)) + self.assert_xml_requires_blob_value(format_client, self.elements_path(submodel.id, NESTED_COLLECTION)) - self.assert_ok(response) - self.assertEqual( - "ExampleProperty", format_client.field(format_client.parse_object(response), "idShort") - ) - - @with_json_client @with_xml_client - def test_submodel_element_get_not_found(self, format_client: FormatClient): - submodel = self.add_example_submodel() + def test_submodel_element_get_xml_rejects_level_core(self, format_client: FormatClient): + submodel = self.add_full_example_submodel() - response = format_client.get(self.elements_path(submodel.id, "DoesNotExist")) + response = format_client.get(f"{self.elements_path(submodel.id, NESTED_COLLECTION)}?level=core") - self.assert_error(response, 404) + self.assert_error(response, 406) @with_json_client - @with_xml_client - def test_submodel_element_get_nested_not_found(self, format_client: FormatClient): - submodel = self.add_example_submodel() + def test_submodel_element_get_level_core(self, format_client: FormatClient): + """ + ``level=core`` keeps the direct children of the requested SubmodelElement but omits their children. + """ + submodel = self.add_full_example_submodel() - response = format_client.get( - self.elements_path(submodel.id, f"{NESTED_COLLECTION}.DoesNotExist") - ) + response = format_client.get(f"{self.elements_path(submodel.id, NESTED_COLLECTION)}?level=core") - self.assert_error(response, 404) + self.assert_ok(response) + collection = format_client.parse_object(response) + self.assertIn("value", collection) + self.assertNotIn("value", _child(collection["value"], NESTED_LIST_ID_SHORT)) - def test_submodel_element_get_path_through_non_namespace_returns_400(self): + @with_json_client + def test_submodel_element_get_path_through_non_namespace_returns_400(self, format_client: FormatClient): submodel = self.add_example_submodel() - response = self.client.get(self.elements_path(submodel.id, f"{NESTED_PROPERTY}.Child")) + response = format_client.get(self.elements_path(submodel.id, f"{NESTED_PROPERTY}.Child")) self.assert_error(response, 400) - def test_submodel_element_get_malformed_id_short_path_returns_400(self): + @with_json_client + def test_submodel_element_get_malformed_id_short_path_returns_400(self, format_client: FormatClient): submodel = self.add_example_submodel() - response = self.client.get(self.elements_path(submodel.id, "a..b")) + response = format_client.get(self.elements_path(submodel.id, "a..b")) self.assert_error(response, 400) @@ -660,13 +662,39 @@ def test_submodel_element_post_child_conflict(self, format_client: FormatClient) self.assert_error(response, 409) - def test_submodel_element_post_into_non_namespace_returns_400(self): + @with_json_client + @with_xml_client + def test_submodel_element_post_into_list_location_uses_index(self, format_client: FormatClient): + """ + Elements of a SubmodelElementList are addressed by their index. The internal idShort the SDK generates for + list elements without an idShort must not leak into the ``Location`` header. + """ + sml = model.SubmodelElementList( + "ExampleList", + model.Property, + value_type_list_element=model.datatypes.String, + value=[model.Property(None, model.datatypes.String, "existing")], + ) + submodel = model.Submodel("https://example.org/Test_Submodel_List", submodel_element=[sml]) + self.object_store.add(submodel) + new_element = model.Property(None, model.datatypes.String, "added") + + response = format_client.post(self.elements_path(submodel.id, "ExampleList"), obj=new_element) + + self.assertEqual(201, response.status_code) + self.assertNotIn(model.SubmodelElementList.GENERATED_ID_SHORT_PREFIX, response.headers["Location"]) + created = format_client.get(f"{response.headers['Location']}?extent=WithBLOBValue") + self.assert_ok(created) + self.assertEqual("added", format_client.field(format_client.parse_object(created), "value")) + + @with_json_client + def test_submodel_element_post_into_non_namespace_returns_400(self, format_client: FormatClient): submodel = self.add_example_submodel() payload = json.dumps( model.Property("Child", model.datatypes.String, "v"), cls=AASToJsonEncoder ) - response = self.client.post( + response = format_client.post( self.elements_path(submodel.id, NESTED_PROPERTY), data=payload, content_type="application/json", @@ -712,7 +740,7 @@ def test_submodel_element_delete_success(self, format_client: FormatClient): response = format_client.delete(self.elements_path(submodel.id, NESTED_PROPERTY)) self.assertEqual(204, response.status_code) - follow_up = format_client.get(self.elements_path(submodel.id, NESTED_PROPERTY)) + follow_up = format_client.get(f"{self.elements_path(submodel.id, NESTED_PROPERTY)}?extent=WithBLOBValue") self.assert_error(follow_up, 404) @with_json_client @@ -724,129 +752,79 @@ def test_submodel_element_delete_not_found(self, format_client: FormatClient): self.assert_error(response, 404) - # ------------------------------------------------------------------ GET ...//$metadata - - @with_json_client - @with_xml_client - def test_submodel_element_metadata_get(self, format_client: FormatClient): - submodel = self.add_example_submodel() - - response = format_client.get(f"{self.elements_path(submodel.id, NESTED_PROPERTY)}/$metadata") - - self.assert_ok(response) - self.assertEqual( - "ExampleProperty", format_client.field(format_client.parse_object(response), "idShort") - ) - - def test_submodel_element_metadata_get_rejects_capability(self): - submodel = self.add_example_submodel() - - response = self.client.get( - f"{self.elements_path(submodel.id, 'ExampleCapability')}/$metadata" - ) - - self.assert_error(response, 400) - - def test_submodel_element_metadata_get_rejects_level(self): - submodel = self.add_example_submodel() - - response = self.client.get( - f"{self.elements_path(submodel.id, NESTED_PROPERTY)}/$metadata?level=core" - ) - - self.assert_error(response, 400) - - # ------------------------------------------------------------------ GET ...//$reference - - @with_json_client - @with_xml_client - def test_submodel_element_reference_get(self, format_client: FormatClient): - submodel = self.add_example_submodel() - - response = format_client.get(f"{self.elements_path(submodel.id, NESTED_PROPERTY)}/$reference") - - self.assert_ok(response) - self.assertEqual( - "ExampleProperty", format_client.reference_target(format_client.parse_object(response)) - ) - - @with_json_client - @with_xml_client - def test_submodel_element_reference_get_not_found(self, format_client: FormatClient): - submodel = self.add_example_submodel() - - response = format_client.get( - f"{self.elements_path(submodel.id, 'DoesNotExist')}/$reference" - ) - - self.assert_error(response, 404) - # ------------------------------------------------------------------ ...//attachment - def test_submodel_element_attachment_get_blob(self): + @with_json_client + def test_submodel_element_attachment_get_blob(self, format_client: FormatClient): submodel = self.add_example_submodel() - response = self.client.get(f"{self.elements_path(submodel.id, NESTED_BLOB)}/attachment") + response = format_client.get(f"{self.elements_path(submodel.id, NESTED_BLOB)}/attachment") self.assertEqual(200, response.status_code) self.assertEqual("application/pdf", response.mimetype) self.assertEqual(bytes([1, 2, 3, 4, 5]), response.get_data()) - def test_submodel_element_attachment_get_file(self): + @with_json_client + def test_submodel_element_attachment_get_file(self, format_client: FormatClient): submodel = self.add_example_submodel() self._nested_file(submodel, NESTED_FILE).value = "/TestFile.pdf" self._nested_file(submodel, NESTED_FILE).content_type = "application/pdf" self.file_store.write_file.side_effect = lambda name, stream: stream.write(b"file-content") - response = self.client.get(f"{self.elements_path(submodel.id, NESTED_FILE)}/attachment") + response = format_client.get(f"{self.elements_path(submodel.id, NESTED_FILE)}/attachment") self.assertEqual(200, response.status_code) self.assertEqual("application/pdf", response.content_type) self.file_store.write_file.assert_any_call("/TestFile.pdf", mock.ANY) self.assertEqual(b"file-content", response.data) - def test_submodel_element_attachment_get_on_non_file_returns_400(self): + @with_json_client + def test_submodel_element_attachment_get_on_non_file_returns_400(self, format_client: FormatClient): submodel = self.add_example_submodel() - response = self.client.get(f"{self.elements_path(submodel.id, NESTED_PROPERTY)}/attachment") + response = format_client.get(f"{self.elements_path(submodel.id, NESTED_PROPERTY)}/attachment") self.assert_error(response, 400) - def test_submodel_element_attachment_get_file_without_value_returns_404(self): + @with_json_client + def test_submodel_element_attachment_get_file_without_value_returns_404(self, format_client: FormatClient): submodel = create_example_submodel() self._nested_file(submodel, NESTED_FILE).value = None self.object_store.add(submodel) - response = self.client.get(f"{self.elements_path(submodel.id, NESTED_FILE)}/attachment") + response = format_client.get(f"{self.elements_path(submodel.id, NESTED_FILE)}/attachment") self.assert_error(response, 404) - def test_submodel_element_attachment_get_external_file_return_400(self): + @with_json_client + def test_submodel_element_attachment_get_external_file_return_400(self, format_client: FormatClient): submodel = create_example_submodel() self._nested_file(submodel, NESTED_FILE).value = "C:\\Users\\Test\\File.pdf" self.object_store.add(submodel) - response = self.client.get(f"{self.elements_path(submodel.id, NESTED_FILE)}/attachment") + response = format_client.get(f"{self.elements_path(submodel.id, NESTED_FILE)}/attachment") self.assert_error(response, 400) - def test_submodel_element_attachment_get_missing_file_return_404(self): + @with_json_client + def test_submodel_element_attachment_get_missing_file_return_404(self, format_client: FormatClient): submodel = create_example_submodel() self.object_store.add(submodel) self.file_store.write_file.side_effect = KeyError - response = self.client.get(f"{self.elements_path(submodel.id, NESTED_FILE)}/attachment") + response = format_client.get(f"{self.elements_path(submodel.id, NESTED_FILE)}/attachment") self.assert_error(response, 404) - def test_submodel_element_attachment_put_success(self): + @with_json_client + def test_submodel_element_attachment_put_success(self, format_client: FormatClient): submodel = create_example_submodel() self._nested_file(submodel, NESTED_FILE).value = None self.object_store.add(submodel) self.file_store.add_file.return_value = "/uploaded.pdf" - response = self.client.put( + response = format_client.put( f"{self.elements_path(submodel.id, NESTED_FILE)}/attachment", data={ "fileName": "/uploaded.pdf", @@ -861,10 +839,11 @@ def test_submodel_element_attachment_put_success(self): "/uploaded.pdf", self._nested_file(self._stored_submodel(submodel.id), NESTED_FILE).value ) - def test_submodel_element_attachment_put_conflict_when_value_present(self): + @with_json_client + def test_submodel_element_attachment_put_conflict_when_value_present(self, format_client: FormatClient): submodel = self.add_example_submodel() - response = self.client.put( + response = format_client.put( f"{self.elements_path(submodel.id, NESTED_FILE)}/attachment", data={ "fileName": "/uploaded.pdf", @@ -875,10 +854,11 @@ def test_submodel_element_attachment_put_conflict_when_value_present(self): self.assert_error(response, 409) - def test_submodel_element_attachment_put_on_non_file_returns_400(self): + @with_json_client + def test_submodel_element_attachment_put_on_non_file_returns_400(self, format_client: FormatClient): submodel = self.add_example_submodel() - response = self.client.put( + response = format_client.put( f"{self.elements_path(submodel.id, NESTED_PROPERTY)}/attachment", data={"fileName": "/x.pdf", "file": (io.BytesIO(b"x"), "x.pdf", "application/pdf")}, content_type="multipart/form-data", @@ -886,11 +866,12 @@ def test_submodel_element_attachment_put_on_non_file_returns_400(self): self.assert_error(response, 400) - def test_submodel_element_attachment_put_missing_filename_returns_400(self): + @with_json_client + def test_submodel_element_attachment_put_missing_filename_returns_400(self, format_client: FormatClient): submodel = self.add_example_submodel() self._nested_file(submodel, NESTED_FILE).value = None - response = self.client.put( + response = format_client.put( f"{self.elements_path(submodel.id, NESTED_FILE)}/attachment", data={"file": (io.BytesIO(b"x"), "x.pdf", "application/pdf")}, content_type="multipart/form-data", @@ -898,11 +879,12 @@ def test_submodel_element_attachment_put_missing_filename_returns_400(self): self.assert_error(response, 400) - def test_submodel_element_attachment_put_external_filename_returns_400(self): + @with_json_client + def test_submodel_element_attachment_put_external_filename_returns_400(self, format_client: FormatClient): submodel = self.add_example_submodel() self._nested_file(submodel, NESTED_FILE).value = None - response = self.client.put( + response = format_client.put( f"{self.elements_path(submodel.id, NESTED_FILE)}/attachment", data={"fileName": "C:\\Users\\Test\\x.pdf", "file": (io.BytesIO(b"x"), "x.pdf", "application/pdf")}, content_type="multipart/form-data", @@ -910,11 +892,12 @@ def test_submodel_element_attachment_put_external_filename_returns_400(self): self.assert_error(response, 400) - def test_submodel_element_attachment_put_missing_file_returns_400(self): + @with_json_client + def test_submodel_element_attachment_put_missing_file_returns_400(self, format_client: FormatClient): submodel = self.add_example_submodel() self._nested_file(submodel, NESTED_FILE).value = None - response = self.client.put( + response = format_client.put( f"{self.elements_path(submodel.id, NESTED_FILE)}/attachment", data={"fileName": "/x.pdf"}, content_type="multipart/form-data", @@ -922,11 +905,12 @@ def test_submodel_element_attachment_put_missing_file_returns_400(self): self.assert_error(response, 400) - def test_submodel_element_attachment_put_mimetype_mismatch_returns_400(self): + @with_json_client + def test_submodel_element_attachment_put_mimetype_mismatch_returns_400(self, format_client: FormatClient): submodel = self.add_example_submodel() self._nested_file(submodel, NESTED_FILE).value = None - response = self.client.put( + response = format_client.put( f"{self.elements_path(submodel.id, NESTED_FILE)}/attachment", data={"fileName": "/x.pdf", "file": (io.BytesIO(b"x"), "x.pdf", "application/xml")}, content_type="multipart/form-data", @@ -934,60 +918,66 @@ def test_submodel_element_attachment_put_mimetype_mismatch_returns_400(self): self.assert_error(response, 415) - def test_submodel_element_attachment_delete_blob(self): + @with_json_client + def test_submodel_element_attachment_delete_blob(self, format_client: FormatClient): submodel = self.add_example_submodel() - response = self.client.delete(f"{self.elements_path(submodel.id, NESTED_BLOB)}/attachment") + response = format_client.delete(f"{self.elements_path(submodel.id, NESTED_BLOB)}/attachment") self.assertEqual(204, response.status_code) self.assertIsNone(self._nested_blob(self._stored_submodel(submodel.id), NESTED_BLOB).value) - def test_submodel_element_attachment_delete_file(self): + @with_json_client + def test_submodel_element_attachment_delete_file(self, format_client: FormatClient): submodel = self.add_example_submodel() self._nested_file(submodel, NESTED_FILE).value = "/TestFile.pdf" - response = self.client.delete(f"{self.elements_path(submodel.id, NESTED_FILE)}/attachment") + response = format_client.delete(f"{self.elements_path(submodel.id, NESTED_FILE)}/attachment") self.assertEqual(204, response.status_code) self.file_store.delete_file.assert_any_call("/TestFile.pdf") self.assertIsNone(self._nested_file(self._stored_submodel(submodel.id), NESTED_FILE).value) - def test_submodel_element_attachment_delete_file_ignores_store_error(self): + @with_json_client + def test_submodel_element_attachment_delete_file_ignores_store_error(self, format_client: FormatClient): submodel = self.add_example_submodel() self._nested_file(submodel, NESTED_FILE).value = "/TestFile.pdf" self.file_store.delete_file.side_effect = KeyError - response = self.client.delete(f"{self.elements_path(submodel.id, NESTED_FILE)}/attachment") + response = format_client.delete(f"{self.elements_path(submodel.id, NESTED_FILE)}/attachment") self.assertEqual(204, response.status_code) self.file_store.delete_file.assert_any_call("/TestFile.pdf") self.assertIsNone(self._nested_file(self._stored_submodel(submodel.id), NESTED_FILE).value) - def test_submodel_element_attachment_delete_on_non_attachment_returns_400(self): + @with_json_client + def test_submodel_element_attachment_delete_on_non_attachment_returns_400(self, format_client: FormatClient): submodel = self.add_example_submodel() - response = self.client.delete( + response = format_client.delete( f"{self.elements_path(submodel.id, NESTED_PROPERTY)}/attachment" ) self.assert_error(response, 400) - def test_submodel_element_attachment_delete_no_value_returns_404(self): + @with_json_client + def test_submodel_element_attachment_delete_no_value_returns_404(self, format_client: FormatClient): submodel = self.add_example_submodel() self._nested_blob(submodel, NESTED_BLOB).value = None - response = self.client.delete( + response = format_client.delete( f"{self.elements_path(submodel.id, NESTED_BLOB)}/attachment" ) self.assert_error(response, 404) - def test_submodel_element_attachment_delete_external_file_returns_400(self): + @with_json_client + def test_submodel_element_attachment_delete_external_file_returns_400(self, format_client: FormatClient): submodel = self.add_example_submodel() self._nested_file(submodel, NESTED_FILE).value = "C:\\Users\\Test\\x.pdf" - response = self.client.delete(f"{self.elements_path(submodel.id, NESTED_FILE)}/attachment") + response = format_client.delete(f"{self.elements_path(submodel.id, NESTED_FILE)}/attachment") self.assertEqual(400, response.status_code) diff --git a/server/test/interfaces/test_base.py b/server/test/interfaces/test_base.py index ecdacca3c..5cf56dd2e 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 @@ -288,3 +289,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)