diff --git a/README.md b/README.md index ed2bd6ac..fa0512d9 100644 --- a/README.md +++ b/README.md @@ -9,12 +9,13 @@ These are the implemented AAS specifications of the [current SDK release](https: | Specification | Version | |---------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| Part 1: Metamodel | [v3.0.1 (01001-3-0-1)](https://industrialdigitaltwin.org/wp-content/uploads/2024/06/IDTA-01001-3-0-1_SpecificationAssetAdministrationShell_Part1_Metamodel.pdf) | +| Part 1: Metamodel | [v3.0.1 (01001)](https://industrialdigitaltwin.org/wp-content/uploads/2024/06/IDTA-01001-3-0-1_SpecificationAssetAdministrationShell_Part1_Metamodel.pdf) | | Schemata (JSONSchema, XSD) | [v3.0.8 (IDTA-01001-3-0-1_schemasV3.0.8)](https://github.com/admin-shell-io/aas-specs/releases/tag/IDTA-01001-3-0-1_schemasV3.0.8) | -| Part 2: API | [v3.0 (01002-3-0)](https://industrialdigitaltwin.org/en/wp-content/uploads/sites/2/2023/06/IDTA-01002-3-0_SpecificationAssetAdministrationShell_Part2_API_.pdf) | +| Part 2: API | [v3.1.1 (01002)](https://industrialdigitaltwin.org/en/wp-content/uploads/sites/2/2025/08/IDTA-01002-3-1-1_AAS-Specification_Part2_API.pdf) | | Part 3a: Data Specification IEC 61360 | [v3.1.1 (01003-a)](https://industrialdigitaltwin.org/wp-content/uploads/2025/08/IDTA-01003-a-3-1-1_AAS-Specification_Part3a_DataSpecification.pdf) | | Part 5: Package File Format (AASX) | [v3.1 (01005)](https://industrialdigitaltwin.org/wp-content/uploads/2025/06/IDTA_01005-25-01_AAS-Specification_Part5_AASXPackageFileFormat.pdf) | + If you need support to an older version of the specifications, please refer to our [prior releases](https://github.com/eclipse-basyx/basyx-python-sdk/releases). Each of them has a similar table at the top of the release notes. diff --git a/server/README.md b/server/README.md index f96365d4..f2da379a 100644 --- a/server/README.md +++ b/server/README.md @@ -99,7 +99,7 @@ The server can also be run directly on the host system without Docker, NGINX and $ python -m app.interfaces.repository ``` -The server can be accessed at http://localhost:8080/api/v3.0/ from your host system. +The server can be accessed at http://localhost:8080/api/v3.1/ from your host system. ## Currently Unimplemented Several features and routes are currently not supported: @@ -136,9 +136,9 @@ This Dockerfile is inspired by the [tiangolo/uwsgi-nginx-docker][10] repository. [1]: https://github.com/eclipse-basyx/basyx-python-sdk/pull/238 [2]: https://basyx-python-sdk.readthedocs.io/en/latest/backend/local_file.html [3]: https://github.com/eclipse-basyx/basyx-python-sdk -[4]: https://app.swaggerhub.com/apis/Plattform_i40/AssetAdministrationShellRepositoryServiceSpecification/V3.0.1_SSP-001 -[5]: https://app.swaggerhub.com/apis/Plattform_i40/SubmodelRepositoryServiceSpecification/V3.0.1_SSP-001 -[6]: https://industrialdigitaltwin.io/aas-specifications/IDTA-01002/v3.0/index.html +[4]: https://app.swaggerhub.com/apis/Plattform_i40/AssetAdministrationShellRepositoryServiceSpecification/V3.1.1_SSP-001 +[5]: https://app.swaggerhub.com/apis/Plattform_i40/SubmodelRepositoryServiceSpecification/V3.1.1_SSP-001 +[6]: https://industrialdigitaltwin.io/aas-specifications/IDTA-01002/v3.1.2/index.html [7]: https://basyx-python-sdk.readthedocs.io/en/latest/adapter/aasx.html#adapter-aasx [8]: https://basyx-python-sdk.readthedocs.io/en/latest/adapter/json.html [9]: https://basyx-python-sdk.readthedocs.io/en/latest/adapter/xml.html diff --git a/server/app/interfaces/_string_constraints.py b/server/app/interfaces/_string_constraints.py index 65a35fe0..8489f4a0 100644 --- a/server/app/interfaces/_string_constraints.py +++ b/server/app/interfaces/_string_constraints.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 the Eclipse BaSyx Authors +# 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. diff --git a/server/app/interfaces/base.py b/server/app/interfaces/base.py index 1c1c77de..a4265997 100644 --- a/server/app/interfaces/base.py +++ b/server/app/interfaces/base.py @@ -42,6 +42,70 @@ T = TypeVar("T") +class ServiceSpecificationProfileEnum(str, enum.Enum): + """ + Enumeration of all standardized Service Specification Profiles + from the AAS Part 2 API Specification (IDTA-01002-3-1). + Each profile is uniquely identified by its semantic URI. + """ + + # --- Asset Administration Shell (AAS) --- + AAS_FULL = "https://admin-shell.io/aas/API/3/1/AssetAdministrationShellServiceSpecification/SSP-001" + AAS_READ = "https://admin-shell.io/aas/API/3/1/AssetAdministrationShellServiceSpecification/SSP-002" + + # --- Submodel --- + SUBMODEL_FULL = "https://admin-shell.io/aas/API/3/1/SubmodelServiceSpecification/SSP-001" + SUBMODEL_VALUE = "https://admin-shell.io/aas/API/3/1/SubmodelServiceSpecification/SSP-002" + SUBMODEL_READ = "https://admin-shell.io/aas/API/3/1/SubmodelServiceSpecification/SSP-003" + + # --- AASX File Server --- + AASX_FILESERVER_FULL = "https://admin-shell.io/aas/API/3/1/AasxFileServerServiceSpecification/SSP-001" + + # --- AAS Registry --- + AAS_REGISTRY_FULL = \ + "https://admin-shell.io/aas/API/3/1/AssetAdministrationShellRegistryServiceSpecification/SSP-001" + AAS_REGISTRY_READ = \ + "https://admin-shell.io/aas/API/3/1/AssetAdministrationShellRegistryServiceSpecification/SSP-002" + AAS_REGISTRY_BULK = \ + "https://admin-shell.io/aas/API/3/1/AssetAdministrationShellRegistryServiceSpecification/SSP-003" + + # --- Submodel Registry --- + SUBMODEL_REGISTRY_FULL = "https://admin-shell.io/aas/API/3/1/SubmodelRegistryServiceSpecification/SSP-001" + SUBMODEL_REGISTRY_READ = "https://admin-shell.io/aas/API/3/1/SubmodelRegistryServiceSpecification/SSP-002" + SUBMODEL_REGISTRY_BULK = "https://admin-shell.io/aas/API/3/1/SubmodelRegistryServiceSpecification/SSP-003" + + # --- AAS Repository --- + AAS_REPOSITORY_FULL = \ + "https://admin-shell.io/aas/API/3/1/AssetAdministrationShellRepositoryServiceSpecification/SSP-001" + AAS_REPOSITORY_READ = \ + "https://admin-shell.io/aas/API/3/1/AssetAdministrationShellRepositoryServiceSpecification/SSP-002" + AAS_REPOSITORY_BULK = \ + "https://admin-shell.io/aas/API/3/1/AssetAdministrationShellRepositoryServiceSpecification/SSP-003" + + # --- Submodel Repository --- + SUBMODEL_REPOSITORY_FULL = "https://admin-shell.io/aas/API/3/1/SubmodelRepositoryServiceSpecification/SSP-001" + SUBMODEL_REPOSITORY_READ = "https://admin-shell.io/aas/API/3/1/SubmodelRepositoryServiceSpecification/SSP-002" + SUBMODEL_REPOSITORY_BULK = "https://admin-shell.io/aas/API/3/1/SubmodelRepositoryServiceSpecification/SSP-003" + + # --- Concept Description Repository --- + CONCEPT_DESCRIPTION_REPOSITORY_FULL = \ + "https://admin-shell.io/aas/API/3/1/ConceptDescriptionRepositoryServiceSpecification/SSP-001" + CONCEPT_DESCRIPTION_REPOSITORY_READ = \ + "https://admin-shell.io/aas/API/3/1/ConceptDescriptionRepositoryServiceSpecification/SSP-002" + CONCEPT_DESCRIPTION_REPOSITORY_BULK = \ + "https://admin-shell.io/aas/API/3/1/ConceptDescriptionRepositoryServiceSpecification/SSP-003" + + # --- Discovery --- + DISCOVERY_FULL = "https://admin-shell.io/aas/API/3/1/DiscoveryServiceSpecification/SSP-001" + DISCOVERY_READ = "https://admin-shell.io/aas/API/3/1/DiscoveryServiceSpecification/SSP-002" + + +# TODO: Maybe remove this in spite of spec? Too complicated structure +class ServiceDescription: + def __init__(self, profiles: List[ServiceSpecificationProfileEnum]): + self.profiles: List[ServiceSpecificationProfileEnum] = profiles + + @enum.unique class MessageType(enum.Enum): UNDEFINED = enum.auto() @@ -118,7 +182,7 @@ def __init__(self, *args, content_type="application/xml", **kwargs): def serialize(self, obj: ResponseData, cursor: Optional[int], stripped: bool) -> str: root_elem = etree.Element("response", nsmap=XML_NS_MAP) - if cursor is not None: + if cursor is not None or not (isinstance(obj, list) and not obj): root_elem.set("cursor", str(cursor)) if isinstance(obj, Result): result_elem = self.result_to_xml(obj, **XML_NS_MAP) @@ -210,7 +274,7 @@ def __call__(self, environ, start_response) -> Iterable[bytes]: return response(environ, start_response) @classmethod - def _get_slice(cls, request: Request, iterator: Iterable[T]) -> Tuple[Iterator[T], int]: + def _get_slice(cls, request: Request, iterator: Iterable[T]) -> Tuple[Iterator[T], Optional[int]]: limit_str = request.args.get("limit", default="10") cursor_str = request.args.get("cursor", default="1") try: @@ -220,8 +284,11 @@ def _get_slice(cls, request: Request, iterator: Iterable[T]) -> Tuple[Iterator[T raise BadRequest("Limit can not be negative, cursor must be positive!") start_index = cursor end_index = cursor + limit - paginated_slice = itertools.islice(iterator, start_index, end_index) - return paginated_slice, end_index + items = list(itertools.islice(iterator, start_index, end_index + 1)) + has_more = len(items) > limit + paginated_slice = iter(items[:limit]) + next_cursor = cursor + limit if has_more else None + return paginated_slice, next_cursor def handle_request(self, request: Request): map_adapter: MapAdapter = self.url_map.bind_to_environ(request.environ) diff --git a/server/app/interfaces/registry.py b/server/app/interfaces/registry.py index 437f9f10..09f262d3 100644 --- a/server/app/interfaces/registry.py +++ b/server/app/interfaces/registry.py @@ -4,7 +4,7 @@ – Application Programming Interface'. """ -from typing import Dict, Iterator, Tuple, Type +from typing import Dict, Iterator, Tuple, Type, Optional import werkzeug.exceptions import werkzeug.routing @@ -108,7 +108,7 @@ def __init__(self, object_store: model.AbstractObjectStore, base_path: str = "/a def _get_all_aas_descriptors( self, request: "Request" - ) -> Tuple[Iterator[server_model.AssetAdministrationShellDescriptor], int]: + ) -> Tuple[Iterator[server_model.AssetAdministrationShellDescriptor], Optional[int]]: descriptors: Iterator[server_model.AssetAdministrationShellDescriptor] = self._get_all_obj_of_type( server_model.AssetAdministrationShellDescriptor @@ -140,7 +140,9 @@ def _get_all_aas_descriptors( def _get_aas_descriptor(self, url_args: Dict) -> server_model.AssetAdministrationShellDescriptor: return self._get_obj_ts(url_args["aas_id"], server_model.AssetAdministrationShellDescriptor) - def _get_all_submodel_descriptors(self, request: Request) -> Tuple[Iterator[server_model.SubmodelDescriptor], int]: + def _get_all_submodel_descriptors(self, request: Request) -> Tuple[ + Iterator[server_model.SubmodelDescriptor], Optional[int] + ]: submodel_descriptors: Iterator[server_model.SubmodelDescriptor] = self._get_all_obj_of_type( server_model.SubmodelDescriptor ) diff --git a/server/app/interfaces/repository.py b/server/app/interfaces/repository.py index 0de3e8aa..15a5213d 100644 --- a/server/app/interfaces/repository.py +++ b/server/app/interfaces/repository.py @@ -24,6 +24,13 @@ from app.interfaces.base import APIResponse, HTTPApiDecoder, ObjectStoreWSGIApp, T, is_stripped_request from app.util.converters import IdentifierToBase64URLConverter, IdShortPathConverter, base64url_decode +from .base import (ObjectStoreWSGIApp, APIResponse, is_stripped_request, HTTPApiDecoder, T, + ServiceSpecificationProfileEnum, ServiceDescription) + +SUPPORTED_PROFILES: ServiceDescription = ServiceDescription([ + ServiceSpecificationProfileEnum.AAS_REPOSITORY_FULL, + ServiceSpecificationProfileEnum.SUBMODEL_REPOSITORY_FULL, +]) class WSGIApp(ObjectStoreWSGIApp): @@ -31,7 +38,7 @@ def __init__( self, object_store: model.AbstractObjectStore, file_store: aasx.AbstractSupplementaryFileContainer, - base_path: str = "/api/v3.0", + base_path: str = "/api/v3.1", ): self.object_store: model.AbstractObjectStore = object_store self.file_store: aasx.AbstractSupplementaryFileContainer = file_store @@ -41,7 +48,7 @@ def __init__( base_path, [ Rule("/serialization", methods=["GET"], endpoint=self.not_implemented), - Rule("/description", methods=["GET"], endpoint=self.not_implemented), + 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), Submount( @@ -421,7 +428,7 @@ def _get_submodel_reference( return ref raise NotFound(f"The AAS {aas!r} doesn't have a submodel reference to {submodel_id!r}!") - def _get_shells(self, request: Request) -> Tuple[Iterator[model.AssetAdministrationShell], int]: + def _get_shells(self, request: Request) -> Tuple[Iterator[model.AssetAdministrationShell], Optional[int]]: aas: Iterator[model.AssetAdministrationShell] = self._get_all_obj_of_type(model.AssetAdministrationShell) id_short = request.args.get("idShort") @@ -470,7 +477,7 @@ def _get_shells(self, request: Request) -> Tuple[Iterator[model.AssetAdministrat 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], int]: + def _get_submodels(self, request: Request) -> Tuple[Iterator[model.Submodel], Optional[int]]: submodels: Iterator[model.Submodel] = self._get_all_obj_of_type(model.Submodel) id_short = request.args.get("idShort") if id_short is not None: @@ -489,7 +496,7 @@ def _get_submodel(self, url_args: Dict) -> model.Submodel: def _get_submodel_submodel_elements( self, request: Request, url_args: Dict - ) -> Tuple[Iterator[model.SubmodelElement], int]: + ) -> Tuple[Iterator[model.SubmodelElement], Optional[int]]: submodel = self._get_submodel(url_args) paginated_submodel_elements: Iterator[model.SubmodelElement] paginated_submodel_elements, end_index = self._get_slice(request, submodel.submodel_element) @@ -507,6 +514,13 @@ def _get_concept_description(self, url_args): def not_implemented(self, request: Request, url_args: Dict, **_kwargs) -> Response: raise werkzeug.exceptions.NotImplemented("This route is not implemented!") + def get_description(self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs) -> Response: + profiles = [] + for profile in SUPPORTED_PROFILES.profiles: + profiles.append(profile.value) + description = {"profiles": profiles} + return response_t(description) + # ------ AAS REPO ROUTES ------- def get_aas_all(self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs) -> Response: aashells, cursor = self._get_shells(request) @@ -570,18 +584,22 @@ def get_aas_submodel_refs( ) -> Response: aas = self._get_shell(url_args) submodel_refs: Iterator[model.ModelReference[model.Submodel]] - submodel_refs, cursor = self._get_slice(request, aas.submodel) + sorted_submodel_refs = sorted(aas.submodel, key=lambda ref: ref.key[0].value) + submodel_refs, cursor = self._get_slice(request, sorted_submodel_refs) return response_t(list(submodel_refs), cursor=cursor) - def post_aas_submodel_refs( - self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs - ) -> Response: + def post_aas_submodel_refs(self, request: Request, url_args: Dict, response_t: Type[APIResponse], + map_adapter: MapAdapter, **_kwargs) -> Response: aas = self._get_shell(url_args) sm_ref = HTTPApiDecoder.request_body(request, model.ModelReference, False) if sm_ref in aas.submodel: raise Conflict(f"{sm_ref!r} already exists!") aas.submodel.add(sm_ref) - return response_t(sm_ref, status=201) + created_resource_url = map_adapter.build(self.delete_aas_submodel_refs_specific, { + "aas_id": aas.id, + "submodel_id": sm_ref.key[0].value + }, force_external=True) + return response_t(sm_ref, status=201, headers={"Location": created_resource_url}) def delete_aas_submodel_refs_specific( self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs @@ -647,9 +665,10 @@ 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: + def get_submodel_all_metadata(self, request: Request, url_args: Dict, response_t: Type[APIResponse], + **_kwargs) -> Response: + if "level" in request.args: + raise BadRequest(f"level cannot be used when retrieving metadata!") submodels, cursor = self._get_submodels(request) return response_t(list(submodels), cursor=cursor, stripped=True) @@ -672,9 +691,10 @@ def get_submodel(self, request: Request, url_args: Dict, response_t: Type[APIRes 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: + def get_submodels_metadata(self, request: Request, url_args: Dict, response_t: Type[APIResponse], + **_kwargs) -> Response: + if "level" in request.args: + raise BadRequest(f"level cannot be used when retrieving metadata!") submodel = self._get_submodel(url_args) return response_t(submodel, stripped=True) @@ -696,9 +716,10 @@ def get_submodel_submodel_elements( submodel_elements, cursor = self._get_submodel_submodel_elements(request, url_args) return response_t(list(submodel_elements), cursor=cursor, stripped=is_stripped_request(request)) - def get_submodel_submodel_elements_metadata( - self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs - ) -> Response: + 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(f"level cannot be used when retrieving metadata!") submodel_elements, cursor = self._get_submodel_submodel_elements(request, url_args) return response_t(list(submodel_elements), cursor=cursor, stripped=True) @@ -717,9 +738,10 @@ def get_submodel_submodel_elements_id_short_path( 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: + 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(f"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!")