From 48bd058c94b80b028341901874be34002d2dddfb Mon Sep 17 00:00:00 2001 From: Tom Hammel Date: Mon, 31 Aug 2026 15:34:05 +0200 Subject: [PATCH] server: Implement the `/serialization` route Previously, `GET /serialization` was not implemented yet. The route now serves JSON, XML or an AASX package, as requested via the `Accept` header. The serialization itself is left to the adapters of the `basyx-python-sdk`, so no rules are duplicated here. The specification leaves the semantics of the query parameters open in some regards, which are resolved as follows: - `aasIds` and `submodelIds` are optional filters, so that a plain `GET` on the route yields a complete export of the repository. - The requested objects are returned as they are, i.e. an AAS does not drag in the Submodels it references. Otherwise a client could not request an AAS on its own. - `includeConceptDescriptions` yields all ConceptDescriptions if no filter is given, so that such an export can be read back without losing objects. For a filtered request, only the ones referenced by the semanticIds of the requested objects are added, as unrelated ones would bloat the export. - Unknown or mistyped identifiers result in `404 Not Found`, as on all other routes taking identifiers. Since content negotiation happens before the route is matched, the AASX media type has to be accepted on all routes, which respond with JSON instead of `406 Not Acceptable`. Error responses stay JSON in either case. Fixes #491 --- server/README.md | 9 +- server/app/interfaces/base.py | 60 ++++-- server/app/interfaces/repository.py | 84 +++++++- server/test/interfaces/test_serialization.py | 197 +++++++++++++++++++ 4 files changed, 328 insertions(+), 22 deletions(-) create mode 100644 server/test/interfaces/test_serialization.py diff --git a/server/README.md b/server/README.md index 1a3e4217..af43efa6 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 ac974cba..fa090553 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 8f931c78..c21bfadb 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 00000000..2176dbee --- /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()