diff --git a/server/README.md b/server/README.md index 1a3e42173..af43efa6c 100644 --- a/server/README.md +++ b/server/README.md @@ -116,15 +116,10 @@ Several features and routes are currently not supported: 3. Route `/shells/{aasIdentifier}/asset-information/thumbnail`: Not implemented because the specification lacks clarity. -4. Serialization and Description Routes: - - `/serialization` - - `/description` - These routes are not implemented at this time. - -5. Value, Path, and PATCH Routes: +4. Value, Path, and PATCH Routes: - All `/…/value$`, `/…/path$`, and `PATCH` routes 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/interfaces/base.py b/server/app/interfaces/base.py index ac974cbad..fa090553e 100644 --- a/server/app/interfaces/base.py +++ b/server/app/interfaces/base.py @@ -42,6 +42,8 @@ T = TypeVar("T") +AASX_CONTENT_TYPE: str = "application/asset-administration-shell-package+xml" + @enum.unique class MessageType(enum.Enum): @@ -184,6 +186,21 @@ def __init__(self, *args, content_type="text/xml", **kwargs): super().__init__(*args, **kwargs, content_type=content_type) +RESPONSE_TYPES: Dict[str, Type[APIResponse]] = { + "application/json": JsonResponse, + "application/xml": XmlResponse, + "text/xml": XmlResponseAlt, +} + +# all content types the server accepts. AASX packages aren't served by any of the response classes, GET /serialization +# builds them itself, but they are accepted here nonetheless, as content negotiation happens before the route is +# matched. +CONTENT_TYPES: Tuple[str, ...] = (*RESPONSE_TYPES, AASX_CONTENT_TYPE) + +# content type served if the client states no preference +DEFAULT_CONTENT_TYPE: str = "application/json" + + class ResultToJsonEncoder(ServerAASToJsonEncoder): @classmethod def _result_to_json(cls, result: Result) -> Dict[str, object]: @@ -268,19 +285,8 @@ def handle_request(self, request: Request): @staticmethod def get_response_type(request: Request) -> Type[APIResponse]: - response_types: Dict[str, Type[APIResponse]] = { - "application/json": JsonResponse, - "application/xml": XmlResponse, - "text/xml": XmlResponseAlt, - } - if len(request.accept_mimetypes) == 0 or request.accept_mimetypes.best in (None, "*/*"): - return JsonResponse - mime_type = request.accept_mimetypes.best_match(response_types) - if mime_type is None: - raise werkzeug.exceptions.NotAcceptable( - "This server supports the following content types: " + ", ".join(response_types.keys()) - ) - return response_types[mime_type] + # errors of the routes serving a content type of their own are returned as JSON + return RESPONSE_TYPES.get(get_content_type(request), JsonResponse) @staticmethod def http_exception_to_response( @@ -482,6 +488,34 @@ def _convert_single_json_item(cls, data: Any, expect_type: Type[T], stripped: bo return cls.json(json_bytes, expect_type, stripped) +def get_content_type(request: Request) -> str: + """ + Determine the content type to serve, based on the Accept header of the request. + + :raises NotAcceptable: If the client accepts none of the :data:`CONTENT_TYPES` + """ + if len(request.accept_mimetypes) == 0 or request.accept_mimetypes.best in (None, "*/*"): + return DEFAULT_CONTENT_TYPE + content_type = request.accept_mimetypes.best_match(CONTENT_TYPES) + if content_type is None: + raise werkzeug.exceptions.NotAcceptable( + "This server supports the following content types: " + ", ".join(CONTENT_TYPES) + ) + return content_type + + +def get_bool_arg(request: Request, name: str, default: bool) -> bool: + """ + Retrieve a boolean query parameter, which the specification defines as either ``true`` or ``false``. + """ + arg = request.args.get(name) + if arg is None: + return default + if arg not in ("true", "false"): + raise BadRequest(f"{name} must be either 'true' or 'false', got {arg!r}!") + return arg == "true" + + def is_stripped_request(request: Request) -> bool: level = request.args.get("level") if level not in {"deep", "core", None}: diff --git a/server/app/interfaces/repository.py b/server/app/interfaces/repository.py index 8f931c786..c21bfadb5 100644 --- a/server/app/interfaces/repository.py +++ b/server/app/interfaces/repository.py @@ -17,6 +17,9 @@ import werkzeug.utils from basyx.aas import model from basyx.aas.adapter import aasx +from basyx.aas.adapter.json import object_store_to_json +from basyx.aas.adapter.xml import write_aas_xml_file +from basyx.aas.util import traversal from werkzeug import Request, Response from werkzeug.datastructures import FileStorage from werkzeug.exceptions import BadRequest, Conflict, NotFound @@ -26,7 +29,16 @@ from app.model import ServiceDescription, ServiceSpecificationProfileEnum from app.util.converters import IdentifierToBase64URLConverter, IdShortPathConverter, base64url_decode -from .base import APIResponse, HTTPApiDecoder, ObjectStoreWSGIApp, T, is_stripped_request +from .base import ( + AASX_CONTENT_TYPE, + APIResponse, + HTTPApiDecoder, + ObjectStoreWSGIApp, + T, + get_bool_arg, + get_content_type, + is_stripped_request, +) SUPPORTED_PROFILES: ServiceDescription = ServiceDescription( [ @@ -52,7 +64,7 @@ def __init__( Submount( base_path, [ - Rule("/serialization", methods=["GET"], endpoint=self.not_implemented), + Rule("/serialization", methods=["GET"], endpoint=self.get_serialization), Rule("/description", methods=["GET"], endpoint=self.get_description), Rule("/shells", methods=["GET"], endpoint=self.get_aas_all), Rule("/shells", methods=["POST"], endpoint=self.post_aas), @@ -519,6 +531,54 @@ def _get_submodel_submodel_elements_id_short_path(self, url_args: Dict) -> model def _get_concept_description(self, url_args): return self._get_obj_ts(url_args["concept_id"], model.ConceptDescription) + def _get_referenced_concept_descriptions( + self, objects: Iterable[model.Identifiable] + ) -> model.DictIdentifiableStore[model.ConceptDescription]: + """ + Resolve the :class:`ConceptDescriptions ` referenced by the + semanticIds of the given objects. semanticIds that cannot be resolved are skipped, just as + :meth:`basyx.aas.adapter.aasx.AASXWriter.write_aas` does, since a single defect reference must not render the + whole repository unserializable. + """ + concept_descriptions: model.DictIdentifiableStore[model.ConceptDescription] = model.DictIdentifiableStore() + for identifiable in objects: + for semantic_id in traversal.walk_semantic_ids_recursive(identifiable): + if ( + not isinstance(semantic_id, model.ModelReference) + or semantic_id.type is not model.ConceptDescription + or semantic_id.get_identifier() in concept_descriptions + ): + continue + try: + concept_descriptions.add(semantic_id.resolve(self.object_store)) + except (IndexError, KeyError, TypeError, ValueError, model.UnexpectedTypeError): + continue + return concept_descriptions + + def _get_serialization_objects(self, request: Request) -> model.DictIdentifiableStore[model.Identifiable]: + aas_ids: List[str] = request.args.getlist("aasIds") + submodel_ids: List[str] = request.args.getlist("submodelIds") + include_concept_descriptions: bool = get_bool_arg(request, "includeConceptDescriptions", True) + objects: model.DictIdentifiableStore[model.Identifiable] = model.DictIdentifiableStore() + if not aas_ids and not submodel_ids: + # both id lists are optional filters, thus the whole repository is serialized if neither is given + types: Tuple[Type[model.Identifiable], ...] = (model.AssetAdministrationShell, model.Submodel) + if include_concept_descriptions: + types += (model.ConceptDescription,) + for obj in self.object_store: + if isinstance(obj, types): + objects.add(obj) + return objects + for aas_id in aas_ids: + objects.add(self._get_obj_ts(base64url_decode(aas_id), model.AssetAdministrationShell)) + for submodel_id in submodel_ids: + objects.add(self._get_obj_ts(base64url_decode(submodel_id), model.Submodel)) + if include_concept_descriptions: + # only the ConceptDescriptions belonging to the requested objects are added, as unrelated ones would + # bloat a filtered Environment + objects.update(self._get_referenced_concept_descriptions(list(objects))) + return objects + # ------ all not implemented ROUTES ------- def not_implemented(self, request: Request, url_args: Dict, **_kwargs) -> Response: raise werkzeug.exceptions.NotImplemented("This route is not implemented!") @@ -526,6 +586,26 @@ def not_implemented(self, request: Request, url_args: Dict, **_kwargs) -> Respon def get_description(self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs) -> Response: return response_t(SUPPORTED_PROFILES.to_dict()) + def get_serialization(self, request: Request, url_args: Dict, **_kwargs) -> Response: + """ + The serialized Environment is returned as-is, i.e. it is not wrapped by any of the + :class:`APIResponses `, which is why the response is built here. For the same + reason the adapters of the SDK are used unaltered: an Environment must conform to the metamodel schema, thus + it must not contain anything the ``ServerAASToJsonEncoder`` of the other routes adds. + """ + objects = self._get_serialization_objects(request) + content_type = get_content_type(request) + if content_type == AASX_CONTENT_TYPE: + aasx_data = io.BytesIO() + with aasx.AASXWriter(aasx_data) as writer: + writer.write_all_aas_objects("/aasx/data.xml", objects, self.file_store) + return Response(aasx_data.getvalue(), content_type=content_type) + if content_type == "application/json": + return Response(object_store_to_json(objects), content_type=content_type) + environment = io.BytesIO() + write_aas_xml_file(environment, objects) + return Response(environment.getvalue(), content_type=content_type) + # ------ AAS REPO ROUTES ------- def get_aas_all(self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs) -> Response: aashells, paging_metadata = self._get_shells(request) diff --git a/server/test/interfaces/test_serialization.py b/server/test/interfaces/test_serialization.py new file mode 100644 index 000000000..2176dbee7 --- /dev/null +++ b/server/test/interfaces/test_serialization.py @@ -0,0 +1,197 @@ +# Copyright (c) 2026 the Eclipse BaSyx Authors +# +# This program and the accompanying materials are made available under the terms of the MIT License, available in +# the LICENSE file of this project. +# +# SPDX-License-Identifier: MIT + +import base64 +import io +import json +import unittest +from typing import Type, TypeVar + +from app.interfaces.base import AASX_CONTENT_TYPE +from app.interfaces.repository import WSGIApp +from basyx.aas import model +from basyx.aas.adapter.aasx import AASXReader, DictSupplementaryFileContainer +from basyx.aas.examples.data.example_aas import create_full_example +from lxml import etree +from werkzeug.test import Client + +BASE_PATH = "/api/v3.1" +SERIALIZATION_PATH = BASE_PATH + "/serialization" +# the only ConceptDescription of the example data, referenced by the semanticId of a SubmodelElement of +# CONCEPT_DESCRIPTION_SUBMODEL_ID +CONCEPT_DESCRIPTION_ID = "https://example.org/Test_ConceptDescription" +CONCEPT_DESCRIPTION_SUBMODEL_ID = "https://example.org/Test_Submodel" +# file referenced by a File SubmodelElement of the example data +SUPPLEMENTARY_FILE_NAME = "/TestFile.pdf" + +_T = TypeVar("_T", bound=model.Identifiable) + + +def _encode_id(identifier: model.Identifier) -> str: + return base64.urlsafe_b64encode(identifier.encode()).decode() + + +class SerializationTest(unittest.TestCase): + def setUp(self) -> None: + self.example_data = create_full_example() + self.aas = self._get_first_of_type(model.AssetAdministrationShell) + self.submodel = self._get_by_id(CONCEPT_DESCRIPTION_SUBMODEL_ID, model.Submodel) + self.file_store = DictSupplementaryFileContainer() + self.file_store.add_file(SUPPLEMENTARY_FILE_NAME, io.BytesIO(b"%PDF-1.4 test"), "application/pdf") + self.client = Client(WSGIApp(self.example_data, self.file_store)) + + def _get_first_of_type(self, type_: Type[_T]) -> _T: + return next(obj for obj in self.example_data if isinstance(obj, type_)) + + def _get_by_id(self, identifier: model.Identifier, type_: Type[_T]) -> _T: + obj = self.example_data.get(identifier) + assert isinstance(obj, type_) + return obj + + def _get_json_environment(self, query: str = "") -> dict: + response = self.client.get(SERIALIZATION_PATH + query) + self.assertEqual(200, response.status_code) + self.assertEqual("application/json", response.content_type) + return json.loads(response.data) + + def test_without_ids_returns_whole_repository(self) -> None: + environment = self._get_json_environment() + expected_aas = [obj.id for obj in self.example_data if isinstance(obj, model.AssetAdministrationShell)] + expected_submodels = [obj.id for obj in self.example_data if isinstance(obj, model.Submodel)] + self.assertEqual(sorted(expected_aas), sorted(aas["id"] for aas in environment["assetAdministrationShells"])) + self.assertEqual(sorted(expected_submodels), sorted(submodel["id"] for submodel in environment["submodels"])) + + def test_unreferenced_concept_descriptions_are_included_without_filter(self) -> None: + orphan = model.ConceptDescription(id_="https://example.org/Orphan_ConceptDescription") + self.example_data.add(orphan) + environment = self._get_json_environment() + self.assertIn( + orphan.id, [concept_description["id"] for concept_description in environment["conceptDescriptions"]] + ) + + def test_concept_descriptions_are_excluded_without_filter(self) -> None: + environment = self._get_json_environment("?includeConceptDescriptions=false") + self.assertNotIn("conceptDescriptions", environment) + + def test_aas_ids_filter(self) -> None: + environment = self._get_json_environment("?aasIds=" + _encode_id(self.aas.id)) + self.assertEqual([self.aas.id], [aas["id"] for aas in environment["assetAdministrationShells"]]) + self.assertNotIn("submodels", environment) + + def test_submodel_ids_filter(self) -> None: + environment = self._get_json_environment("?submodelIds=" + _encode_id(self.submodel.id)) + self.assertEqual([self.submodel.id], [submodel["id"] for submodel in environment["submodels"]]) + self.assertNotIn("assetAdministrationShells", environment) + + def test_aas_and_submodel_ids_filter(self) -> None: + environment = self._get_json_environment( + f"?aasIds={_encode_id(self.aas.id)}&submodelIds={_encode_id(self.submodel.id)}" + ) + self.assertEqual([self.aas.id], [aas["id"] for aas in environment["assetAdministrationShells"]]) + self.assertEqual([self.submodel.id], [submodel["id"] for submodel in environment["submodels"]]) + + def test_referenced_concept_descriptions_are_included(self) -> None: + environment = self._get_json_environment("?submodelIds=" + _encode_id(self.submodel.id)) + self.assertEqual( + [CONCEPT_DESCRIPTION_ID], + [concept_description["id"] for concept_description in environment["conceptDescriptions"]], + ) + + def test_unreferenced_concept_descriptions_are_excluded(self) -> None: + submodel = self._get_by_id("http://example.org/Submodels/Assets/TestAsset/Identification", model.Submodel) + environment = self._get_json_environment("?submodelIds=" + _encode_id(submodel.id)) + self.assertNotIn("conceptDescriptions", environment) + + def test_include_concept_descriptions_false(self) -> None: + environment = self._get_json_environment( + f"?submodelIds={_encode_id(self.submodel.id)}&includeConceptDescriptions=false" + ) + self.assertNotIn("conceptDescriptions", environment) + + def test_invalid_include_concept_descriptions_returns_400(self) -> None: + response = self.client.get(SERIALIZATION_PATH + "?includeConceptDescriptions=yes") + self.assertEqual(400, response.status_code) + + def test_xml_serialization(self) -> None: + response = self.client.get(SERIALIZATION_PATH, headers={"Accept": "application/xml"}) + self.assertEqual(200, response.status_code) + self.assertEqual("application/xml", response.content_type) + root = etree.fromstring(response.data) + self.assertEqual("{https://admin-shell.io/aas/3/1}environment", root.tag) + + def test_aasx_serialization(self) -> None: + response = self.client.get( + SERIALIZATION_PATH + "?aasIds=" + _encode_id(self.aas.id), headers={"Accept": AASX_CONTENT_TYPE} + ) + self.assertEqual(200, response.status_code) + self.assertEqual(AASX_CONTENT_TYPE, response.content_type) + objects: model.DictIdentifiableStore[model.Identifiable] = model.DictIdentifiableStore() + AASXReader(io.BytesIO(response.data)).read_into(objects, DictSupplementaryFileContainer()) + self.assertEqual([self.aas.id], [obj.id for obj in objects]) + + def test_unknown_id_returns_404(self) -> None: + response = self.client.get(SERIALIZATION_PATH + "?aasIds=" + _encode_id("http://example.org/nonexistent_aas")) + self.assertEqual(404, response.status_code) + + def test_aasx_serialization_contains_supplementary_files(self) -> None: + response = self.client.get( + SERIALIZATION_PATH + "?submodelIds=" + _encode_id(self.submodel.id), headers={"Accept": AASX_CONTENT_TYPE} + ) + self.assertEqual(200, response.status_code) + file_store = DictSupplementaryFileContainer() + AASXReader(io.BytesIO(response.data)).read_into(model.DictIdentifiableStore(), file_store) + self.assertIn(SUPPLEMENTARY_FILE_NAME, file_store) + + def test_text_xml_serialization(self) -> None: + response = self.client.get(SERIALIZATION_PATH, headers={"Accept": "text/xml"}) + self.assertEqual(200, response.status_code) + self.assertEqual("text/xml", response.content_type) + self.assertEqual("{https://admin-shell.io/aas/3/1}environment", etree.fromstring(response.data).tag) + + def test_invalid_base64_id_returns_400(self) -> None: + response = self.client.get(SERIALIZATION_PATH + "?aasIds=invalid_base64!") + self.assertEqual(400, response.status_code) + + def test_id_of_wrong_type_returns_404(self) -> None: + response = self.client.get(SERIALIZATION_PATH + "?aasIds=" + _encode_id(self.submodel.id)) + self.assertEqual(404, response.status_code) + + def test_unresolvable_semantic_id_is_skipped(self) -> None: + # a semanticId whose key path descends into a Property, which is not a namespace, must not render the whole + # repository unserializable + submodel_element = next(iter(self.submodel.submodel_element)) + submodel_element.semantic_id = model.ModelReference( + ( + model.Key(model.KeyTypes.SUBMODEL, self.submodel.id), + model.Key(model.KeyTypes.PROPERTY, submodel_element.id_short), + model.Key(model.KeyTypes.PROPERTY, "nonexistent"), + ), + model.ConceptDescription, + ) + environment = self._get_json_environment("?submodelIds=" + _encode_id(self.submodel.id)) + self.assertEqual([self.submodel.id], [submodel["id"] for submodel in environment["submodels"]]) + self.assertNotIn("conceptDescriptions", environment) + + def test_errors_are_returned_as_json_if_aasx_is_requested(self) -> None: + response = self.client.get( + SERIALIZATION_PATH + "?aasIds=" + _encode_id("http://example.org/nonexistent_aas"), + headers={"Accept": AASX_CONTENT_TYPE}, + ) + self.assertEqual(404, response.status_code) + self.assertEqual("application/json", response.content_type) + self.assertFalse(json.loads(response.data)["success"]) + + def test_aasx_is_accepted_on_other_routes(self) -> None: + # content negotiation happens before the route is matched, thus the AASX content type is accepted everywhere + # and other routes respond with JSON instead of 406 + response = self.client.get(BASE_PATH + "/shells", headers={"Accept": AASX_CONTENT_TYPE}) + self.assertEqual(200, response.status_code) + self.assertEqual("application/json", response.content_type) + + +if __name__ == "__main__": + unittest.main()