Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |

Expand Down
16 changes: 8 additions & 8 deletions server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
```
Expand All @@ -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`.
Expand All @@ -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

Expand All @@ -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
Expand Down
74 changes: 70 additions & 4 deletions server/app/interfaces/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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)
Expand Down
57 changes: 44 additions & 13 deletions server/app/interfaces/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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.
Expand Down Expand Up @@ -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", [
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)

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

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

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