diff --git a/README.md b/README.md index 82e56055..2051bf8e 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,9 @@ These are the currently implemented specifications: | 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-3-0-1)](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-3-1-1)](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.0 (01003-a-3-0)](https://industrialdigitaltwin.org/wp-content/uploads/2023/04/IDTA-01003-a-3-0_SpecificationAssetAdministrationShell_Part3a_DataSpecification_IEC61360.pdf) | | Part 5: Package File Format (AASX) | [v3.0 (01005-3-0)](https://industrialdigitaltwin.org/wp-content/uploads/2023/04/IDTA-01005-3-0_SpecificationAssetAdministrationShell_Part5_AASXPackageFileFormat.pdf) | diff --git a/server/README.md b/server/README.md index 979771cf..58107a91 100644 --- a/server/README.md +++ b/server/README.md @@ -40,7 +40,7 @@ To expose it on the host on port 8080, use the option `-p 8080:80` when running The container can be configured via environment variables: - `API_BASE_PATH` determines the base path under which all other API paths are made available. - Default: `/api/v3.0` + Default: `/api/v3.1` - `STORAGE_TYPE` can be one of `LOCAL_FILE_READ_ONLY` or `LOCAL_FILE_BACKEND`: - When set to `LOCAL_FILE_READ_ONLY` (the default), the server will read and serve AASX, JSON, XML files from the storage directory. The files are not modified, all changes done via the API are only stored in memory. @@ -60,7 +60,7 @@ Since Windows uses backslashes instead of forward slashes in paths, you'll have > docker run -p 8080:80 -v .\storage:/storage basyx-python-server ``` -Per default, the server will use the `LOCAL_FILE_READ_ONLY` storage type and serve the API under `/api/v3.0` and read files from `/storage`. If you want to change this, you can do so like this: +Per default, the server will use the `LOCAL_FILE_READ_ONLY` storage type and serve the API under `/api/v3.1` and read files from `/storage`. If you want to change this, you can do so like this: ``` $ docker run -p 8080:80 -v ./storage2:/storage2 -e API_BASE_PATH=/api/v3.1 -e STORAGE_TYPE=LOCAL_FILE_BACKEND -e STORAGE_PATH=/storage2 basyx-python-server ``` @@ -85,7 +85,7 @@ services: - ./storage:/storage ``` -Here files are read from `/storage` and the server can be accessed at http://localhost:8080/api/v3.0/ from your host system. +Here files are read from `/storage` and the server can be accessed at http://localhost:8080/api/v3.1/ from your host system. To get a different setup this compose.yaml file can be adapted and expanded. Note that the `Dockerfile` has to be specified explicitly, as the build context must be set to the parent directory of `/server` to allow access to the local `/sdk`. @@ -103,12 +103,12 @@ The server can also be run directly on the host system without Docker, NGINX and $ pip install ./app ``` -2. Run the server by executing the main function in [`./app/interfaces/repository.py`](./app/interfaces/repository.py) from within the current folder. +2. Run the server by executing the main function in [`./app/interfaces/repository.py`](./app/interfaces/repository.py) from within the `app` folder. ```bash - $ python -m app.interfaces.repository + $ python -m 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. ## Acknowledgments @@ -117,8 +117,8 @@ 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 +[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.org/content-hub/aasspecifications/idta_01002-3-0_application_programming_interfaces [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 diff --git a/server/app/interfaces/base.py b/server/app/interfaces/base.py index 686bb92c..b861990f 100644 --- a/server/app/interfaces/base.py +++ b/server/app/interfaces/base.py @@ -31,6 +31,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() @@ -104,7 +168,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) @@ -163,7 +227,6 @@ class ResultToJsonEncoder(AASToJsonEncoder): @classmethod def _result_to_json(cls, result: Result) -> Dict[str, object]: return { - "success": result.success, "messages": result.messages } @@ -210,8 +273,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/repository.py b/server/app/interfaces/repository.py index 03a3974c..26e695c0 100644 --- a/server/app/interfaces/repository.py +++ b/server/app/interfaces/repository.py @@ -3,7 +3,7 @@ # 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 +# SPDX-License-Identifier: MITd """ This module implements the "Specification of the Asset Administration Shell Part 2 Application Programming Interfaces". However, several features and routes are currently not supported: @@ -15,10 +15,9 @@ 3. Route `/shells/{aasIdentifier}/asset-information/thumbnail`: Not implemented because the specification lacks clarity. -4. Serialization and Description Routes: +4. Serialization Route: - `/serialization` - - `/description` - These routes are not implemented at this time. + This route is not implemented at this time. 5. Value, Path, and PATCH Routes: - All `/…/value$`, `/…/path$`, and `PATCH` routes are currently not implemented. @@ -49,18 +48,24 @@ from basyx.aas import model from basyx.aas.adapter import aasx from util.converters import IdentifierToBase64URLConverter, IdShortPathConverter, base64url_decode -from .base import ObjectStoreWSGIApp, APIResponse, is_stripped_request, HTTPApiDecoder, T +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): 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 self.url_map = werkzeug.routing.Map([ Submount(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("/shells", [ @@ -200,9 +205,15 @@ def _get_obj_ts(self, identifier: model.Identifier, type_: Type[model.provider._ return identifiable def _get_all_obj_of_type(self, type_: Type[model.provider._IT]) -> Iterator[model.provider._IT]: - for obj in self.object_store: - if isinstance(obj, type_): - yield obj + matching_identifiables = [] + for identifiable in self.object_store: + if isinstance(identifiable, type_): + matching_identifiables.append(identifiable) + + sorted_identifiables = sorted(matching_identifiables, key=lambda identifiable: identifiable.id) + + for identifiable in sorted_identifiables: + yield identifiable def _resolve_reference(self, reference: model.ModelReference[model.base._RT]) -> model.base._RT: try: @@ -341,6 +352,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) @@ -401,17 +419,22 @@ def get_aas_submodel_refs(self, request: Request, url_args: Dict, response_t: Ty **_kwargs) -> 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: + 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) -> Response: @@ -476,6 +499,8 @@ def post_submodel(self, request: Request, url_args: Dict, response_t: Type[APIRe 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) @@ -498,6 +523,8 @@ def get_submodel(self, request: Request, url_args: Dict, response_t: Type[APIRes 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) @@ -519,6 +546,8 @@ def get_submodel_submodel_elements(self, request: Request, url_args: Dict, respo 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) @@ -537,6 +566,8 @@ def get_submodel_submodel_elements_id_short_path(self, request: Request, url_arg 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!")