Skip to content
Merged
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
8 changes: 4 additions & 4 deletions server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion server/app/interfaces/_string_constraints.py
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
75 changes: 71 additions & 4 deletions server/app/interfaces/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand Down
8 changes: 5 additions & 3 deletions server/app/interfaces/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
)
Expand Down
66 changes: 44 additions & 22 deletions server/app/interfaces/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,21 @@

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):
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
Expand All @@ -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(
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand All @@ -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)

Expand All @@ -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)

Expand All @@ -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!")
Expand Down
Loading