diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 49d4c20..c9e1c7d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,7 +6,7 @@ env: X_PYTHON_VERSION: "3.10" jobs: - test: + sdk-test: runs-on: ubuntu-latest strategy: matrix: @@ -26,7 +26,7 @@ jobs: pip install coverage cd ./sdk pip install . - - name: Test with coverage + unittest + - name: Test sdk with coverage + unittest run: | cd ./sdk coverage run -m unittest @@ -36,6 +36,39 @@ jobs: cd ./sdk coverage report -m + api-test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.8", "3.12"] + + + + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install Python dependencies + run: | + python -m pip install --upgrade pip + pip install coverage + cd ./sdk + pip install . + cd .. + cd ./api + pip install .[dev] + - name: Test sdk with coverage + unittest + run: | + cd ./api + coverage run -m unittest + - name: Report test coverage + if: ${{ always() }} + run: | + cd ./api + coverage report -m + static-analysis: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 09768ea..e7fe581 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ /venv/ /sdk/venv sdk/basyx_python_framework.egg-info/ +api/basyx_server.egg-info/ # IDE settings /.idea/ diff --git a/api/DESIGN_NOTES.md b/api/DESIGN_NOTES.md new file mode 100644 index 0000000..4777a54 --- /dev/null +++ b/api/DESIGN_NOTES.md @@ -0,0 +1,26 @@ +# Server Design Notes + +> [!warning] +> This concept is heavily WIP! Features presented here might not be implemented + +## General ideas +The server is implemented using `FastAPI`. +The project is divided into distinct modules (inspired +[by the server specification](https://industrialdigitaltwin.org/en/wp-content/uploads/sites/2/2024/10/IDTA-01002-3-0-3_SpecificationAssetAdministrationShell_Part2_API.pdf#page=128) +) to enhance maintainability and readability. + +### API Classes + +The `api` module contains classes that define the endpoints exposed by the server. As little logic as possible is implemented here. + +### Service Classes +The `services` module contains all the necessary logic to enable the actions requested by the endpoints defined in `api`. + +### Shared Data +With this structure all the routers and services are standalone and do not access each other in any way. As endpoints +need to maintain context across service specifications, we use a central `ObjectStore` instance to handle +objects present during runtime. + +### server.py +The server.py contains the main class. Here we construct our `ObjectStore` instance and with that the routers. +As every router is standalone and adds onto the current set of endpoints, we can select which specifications we want to add on startup. \ No newline at end of file diff --git a/api/README.md b/api/README.md new file mode 100644 index 0000000..19940b2 --- /dev/null +++ b/api/README.md @@ -0,0 +1,144 @@ +# Server Development Status + +> [!warning] +> This README tracks the development progress of the server's endpoints. Endpoints are sorted based on the +> specification's services (Submodel, AASX File Server, ...). + +- **Implemented (✅)**: Currently available, but **not necessarily fully functional**. +- **Planned (📅)**: Scheduled for future implementation. +- **Not Planned (❌)**: Currently not scheduled for implementation. + +> [!warning] +> The project is WIP and endpoints might be declared as 'Implemented' whilst still having issues. + +> Operation Parameters (e.g. level, content, extent) are generally not supported at the moment. + + +Below is the status table for the endpoints, organized as specified. Content parameters (/$reference, /$metadata, etc.) +will be implemented as separate routes, but are not listed in this table as it's a simple suffix and does only affect +serialization settings. + +## AAS Service +| Endpoint | Operation | Description | Status | +|------------------------------------------------------------|-----------|-------------------------------------------------------------------------|--------| +| `/aas/shells` | GET | Returns all Asset Administration Shells | ✅ | +| `/aas/shells` | POST | Creates a new Asset Administration Shell | ✅ | +| `/aas/shells/{aas_identifier}` | GET | Returns an Asset Administration Shell by ID | ✅ | +| `/aas/shells/{aas_identifier}` | PUT | Updates an existing Asset Administration Shell | ✅ | +| `/aas/shells/{aas_identifier}` | DELETE | Deletes an Asset Administration Shell | ✅ | +| `/aas/shells/{aas_identifier}/asset-information` | GET | Returns the Asset Information of a specific Asset Administration Shell | ✅ | +| `/aas/shells/{aas_identifier}/asset-information` | PUT | Replaces the Asset Information of a specific Asset Administration Shell | ✅ | +| `/aas/shells/{aas_identifier}/asset-information/thumbnail` | GET | Returns the thumbnail file of the Asset Information | ✅ | +| `/aas/shells/{aas_identifier}/asset-information/thumbnail` | PUT | Replaces the thumbnail file of the Asset Information | ✅ | +| `/aas/shells/{aas_identifier}/asset-information/thumbnail` | DELETE | Deletes the thumbnail file of the Asset Information | ✅ | + + +## Submodel Service +| Endpoint | Operation | Description | Status | +|--------------------------------------------------------------------------------------|-----------|--------------------------------------------------------------------------|--------| +| `/submodels` | GET | Retrieve all submodels | ✅ | +| `/submodels` | POST | Create a new submodel | ✅ | +| `/submodels/{submodel_id}` | GET | Retrieve a submodel by ID | ✅ | +| `/submodels/{submodel_id}` | PUT | Replace a submodel by ID | ✅ | +| `/submodels/{submodel_id}` | PATCH | Update a submodel by ID | 📅 | +| `/submodels/{submodel_id}` | DELETE | Delete a submodel by ID | ✅ | +| `/submodels/{submodel_id}/submodel-elements` | GET | Retrieve all elements of a specific submodel | ✅ | +| `/submodels/{submodel_id}/submodel-elements` | POST | Create new elements in a specific submodel | 📅 | +| `/submodels/{submodel_id}/submodel-elements/{id_shorts}` | GET | Retrieve specific elements by short ID in a submodel | ✅ | +| `/submodels/{submodel_id}/submodel-elements/{id_shorts}` | POST | Create specific elements by short ID in a submodel | ✅ | +| `/submodels/{submodel_id}/submodel-elements/{id_shorts}` | PUT | Update specific elements by short ID in a submodel | ✅ | +| `/submodels/{submodel_id}/submodel-elements/{id_shorts}` | DELETE | Delete specific elements by short ID in a submodel | ✅ | +| `/submodels/{submodel_id}/submodel-elements/{id_shorts}` | PATCH | Partially update specific elements by short ID in a submodel | ❌ | +| `/submodels/{submodel_id}/submodel-elements/{id_shorts}/attachment` | GET | Retrieve attachments of specific elements by short ID | 📅 | +| `/submodels/{submodel_id}/submodel-elements/{id_shorts}/attachment` | PUT | Update attachments of specific elements by short ID | 📅 | +| `/submodels/{submodel_id}/submodel-elements/{id_shorts}/attachment` | DELETE | Delete attachments of specific elements by short ID | 📅 | +| `/submodels/{submodel_id}/submodel-elements/{id_shorts}/invoke` | POST | Invoke operations on specific elements by short ID | ❌ | +| `/submodels/{submodel_id}/submodel-elements/{id_shorts}/invoke-async` | POST | Asynchronously invoke operations on specific elements by short ID | ❌ | +| `/submodels/{submodel_id}/submodel-elements/{id_shorts}/qualifiers` | GET | Retrieve qualifiers for specific elements by short ID | 📅 | +| `/submodels/{submodel_id}/submodel-elements/{id_shorts}/qualifiers` | POST | Add qualifiers to specific elements by short ID | 📅 | +| `/submodels/{submodel_id}/submodel-elements/{id_shorts}/qualifiers/{qualifier_type}` | GET | Retrieve qualifiers of a specific type for specific elements by short ID | 📅 | +| `/submodels/{submodel_id}/submodel-elements/{id_shorts}/qualifiers/{qualifier_type}` | PUT | Update qualifiers of a specific type for specific elements by short ID | 📅 | +| `/submodels/{submodel_id}/submodel-elements/{id_shorts}/qualifiers/{qualifier_type}` | DELETE | Delete qualifiers of a specific type for specific elements by short ID | 📅 | + +## AASX File Server Interface and Operations +| Endpoint | Operation | Description | Status | +|---------------------------|-----------|----------------------------------------------------|--------| +| `/aasx` | GET | Returns all available AASX packages at the server. | ✅ | +| `/aasx/{aasx_package_id}` | GET | Returns a specific AASX package from the server. | ✅ | +| `/aasx` | POST | Creates an AASX package at the server. | ✅ | +| `/aasx` | PUT | Replaces the AASX package at the server. | ✅ | +| `/aasx/{aasx_package_id}` | DELETE | Deletes a specific AASX package at the server. | ✅ | + +## AAS Registry Service +| Endpoint | Operation | Description | Status | +|--------------------------------------|-----------|----------------------------------------------------|--------| +| `/registry/aas-descriptors` | GET | Returns all Asset Administration Shell Descriptors | ✅ | +| `/registry/aas-descriptors/{aas_id}` | GET | Returns an AAS Descriptor by ID | ✅ | +| `/registry/aas-descriptors` | POST | Creates an AAS Descriptor | ✅ | +| `/registry/aas-descriptors/{aas_id}` | PUT | Updates an AAS Descriptor | ✅ | +| `/registry/aas-descriptors/{aas_id}` | DELETE | Deletes an AAS Descriptor | ✅ | + +## Submodel Registry Service +| Endpoint | Operation | Description | Status | +|------------------------------------------------|-----------|-------------------------------------|--------| +| `/registry/submodel-descriptors` | GET | Returns all Submodel Descriptors | ✅ | +| `/registry/submodel-descriptors/{submodel_id}` | GET | Returns a Submodel Descriptor by ID | ✅ | +| `/registry/submodel-descriptors` | POST | Creates a Submodel Descriptor | ✅ | +| `/registry/submodel-descriptors/{submodel_id}` | PUT | Updates a Submodel Descriptor | ✅ | +| `/registry/submodel-descriptors/{submodel_id}` | DELETE | Deletes a Submodel Descriptor | ✅ | + +## Discovery Service +| Endpoint | Operation | Description | Status | +|-----------------------------------|-----------|--------------------------------------|--------| +| `/discovery/aas-ids` | GET | Returns all AAS IDs by an Asset Link | 📅 | +| `/discovery/asset-links/{aas_id}` | GET | Returns all Asset Links by an AAS ID | 📅 | +| `/discovery/asset-links/{aas_id}` | POST | Posts new Asset Links | 📅 | +| `/discovery/asset-links/{aas_id}` | DELETE | Deletes Asset Links | 📅 | + +## AAS Repository Service +| Endpoint | Operation | Description | Status | +|--------------------------------------------|-----------|---------------------------------------------|--------| +| `/shells` | GET | Returns all Asset Administration Shells | 📅 | +| `/shells/{aas_id}` | GET | Returns an Asset Administration Shell by ID | 📅 | +| `/shells/{aas_id}` | PUT | Updates an Asset Administration Shell by ID | 📅 | +| `/shells/{aas_id}` | DELETE | Deletes an Asset Administration Shell by ID | 📅 | +| `/shells/{aas_id}/asset-information` | GET | Returns Asset Information for an AAS | 📅 | +| `/shells/{aas_id}/submodels` | GET | Returns all Submodels for an AAS | 📅 | +| `/shells/{aas_id}/submodels/{submodel_id}` | GET | Returns a Submodel by ID for an AAS | 📅 | + +## Submodel Repository Service +| Endpoint | Operation | Description | Status | +|----------------------------|-----------|--------------------------|--------| +| `/submodels` | GET | Returns all Submodels | 📅 | +| `/submodels/{submodel_id}` | GET | Returns a Submodel by ID | 📅 | +| `/submodels/{submodel_id}` | PUT | Updates a Submodel by ID | 📅 | +| `/submodels/{submodel_id}` | DELETE | Deletes a Submodel by ID | 📅 | + +## ConceptDescription Repository Service +| Endpoint | Operation | Description | Status | +|--------------------------------------|-----------|-------------------------------------|--------| +| `/concept-descriptions` | GET | Returns all Concept Descriptions | 📅 | +| `/concept-descriptions/{concept_id}` | GET | Returns a Concept Description by ID | 📅 | +| `/concept-descriptions/{concept_id}` | POST | Creates a new Concept Description | 📅 | +| `/concept-descriptions/{concept_id}` | PUT | Updates a Concept Description by ID | 📅 | +| `/concept-descriptions/{concept_id}` | DELETE | Deletes a Concept Description by ID | 📅 | + + +## SerializationModifiers +### Level +| Value | Status | +|-------|--------| +| Deep | ❌ | +| Core | ❌ | +### Content +| Value | Status | +|-----------|--------| +| Normal | ❌ | +| Reference | ❌ | +| Value | ❌ | +| Path | ❌ | +### Extent +| Value | Status | +|------------------|--------| +| WithoutBLOBValue | ❌ | +| WithBLOBValue | ❌ | \ No newline at end of file diff --git a/api/pyproject.toml b/api/pyproject.toml new file mode 100644 index 0000000..c9e3f0f --- /dev/null +++ b/api/pyproject.toml @@ -0,0 +1,26 @@ +[build-system] +requires = ["setuptools >= 61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "basyx-server" # TODO: name is tbd +version = "0.1" +dependencies = ["aas-core3.0","fastapi","basyx-python-framework-base","uvicorn"] +requires-python = ">=3.8, <3.13" +authors = [ + {name = "The Eclipse BaSyx Authors"} +] +# TODO: description is tbd +description="The Eclipse BaSyx Server does stuff" +readme = "README.md" +license = {file = "./LICENSE"} + + +[project.optional-dependencies] +dev = [ + "mypy", + "pycodestyle", + "codeblocks", + "coverage", + "httpx" +] diff --git a/api/server/__init__.py b/api/server/__init__.py new file mode 100644 index 0000000..2e14053 --- /dev/null +++ b/api/server/__init__.py @@ -0,0 +1 @@ +from .server_main import app diff --git a/api/server/routes/__init__.py b/api/server/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/server/routes/aas.py b/api/server/routes/aas.py new file mode 100644 index 0000000..445b6a0 --- /dev/null +++ b/api/server/routes/aas.py @@ -0,0 +1,68 @@ +from typing import Any + +from aas_core3.types import Identifiable +from fastapi import APIRouter, Request + +from server.services.aas_service import AasService +from server.utils.pagination import Pagination +from basyx import ObjectStore + + +class AasRouter(Pagination): + def __init__(self, global_obj_store: ObjectStore[Identifiable]): + self.router = APIRouter() + self.service = AasService(global_obj_store) + self._setup_routes() + + def _setup_routes(self): + @self.router.get("/shells") + async def get_all_aas() -> Any: + return self.service.get_all_shells_as_jsonable() + + @self.router.post("/shells") + async def create_aas(request: Request) -> Any: + body = await request.json() + return self.service.add_shell_from_body(body) + + @self.router.get("/shells/$reference") + async def get_all_aas_reference() -> Any: + return {"message": "Content parameters are not supported yet."} + + @self.router.get("/shells/{aas_identifier}") + async def get_aas_by_id(aas_identifier: str) -> Any: + return self.service.get_shell_jsonable_by_id(aas_identifier) + + @self.router.put("/shells/{aas_identifier}") + async def put_aas(aas_identifier: str, request: Request) -> Any: + # Update shell with given id + body = await request.json() + return self.service.put_shell_by_id(aas_identifier, body) + + @self.router.delete("/shells/{aas_identifier}") + async def delete_aas(aas_identifier: str) -> Any: + return self.service.delete_shell_by_id(aas_identifier) + + @self.router.get("/shells/{aas_identifier}/asset-information") + async def get_aas_reference_by_id(aas_identifier: str) -> Any: + return self.service.get_asset_information_by_id_as_jsonable(aas_identifier) + + @self.router.put("/shells/{aas_identifier}/asset-information") + async def get_aas_reference_by_id(aas_identifier: str, request: Request) -> Any: + body = await request.json() + return self.service.put_asset_information_by_id_from_jsonable(aas_identifier, body) + + @self.router.get("/shells/{aas_identifier}/asset-information/thumbnail") + async def get_aas_thumbnail_by_id(aas_identifier: str) -> Any: + return self.service.get_thumbnail_by_id(aas_identifier) + + @self.router.put("/shells/{aas_identifier}/asset-information/thumbnail") + async def get_aas_reference_by_id(aas_identifier: str, request: Request) -> Any: + body = await request.json() + return self.service.put_thumbnail_by_id(aas_identifier, body) + + @self.router.delete("/shells/{aas_identifier}/asset-information/thumbnail") + async def delete_aas(aas_identifier: str) -> Any: + return self.service.delete_thumbnail_by_id(aas_identifier) + + # TODO: Asset-information endpoints + # /shells/{aas_identifier}/$reference GET diff --git a/api/server/routes/aas_registry_server.py b/api/server/routes/aas_registry_server.py new file mode 100644 index 0000000..4b2dfa2 --- /dev/null +++ b/api/server/routes/aas_registry_server.py @@ -0,0 +1,38 @@ +from typing import Any + +from aas_core3.types import Identifiable +from fastapi import APIRouter, Request + +from server.services.aas_registry_server_service import AasRegistryServerService +from basyx import ObjectStore + + +class AasRegistryRouter: + def __init__(self, global_obj_store: ObjectStore[Identifiable]): + self.router = APIRouter() + self.obj_store = global_obj_store + self.service = AasRegistryServerService(global_obj_store) + self._setup_routes() + + def _setup_routes(self): + @self.router.get("/") + async def get_all_aas_descriptors() -> Any: + return self.service.get_all_asset_administration_shell_descriptors() + + @self.router.get("/{aas_descriptor_id}") + async def get_aas_descriptor_by_id(aas_descriptor_id: str) -> Any: + return self.service.get_asset_administration_shell_descriptor_by_id(aas_descriptor_id) + + @self.router.post("/") + async def post_aas_descriptor(request: Request) -> Any: + body = await request.json() + return self.service.post_asset_administration_shell_descriptor(body) + + @self.router.put("/") + async def put_aas_descriptor(request: Request) -> Any: + body = await request.json() + return self.service.put_asset_administration_shell_descriptor_by_id(body) + + @self.router.delete("/{aas_descriptor_id}") + async def delete_aas_descriptor_by_id(aas_descriptor_id: str) -> Any: + return self.service.delete_asset_administration_shell_descriptor_by_id(aas_descriptor_id) diff --git a/api/server/routes/aasx_file_server.py b/api/server/routes/aasx_file_server.py new file mode 100644 index 0000000..703f4bb --- /dev/null +++ b/api/server/routes/aasx_file_server.py @@ -0,0 +1,38 @@ +from typing import Any + +from aas_core3.types import Identifiable +from fastapi import APIRouter, Request + +from server.services.aasx_file_server_service import AasxFileServerService +from basyx import ObjectStore + + +class AasxFileServerRouter: + def __init__(self, global_obj_store: ObjectStore[Identifiable]): + self.router = APIRouter() + self.obj_store = global_obj_store + self.service = AasxFileServerService(global_obj_store) + self._setup_routes() + + def _setup_routes(self): + @self.router.get("") + async def get_all_aasx() -> Any: + return self.service.get_all_aasx_package_ids() + + @self.router.get("/{aasx_package_id}") + async def get_aasx_by_package_id(aasx_package_id: str) -> Any: + return self.service.get_aasx_by_package_id(aasx_package_id) + + @self.router.post("") + async def post_aasx_package(request: Request) -> Any: + body = await request.json() + return self.service.post_aasx_package(body) + + @self.router.put("") + async def put_assx_package(request: Request) -> Any: + body = await request.json() + return self.service.put_aasx_by_package_id(body) + + @self.router.delete("/{aasx_package_id}") + async def delete_aasx_package_by_id(aasx_package_id: str) -> Any: + return self.service.delete_aasx_by_package_id(aasx_package_id) diff --git a/api/server/routes/submodel.py b/api/server/routes/submodel.py new file mode 100644 index 0000000..4be004d --- /dev/null +++ b/api/server/routes/submodel.py @@ -0,0 +1,219 @@ +from typing import Any, Iterable + +from aas_core3.types import Identifiable +from fastapi import APIRouter, Request, HTTPException + +from server.services.submodel_service import SubmodelService +from basyx import ObjectStore + +from server.utils.pagination import Pagination +from server.utils.decorator import limited + + +class SubmodelRouter(Pagination): + def __init__(self, global_obj_store: ObjectStore[Identifiable]): + self.router = APIRouter() + self.obj_store = global_obj_store + self.service = SubmodelService(global_obj_store) + self._setup_routes() + + def _setup_routes(self): + # GetAllSubmodels and path-suffixes + @self.router.get("") + @limited() + async def get_submodel_all(request: Request) -> Any: + return self.service.get_all_submodels_as_jsonables() + + @self.router.get("/$metadata") + @limited() + async def get_submodel_all_metadata(request: Request) -> Any: + # Returns metadata for all submodels, stripped of detailed content + raise HTTPException(status_code=501, detail="This route is not yet implemented!") + + @self.router.get("/$reference") + @limited() + async def get_submodel_all_reference(request: Request) -> Any: + # Returns references for all submodels without full data + raise HTTPException(status_code=501, detail="This route is not yet implemented!") + + @self.router.get("/$value") + @limited() + async def not_implemented_value(request: Request) -> Any: + raise HTTPException(status_code=501, detail="This route is not implemented!") + + @self.router.get("/$path") + async def not_implemented_path() -> Any: + raise HTTPException(status_code=501, detail="This route is not implemented!") + + @self.router.post("") + async def post_submodel(request: Request) -> Any: + body = await request.json() + return self.service.add_submodel_from_body(body) + + @self.router.get("/{submodel_identifier}") + async def get_submodel(submodel_identifier: str) -> Any: + return self.service.get_submodel_jsonable_by_id(submodel_identifier) + + @self.router.put("/{submodel_identifier}") + async def put_submodel(submodel_identifier: str, request: Request) -> Any: + # Update submodel with given id + body = await request.json() + return self.service.update_submodel_by_id(submodel_identifier, body) + + @self.router.patch("/{submodel_identifier}") + async def not_implemented_patch_submodel(submodel_identifier: str) -> Any: + raise HTTPException(status_code=501, detail="This route is not implemented!") + + @self.router.delete("/{submodel_identifier}") + async def delete_submodel(submodel_identifier: str) -> Any: + return self.service.delete_submodel_by_id(submodel_identifier) + + # Nested routes for each submodel (PUT/PATCH x $metadata/$value/$reference/$path) + @self.router.get("/{submodel_identifier}/$metadata") + async def get_submodels_metadata(submodel_identifier: str) -> Any: + raise HTTPException(status_code=501, detail="This route is not yet implemented!") + + @self.router.patch("/{submodel_identifier}/$metadata") + async def not_implemented_metadata_patch(submodel_identifier: str) -> Any: + raise HTTPException(status_code=501, detail="This route is not yet implemented!") + + @self.router.get("/{submodel_identifier}/$value") + async def not_implemented_value_get(submodel_identifier: str) -> Any: + raise HTTPException(status_code=501, detail="This route is not yet implemented!") + + @self.router.patch("/{submodel_identifier}/$value") + async def not_implemented_value_patch(submodel_identifier: str) -> Any: + raise HTTPException(status_code=501, detail="This route is yet implemented!") + + @self.router.get("/{submodel_identifier}/$reference") + async def get_submodels_reference(submodel_identifier: str) -> Any: + raise HTTPException(status_code=501, detail="This route is not yet implemented!") + + @self.router.get("/{submodel_identifier}/$path") + async def not_implemented_path_get(submodel_identifier: str) -> Any: + raise HTTPException(status_code=501, detail="This route is yet implemented!") + + @self.router.post("/{submodel_identifier}/submodel-elements") + async def post_submodel_elements(submodel_identifier: str, request: Request) -> Any: + body = await request.json() + return self.service.post_submodel_element(submodel_identifier, body) + + @self.router.get("/{submodel_identifier}/submodel-elements") + async def get_submodel_submodel_elements(submodel_identifier: str) -> Any: + # Get submodel elements + self.service.get_submodel_elements(submodel_identifier) + + # GetSubmodelElement and path-suffixes + @self.router.get("/{submodel_identifier}/submodel-elements/$metadata") + async def get_submodel_submodel_elements_metadata(submodel_identifier: str) -> Any: + raise HTTPException(status_code=501, detail="This route is not yet implemented!") + + @self.router.get("/{submodel_identifier}/submodel-elements/$reference") + async def get_submodel_submodel_elements_reference(submodel_identifier: str) -> Any: + raise HTTPException(status_code=501, detail="This route is not yet implemented!") + + @self.router.get("/{submodel_identifier}/submodel-elements/$value") + async def not_implemented_submodel_elements_value(submodel_identifier: str) -> Any: + raise HTTPException(status_code=501, detail="This route is not yet implemented!") + + @self.router.get("/{submodel_identifier}/submodel-elements/$path") + async def not_implemented_submodel_elements_path(submodel_identifier: str) -> Any: + raise HTTPException(status_code=501, detail="This route is not yet implemented!") + + @self.router.get("/{submodel_identifier}/submodel-elements/{id_short_path}") + async def get_submodel_submodel_elements_id_short_path(submodel_identifier: str, id_short_path: str) -> Any: + return self.service.get_submodel_element(submodel_identifier, id_short_path) + + @self.router.post("/{submodel_identifier}/submodel-elements/{id_short_path}") + async def post_submodel_submodel_elements_id_short_path(submodel_identifier: str, request: Request) -> Any: + raise HTTPException(status_code=501, detail="This route is not yet implemented!") + + @self.router.put("/{submodel_identifier}/submodel-elements/{id_short_path}") + async def put_submodel_submodel_elements_id_short_path(submodel_identifier: str, request: Request) -> Any: + body = await request.json() + return self.service.put_submodel_element(submodel_identifier, body) + + @self.router.patch("/{submodel_identifier}/submodel-elements/{id_short_path}") + async def not_implemented_patch_submodel_submodel_elements_id_short_path(submodel_identifier: str, + id_short_path: str) -> Any: + raise HTTPException(status_code=501, detail="This route is not yet implemented!") + + @self.router.delete("/{submodel_identifier}/submodel-elements/{id_short_path}") + async def delete_submodel_submodel_elements_id_short_path(submodel_identifier: str, id_short_path: str) -> Any: + return self.service.delete_submodel_element(submodel_identifier, id_short_path) + + @self.router.get("/{submodel_identifier}/submodel-elements/{id_short_path}/$metadata") + async def get_submodel_submodel_elements_id_short_path_metadata(submodel_identifier: str, id_short_path: str) -> Any: + raise HTTPException(status_code=501, detail="This route is not yet implemented!") + + @self.router.patch("/{submodel_identifier}/submodel-elements/{id_short_path}/$metadata") + async def not_implemented_metadata_patch(submodel_identifier: str, id_short_path: str) -> Any: + raise HTTPException(status_code=501, detail="This route is not yet implemented!") + + @self.router.get("/{submodel_identifier}/submodel-elements/{id_short_path}/$reference") + async def get_submodel_submodel_elements_id_short_path_reference(submodel_identifier: str, id_short_path: str) -> Any: + raise HTTPException(status_code=501, detail="This route is not yet implemented!") + + @self.router.get("/{submodel_identifier}/submodel-elements/{id_short_path}/$value") + async def not_implemented_value_get(submodel_identifier: str, id_short_path: str) -> Any: + raise HTTPException(status_code=501, detail="This route is not yet implemented!") + + @self.router.patch("/{submodel_identifier}/submodel-elements/{id_short_path}/$value") + async def not_implemented_value_patch(submodel_identifier: str, id_short_path: str) -> Any: + raise HTTPException(status_code=501, detail="This route is not yet implemented!") + + @self.router.get("/{submodel_identifier}/submodel-elements/{id_short_path}/attachment") + async def get_submodel_submodel_element_attachment(submodel_identifier: str, id_short_path: str) -> Any: + raise HTTPException(status_code=501, detail="This route is not yet implemented!") + + @self.router.put("/{submodel_identifier}/submodel-elements/{id_short_path}/attachment") + async def put_submodel_submodel_element_attachment(submodel_identifier: str, id_short_path: str) -> Any: + raise HTTPException(status_code=501, detail="This route is not yet implemented!") + + @self.router.delete("/{submodel_identifier}/submodel-elements/{id_short_path}/attachment") + async def delete_submodel_submodel_element_attachment(submodel_identifier: str, id_short_path: str) -> Any: + raise HTTPException(status_code=501, detail="This route is not yet implemented!") + + @self.router.post("/{submodel_identifier}/submodel-elements/{id_short_path}/invoke") + async def not_implemented_invoke(submodel_identifier: str, id_short_path: str) -> Any: + raise HTTPException(status_code=501, detail="This route is not yet implemented!") + + @self.router.post("/{submodel_identifier}/submodel-elements/{id_short_path}/invoke/$value") + async def not_implemented_invoke_value(submodel_identifier: str, id_short_path: str) -> Any: + raise HTTPException(status_code=501, detail="This route is not yet implemented!") + + @self.router.post("/{submodel_identifier}/submodel-elements/{id_short_path}/invoke-async") + async def not_implemented_invoke_async(submodel_identifier: str, id_short_path: str) -> Any: + raise HTTPException(status_code=501, detail="This route is not yet implemented!") + + @self.router.post("/{submodel_identifier}/submodel-elements/{id_short_path}/invoke-async/$value") + async def not_implemented_invoke_async_value(submodel_identifier: str, id_short_path: str) -> Any: + raise HTTPException(status_code=501, detail="This route is not yet implemented!") + + @self.router.get("/{submodel_identifier}/submodel-elements/{id_short_path}/qualifiers") + async def get_submodel_submodel_element_qualifiers(submodel_identifier: str, id_short_path: str) -> Any: + raise HTTPException(status_code=501, detail="This route is not yet implemented!") + + @self.router.post("/{submodel_identifier}/submodel-elements/{id_short_path}/qualifiers") + async def post_submodel_submodel_element_qualifiers(submodel_identifier: str, id_short_path: str) -> Any: + raise HTTPException(status_code=501, detail="This route is not yet implemented!") + + @self.router.get("/{submodel_identifier}/submodel-elements/{id_short_path}/qualifiers/{qualifier_type}") + async def get_submodel_submodel_element_qualifiers_specific(submodel_identifier: str, id_short_path: str, + qualifier_type: str) -> Any: + raise HTTPException(status_code=501, detail="This route is not yet implemented!") + + @self.router.put("/{submodel_identifier}/submodel-elements/{id_short_path}/qualifiers/{qualifier_type}") + async def put_submodel_submodel_element_qualifiers(submodel_identifier: str, id_short_path: str, + qualifier_type: str) -> Any: + raise HTTPException(status_code=501, detail="This route is not yet implemented!") + + @self.router.delete("/{submodel_identifier}/submodel-elements/{id_short_path}/qualifiers/{qualifier_type}") + async def delete_submodel_submodel_element_qualifiers(submodel_identifier: str, id_short_path: str, + qualifier_type: str) -> Any: + raise HTTPException(status_code=501, detail="This route is not yet implemented!") + + # FIXME: Missing based on swaggerhub: + # - /shells/{aasIdentifier}/submodels/{submodelIdentifier}/submodel-elements/{idShortPath}/operation-status/{handleId} + # - /shells/{aasIdentifier}/submodels/{submodelIdentifier}/submodel-elements/{idShortPath}/operation-results/{handleId} + # - /shells/{aasIdentifier}/submodels/{submodelIdentifier}/submodel-elements/{idShortPath}/operation-results/{handleId}/$value diff --git a/api/server/routes/submodel_registry_server.py b/api/server/routes/submodel_registry_server.py new file mode 100644 index 0000000..e5f976a --- /dev/null +++ b/api/server/routes/submodel_registry_server.py @@ -0,0 +1,38 @@ +from typing import Any + +from aas_core3.types import Identifiable +from fastapi import APIRouter, Request + +from server.services.submodel_registry_server_service import SubmodelRegistryServerService +from basyx import ObjectStore + + +class SubmodelRegistryRouter: + def __init__(self, global_obj_store: ObjectStore[Identifiable]): + self.router = APIRouter() + self.obj_store = global_obj_store + self.service = SubmodelRegistryServerService(global_obj_store) + self._setup_routes() + + def _setup_routes(self): + @self.router.get("/") + async def get_all_submodel_descriptors() -> Any: + return self.service.get_all_submodel_descriptors() + + @self.router.get("/{submodel_id}") + async def get_submodel_descriptor_by_id(submodel_id: str) -> Any: + return self.service.get_submodel_descriptor_by_id(submodel_id) + + @self.router.post("/") + async def post_submodel_descriptor(request: Request) -> Any: + body = await request.json() + return self.service.post_submodel_descriptor(body) + + @self.router.put("/") + async def put_submodel_descriptor_by_id(request: Request) -> Any: + body = await request.json() + return self.service.put_submodel_descriptor_by_id(body) + + @self.router.delete("/{submodel_id}") + async def delete_submodel_descriptor_by_id(submodel_id: str) -> Any: + return self.service.delete_submodel_descriptor_by_id(submodel_id) diff --git a/api/server/server_main.py b/api/server/server_main.py new file mode 100644 index 0000000..8b68771 --- /dev/null +++ b/api/server/server_main.py @@ -0,0 +1,28 @@ +import uvicorn +from fastapi import FastAPI + +from server.routes import submodel, aas, aasx_file_server, aas_registry_server, submodel_registry_server +from basyx import object_store + +app = FastAPI() +prefix = "/api/v3.0" + +central_object_store = object_store.ObjectStore() + +aas_router = aas.AasRouter(central_object_store) +submodel_router = submodel.SubmodelRouter(central_object_store) +aasx_file_router = aasx_file_server.AasxFileServerRouter(central_object_store) +aas_registry_router = aas_registry_server.AasRegistryRouter(central_object_store) +submodel_registry_router = submodel_registry_server.SubmodelRegistryRouter(central_object_store) + +# Register router +# TODO: This can be done dynamically based on startup params +app.include_router(submodel_router.router, prefix=prefix + "/submodels") +app.include_router(aasx_file_router.router, prefix=prefix + "/aasx") +app.include_router(aas_registry_router.router, prefix=prefix + "/registry") +app.include_router(submodel_registry_router.router, prefix=prefix + "/submodels") +app.include_router(aas_router.router, prefix=prefix + "/aas") + +# Start the server if this file is executed directly +if __name__ == "__main__": + uvicorn.run("server:app", host="127.0.0.1", port=8000, reload=True) diff --git a/api/server/services/__init__.py b/api/server/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/server/services/aas_registry_server_service.py b/api/server/services/aas_registry_server_service.py new file mode 100644 index 0000000..b992f43 --- /dev/null +++ b/api/server/services/aas_registry_server_service.py @@ -0,0 +1,67 @@ +from typing import Any, MutableMapping, List, Union + +from aas_core3 import jsonization +from aas_core3.types import AssetAdministrationShell, ConceptDescription + +from basyx import ObjectStore +from server.utils.error_handling import CustomErrorResponse + + +class AasRegistryServerService: + def __init__(self, global_object_store: ObjectStore): + self.obj_store = global_object_store + + def get_all_asset_administration_shell_descriptors(self) -> List[str]: + all_descriptors = self.obj_store.get_identifiables_by_type(ConceptDescription) + # print(all_descriptors.__dict__) + # print(all_descriptors) + # print("test") + aas_descriptors_store = ObjectStore() + for descriptor in all_descriptors: + reference_list = descriptor.is_case_of + print(reference_list) + for element in reference_list: + reference_ids = element.keys + for reference_id in reference_ids: + try: + identifiable = self.obj_store.get_identifiable(reference_id) + except KeyError as e: + raise CustomErrorResponse(status_code=400, exception=e) + + if isinstance(identifiable, AssetAdministrationShell): + try: + aas_descriptors_store.add(descriptor) + except KeyError as e: + pass + return [jsonization.to_jsonable(descriptor) for descriptor in aas_descriptors_store] + + def get_asset_administration_shell_descriptor_by_id(self, descriptor_id) \ + -> List[Union[bool, int, float, str, List[Any], MutableMapping[str, Any]]]: + aas_descriptor = self.obj_store.get_identifiable(descriptor_id) + assert isinstance(aas_descriptor, ConceptDescription) + return jsonization.to_jsonable(aas_descriptor) + + def post_asset_administration_shell_descriptor(self, json): + aas_descriptor = jsonization.concept_description_from_jsonable(json) + try: + self.obj_store.add(aas_descriptor) + except KeyError as e: + raise CustomErrorResponse(status_code=400, exception=e) + return {"message": "AAS Descriptor processed"} + + def put_asset_administration_shell_descriptor_by_id(self, json): + aas_descriptor = jsonization.asset_administration_shell_from_jsonable(json) + try: + self.obj_store.delete(aas_descriptor.id) # should there be an exception if there is no aasx_package to + # update? + self.obj_store.add(aas_descriptor) + except KeyError as e: + raise CustomErrorResponse(status_code=400, exception=e) + return {"message": "AASX package updated"} + + def delete_asset_administration_shell_descriptor_by_id(self, descriptor_id): + try: + self.obj_store.delete(descriptor_id) # should there be an exception if there is no aasx_package to delete? + except KeyError as e: + raise CustomErrorResponse(status_code=400, exception=e) + return {"message": "AASX descriptor deleted"} diff --git a/api/server/services/aas_service.py b/api/server/services/aas_service.py new file mode 100644 index 0000000..1d57ab4 --- /dev/null +++ b/api/server/services/aas_service.py @@ -0,0 +1,88 @@ +from typing import List, Type, Any, MutableMapping, Union + +from aas_core3 import jsonization +from aas_core3.types import AssetAdministrationShell + +from basyx import ObjectStore +from server.utils.error_handling import CustomErrorResponse + + +class AasService: + def __init__(self, global_object_store: ObjectStore): + self.obj_store = global_object_store + + # General helper functions + def _get_all_shells(self) -> List[Type]: + # FIXME: Type issues. Should this be casted to List[AAS]? + return self.obj_store.get_identifiables_by_type(AssetAdministrationShell) + + def _get_shell_by_id(self, aas_identifier) -> AssetAdministrationShell: + shell = self.obj_store.get(aas_identifier) + if shell is None or not isinstance(shell, AssetAdministrationShell): + raise CustomErrorResponse(status_code=404, message="Submodel with id " + aas_identifier + " not found") + return shell + + def _jsonable_shells(self, submodels: List[AssetAdministrationShell]) \ + -> List[Union[bool, int, float, str, List[Any], MutableMapping[str, Any]]]: + return [jsonization.to_jsonable(submodel) for submodel in submodels] + + # Endpoint specific logic + def get_all_shells_as_jsonable(self) -> List[Union[bool, int, float, str, List[Any], MutableMapping[str, Any]]]: + return self._jsonable_shells(self._get_all_shells()) + + def get_shell_jsonable_by_id(self, aas_identifier): + shell = self._get_shell_by_id(aas_identifier) + return jsonization.to_jsonable(shell) + + def add_shell_from_body(self, json): + shell = jsonization.asset_administration_shell_from_jsonable(json) + try: + self.obj_store.add(shell) + except KeyError as e: + raise CustomErrorResponse(status_code=400, exception=e) + return {"message": "Shell processed"} + + def put_shell_by_id(self, aas_identifier, json): + shell = self._get_shell_by_id(aas_identifier) + new_shell = jsonization.asset_administration_shell_from_jsonable(json) + if shell.id != new_shell.id: + raise CustomErrorResponse(status_code=403, message="Shell with id " + aas_identifier + " does not match") + self.obj_store.discard(shell) + self.obj_store.add(new_shell) + return jsonization.to_jsonable(new_shell) + + def delete_shell_by_id(self, aas_identifier): + shell = self._get_shell_by_id(aas_identifier) + self.obj_store.discard(shell) + return {"message": "AssetAdministrationShell with id " + aas_identifier + " deleted successfully"} + + def get_asset_information_by_id_as_jsonable(self, aas_identifier): + shell = self._get_shell_by_id(aas_identifier) + return jsonization.to_jsonable(shell.asset_information) + + def put_asset_information_by_id_from_jsonable(self, aas_identifier, json): + shell = self._get_shell_by_id(aas_identifier) + self.obj_store.discard(shell) + new_information = jsonization.asset_information_from_jsonable(json) + shell.asset_information = new_information + self.obj_store.add(shell) + return jsonization.to_jsonable(shell) + + def get_thumbnail_by_id(self, aas_identifier): + shell = self._get_shell_by_id(aas_identifier) + return jsonization.to_jsonable(shell.asset_information.default_thumbnail) + + def put_thumbnail_by_id(self, aas_identifier, thumbnail): + shell = self._get_shell_by_id(aas_identifier) + self.obj_store.discard(shell) + new_thumbnail = jsonization.resource_from_jsonable(thumbnail) + shell.asset_information.default_thumbnail = new_thumbnail + self.obj_store.add(shell) + return jsonization.to_jsonable(shell.asset_information.default_thumbnail) + + def delete_thumbnail_by_id(self, ass_identifier): + shell = self._get_shell_by_id(ass_identifier) + self.obj_store.discard(shell) + shell.asset_information.default_thumbnail = None + self.obj_store.add(shell) + return jsonization.to_jsonable(shell) \ No newline at end of file diff --git a/api/server/services/aasx_file_server_service.py b/api/server/services/aasx_file_server_service.py new file mode 100644 index 0000000..67ede61 --- /dev/null +++ b/api/server/services/aasx_file_server_service.py @@ -0,0 +1,46 @@ +from typing import Any, MutableMapping, List, Union + +from aas_core3.types import AssetAdministrationShell +from aas_core3 import jsonization + +from basyx import ObjectStore +from server.utils.error_handling import CustomErrorResponse + + +class AasxFileServerService: + def __init__(self, global_object_store: ObjectStore): + self.obj_store = global_object_store + + def get_all_aasx_package_ids(self) -> List[str]: + return [item.id for item in self.obj_store if isinstance(item, AssetAdministrationShell)] + + def get_aasx_by_package_id(self, package_id) \ + -> List[Union[bool, int, float, str, List[Any], MutableMapping[str, Any]]]: + aasx_package = self.obj_store.get_identifiable(package_id) + assert isinstance(aasx_package, AssetAdministrationShell) + return jsonization.to_jsonable(aasx_package) + + def post_aasx_package(self, json): + aasx_package = jsonization.asset_administration_shell_from_jsonable(json) + try: + self.obj_store.add(aasx_package) + except KeyError as e: + raise CustomErrorResponse(status_code=400, exception=e) + return {"message": "AASX package processed"} + + def put_aasx_by_package_id(self, json): + aasx_package = jsonization.asset_administration_shell_from_jsonable(json) + try: + self.obj_store.delete(aasx_package.id) # should there be an exception if there is no aasx_package to + # update? + self.obj_store.add(aasx_package) + except KeyError as e: + raise CustomErrorResponse(status_code=400, exception=e) + return {"message": "AASX package updated"} + + def delete_aasx_by_package_id(self, package_id): + try: + self.obj_store.delete(package_id) # should there be an exception if there is no aasx_package to delete? + except KeyError as e: + raise CustomErrorResponse(status_code=400, exception=e) + return {"message": "AASX package deleted"} diff --git a/api/server/services/submodel_registry_server_service.py b/api/server/services/submodel_registry_server_service.py new file mode 100644 index 0000000..4b4197c --- /dev/null +++ b/api/server/services/submodel_registry_server_service.py @@ -0,0 +1,101 @@ +from typing import Any, MutableMapping, List, Union + +from aas_core3 import jsonization +from aas_core3.types import Submodel, ConceptDescription + +from basyx import ObjectStore +from server.utils.error_handling import CustomErrorResponse + + +class SubmodelRegistryServerService: + def __init__(self, global_object_store: ObjectStore): + self.obj_store = global_object_store + + def get_all_submodel_descriptors(self) -> List[str]: + #print(self.obj_store.__dict__) + + all_descriptors = self.obj_store.get_identifiables_by_type(ConceptDescription) + #print(all_descriptors.__dict__) + print(all_descriptors) + #print("test") + submodel_descriptors_store = ObjectStore() + for descriptor in all_descriptors: + reference_list = descriptor.is_case_of + print(reference_list) + for element in reference_list: + reference_ids = element.keys + print(reference_ids) + for reference_id in reference_ids: + try: + identifiable = self.obj_store.get_identifiable(reference_id.value) + except KeyError as e: + # TODO: Handle error? Or is this intended? + identifiable = [] + if isinstance(identifiable, Submodel): + try: + submodel_descriptors_store.add(descriptor) + except KeyError as e: + pass + return [jsonization.to_jsonable(descriptor) for descriptor in submodel_descriptors_store] + + def get_submodel_descriptor_by_id(self, descriptor_id) \ + -> List[Union[bool, int, float, str, List[Any], MutableMapping[str, Any]]]: + try: + aas_descriptor = self.obj_store.get_identifiable(descriptor_id) + except KeyError as e: + raise CustomErrorResponse(status_code=400, exception=e) + assert isinstance(aas_descriptor, ConceptDescription) + return jsonization.to_jsonable(aas_descriptor) + + def post_submodel_descriptor(self, json): + submodel_descriptor = jsonization.concept_description_from_jsonable(json) + + # Check if all referenced submodels exist in the obeject_store + + for reference in submodel_descriptor.is_case_of: + reference_ids = reference.keys + for reference_id in reference_ids: + try: + self.obj_store.get_identifiable(reference_id.value) + except KeyError as e: + raise CustomErrorResponse(status_code=400, message="A referenced submodel of the concept description " + "with the following id does not exist in the " + "object_store", exception=e) + + try: + self.obj_store.add(submodel_descriptor) + except KeyError as e: + raise CustomErrorResponse(status_code=400, detail="A referenced submodel of the concept description " + "with the following id does not exist in the " + "object_store:", exception=e) + return {"message": "Submodel descriptor processed"} + + def put_submodel_descriptor_by_id(self, json): + submodel_descriptor = jsonization.concept_description_from_jsonable(json) + + # Check if all referenced submodels exist in the obeject_store + + for reference in submodel_descriptor.is_case_of: + reference_ids = reference.keys + for reference_id in reference_ids: + try: + self.obj_store.get_identifiable(reference_id.value) + except KeyError as e: + raise CustomErrorResponse(status_code=400, detail= "A referenced submodel of the concept description " + "with the following id does not exist in the " + "object_store:", exception=e) + + try: + self.obj_store.delete(submodel_descriptor.id) # should there be an exception if there is no aasx_package to + # update? + self.obj_store.add(submodel_descriptor) + except KeyError as e: + raise CustomErrorResponse(status_code=400, exception=e) + return {"message": "AASX package updated"} + + def delete_submodel_descriptor_by_id(self, descriptor_id): + try: + self.obj_store.delete(descriptor_id) # should there be an exception if there is no aasx_package to delete? + except KeyError as e: + raise CustomErrorResponse(status_code=400, exception=e) + return {"message": "Submodel descriptor deleted"} diff --git a/api/server/services/submodel_service.py b/api/server/services/submodel_service.py new file mode 100644 index 0000000..536ec0d --- /dev/null +++ b/api/server/services/submodel_service.py @@ -0,0 +1,129 @@ +from typing import Any, MutableMapping, List, Union, Type + +from aas_core3 import jsonization +from aas_core3.types import Submodel, SubmodelElement, AssetAdministrationShell + +from basyx import ObjectStore +from server.utils.error_handling import CustomErrorResponse + + +class SubmodelService: + def __init__(self, global_object_store: ObjectStore): + self.obj_store = global_object_store + + # General helper functions + def _get_all_submodels(self) -> List[Type]: + return self.obj_store.get_identifiables_by_type(Submodel) + + def _get_all_submodel_references_by_shell(self, aas_identifier: str) -> List[Type]: + shell = self.obj_store.get(aas_identifier) + if isinstance(shell, AssetAdministrationShell): + return shell.submodels # FIXME: Typing issues, should this be (safe) casted? + else: + raise CustomErrorResponse(status_code=404, message="AAS " + aas_identifier + " not found") + + def _jsonable_submodels(self, submodels: List[Submodel]) \ + -> List[Union[bool, int, float, str, List[Any], MutableMapping[str, Any]]]: + return [jsonization.to_jsonable(submodel) for submodel in submodels] + + def _get_submodel_by_id(self, submodel_id): + submodel = self.obj_store.get(submodel_id) + if submodel is None or not isinstance(submodel, Submodel): + raise CustomErrorResponse(status_code=404, message="Submodel with id " + submodel_id + " not found") + return submodel + + def _get_submodel_element_by_id_short(self, submodel_id, element_id_short): + submodel = self._get_submodel_by_id(submodel_id) + for element in submodel.descend(): + if isinstance(element, SubmodelElement): + if element.id_short == element_id_short: + return element + return None + + # Endpoint specific logic + def get_all_submodels_as_jsonables(self) \ + -> List[Union[bool, int, float, str, List[Any], MutableMapping[str, Any]]]: + return self._jsonable_submodels(self._get_all_submodels()) + + def add_submodel_from_body(self, json): + submodel = jsonization.submodel_from_jsonable(json) + try: + self.obj_store.add(submodel) + except KeyError as e: + raise CustomErrorResponse(status_code=400, exception=e) + return {"message": "Submodel processed"} + + def get_submodel_jsonable_by_id(self, submodel_id: str): + submodel = self._get_submodel_by_id(submodel_id) + return jsonization.to_jsonable(submodel) + + def update_submodel_by_id(self, submodel_id: str, json): + submodel = self._get_submodel_by_id(submodel_id) + new_submodel = jsonization.submodel_from_jsonable(json) + if submodel.id != new_submodel.id: + raise CustomErrorResponse(status_code=403, message="Submodel with id " + submodel_id + " does not match") + self.obj_store.discard(submodel) + self.obj_store.add(new_submodel) + return jsonization.to_jsonable(new_submodel) + + def delete_submodel_by_id(self, submodel_id): + submodel = self._get_submodel_by_id(submodel_id) + self.obj_store.discard(submodel) + return {"message": "Submodel with id " + submodel_id + " deleted successfully"} + + def get_submodel_elements(self, submodel_id): + # FIXME: Has to respect hierarchy!! + submodel = self._get_submodel_by_id(submodel_id) + elements = [] + for element in submodel.descend(): + # Maybe filter some items out? + elements.append(jsonization.to_jsonable(element)) + return elements + + def update_submodel_elements(self, submodel_id, json): + submodel = self._get_submodel_by_id(submodel_id) + new_elements = [] + for element in json: + deserialized_element = jsonization.submodel_element_from_jsonable(element) + new_elements.append(deserialized_element) + self.obj_store.discard(submodel) + submodel.submodel_elements = new_elements + self.obj_store.add(submodel) + + def get_submodel_element(self, submodel_id, element_short_id): + element = self._get_submodel_element_by_id_short(submodel_id, element_short_id) + if element is None: + raise CustomErrorResponse(status_code=404, message="Submodel element with id " + element_short_id + " not found.") + return jsonization.to_jsonable(element) + + def post_submodel_element(self, submodel_id, body): + submodel = self._get_submodel_by_id(submodel_id) + submodel_element = jsonization.submodel_element_from_jsonable(body) + existing_submodel_element = self._get_submodel_element_by_id_short(submodel_id, submodel_element.id_short) + if existing_submodel_element is None: + submodel.submodel_elements.append(submodel_element) + return jsonization.to_jsonable(submodel_element) + else: + raise CustomErrorResponse(status_code=400, + message="Submodel element with id " + submodel_element.id_short + " already exists") + + def put_submodel_element(self, submodel_id, body): + submodel = self._get_submodel_by_id(submodel_id) + submodel_element = jsonization.submodel_element_from_jsonable(body) + existing_submodel_element = self._get_submodel_element_by_id_short(submodel_id, submodel_element.id_short) + if existing_submodel_element is None: + raise CustomErrorResponse(status_code=404, + message="Submodel element with id " + submodel_element.id_short + " does not exist") + else: + submodel.submodel_elements.remove(existing_submodel_element) + submodel.submodel_elements.append(submodel_element) + return jsonization.to_jsonable(submodel_element) + + def delete_submodel_element(self, submodel_id, id_short): + submodel = self._get_submodel_by_id(submodel_id) + existing_submodel_element = self._get_submodel_element_by_id_short(submodel_id, id_short) + if existing_submodel_element is None: + raise CustomErrorResponse(status_code=404, message="Submodel element with id " + id_short + " does not exist") + else: + submodel.submodel_elements.remove(existing_submodel_element) + return jsonization.to_jsonable(existing_submodel_element) diff --git a/api/server/utils/decorator.py b/api/server/utils/decorator.py new file mode 100644 index 0000000..8035b4a --- /dev/null +++ b/api/server/utils/decorator.py @@ -0,0 +1,29 @@ +from functools import wraps +from fastapi import Request +from typing import Callable, Any, Union + + +def limited(default_limit: int = 100): + def decorator(func: Callable): + @wraps(func) + async def wrapper(*args, **kwargs) -> Any: + # Extract request from kwargs (FastAPI automatically passes it) + request: Request = kwargs.get("request") + limit: Union[str, int] = request.query_params.get("limit", default_limit) + + try: + limit = int(limit) # Ensure limit is an integer + except ValueError: + limit = default_limit # Fallback if conversion fails + + # Call the original function + result = await func(*args, **kwargs) + + # Apply limit if the result is a list + if isinstance(result, list): + return result[:limit] + return result # If not a list, return as-is + + return wrapper + + return decorator diff --git a/api/server/utils/error_handling.py b/api/server/utils/error_handling.py new file mode 100644 index 0000000..10a08eb --- /dev/null +++ b/api/server/utils/error_handling.py @@ -0,0 +1,14 @@ +import traceback +from fastapi import HTTPException + +class CustomErrorResponse(HTTPException): + def __init__(self, exception: Exception = Exception("No exception provided"), + message: str = "No additional information was provided.", status_code: int = 400): + super().__init__( + status_code=status_code, + detail={ + "message": message, + "error": str(exception), + "stacktrace": traceback.format_exc(), + }, + ) diff --git a/api/server/utils/pagination.py b/api/server/utils/pagination.py new file mode 100644 index 0000000..f04fff6 --- /dev/null +++ b/api/server/utils/pagination.py @@ -0,0 +1,7 @@ +from typing import Iterable, Any + + +class Pagination: + # TODO: Add cursor + def paginate(self, collection: Iterable[Any], limit: int) -> Iterable[Any]: + return list(collection)[:limit] diff --git a/api/test/__init__.py b/api/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/test/examples/__init__.py b/api/test/examples/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/test/examples/aas/aas.json b/api/test/examples/aas/aas.json new file mode 100644 index 0000000..f2f276c --- /dev/null +++ b/api/test/examples/aas/aas.json @@ -0,0 +1,10 @@ +{ + "id": "urn:x-test:aas1", + "assetInformation": { + "assetKind": "Type", + "defaultThumbnail": { + "path": "blub" + } + }, + "modelType": "AssetAdministrationShell" +} diff --git a/api/test/examples/aas/aas_modified.json b/api/test/examples/aas/aas_modified.json new file mode 100644 index 0000000..f5a1d36 --- /dev/null +++ b/api/test/examples/aas/aas_modified.json @@ -0,0 +1,10 @@ +{ + "id": "urn:x-test:aas1", + "assetInformation": { + "assetKind": "Instance", + "defaultThumbnail": { + "path": "blub" + } + }, + "modelType": "AssetAdministrationShell" +} diff --git a/api/test/examples/aas/asset_information.json b/api/test/examples/aas/asset_information.json new file mode 100644 index 0000000..00120dd --- /dev/null +++ b/api/test/examples/aas/asset_information.json @@ -0,0 +1,6 @@ +{ + "assetKind": "Type", + "defaultThumbnail": { + "path": "blub" + } +} diff --git a/api/test/examples/aas/asset_information_modified.json b/api/test/examples/aas/asset_information_modified.json new file mode 100644 index 0000000..d27838e --- /dev/null +++ b/api/test/examples/aas/asset_information_modified.json @@ -0,0 +1,6 @@ +{ + "assetKind": "Instance", + "defaultThumbnail": { + "path": "blub" + } +} diff --git a/api/test/examples/aas/asset_information_no_thumbnail.json b/api/test/examples/aas/asset_information_no_thumbnail.json new file mode 100644 index 0000000..4ac0bf9 --- /dev/null +++ b/api/test/examples/aas/asset_information_no_thumbnail.json @@ -0,0 +1,3 @@ +{ + "assetKind": "Type" +} diff --git a/api/test/examples/aas/thumbnail.json b/api/test/examples/aas/thumbnail.json new file mode 100644 index 0000000..f1be4d5 --- /dev/null +++ b/api/test/examples/aas/thumbnail.json @@ -0,0 +1,3 @@ +{ + "path": "blub" +} diff --git a/api/test/examples/aas/thumbnail_modified.json b/api/test/examples/aas/thumbnail_modified.json new file mode 100644 index 0000000..1621570 --- /dev/null +++ b/api/test/examples/aas/thumbnail_modified.json @@ -0,0 +1,3 @@ +{ + "path": "bluuub" +} diff --git a/api/test/examples/aasx/aasx.json b/api/test/examples/aasx/aasx.json new file mode 100644 index 0000000..8ff2c4c --- /dev/null +++ b/api/test/examples/aasx/aasx.json @@ -0,0 +1,7 @@ +{ + "id":"aasx_package_test", + "assetInformation":{ + "assetKind":"Type" + }, + "modelType":"AssetAdministrationShell" +} diff --git a/api/test/examples/submodel/submodel.json b/api/test/examples/submodel/submodel.json new file mode 100644 index 0000000..d308fa2 --- /dev/null +++ b/api/test/examples/submodel/submodel.json @@ -0,0 +1,37 @@ +{ + "id": "urn:x-test:submodel1", + "submodelElements": [ + { + "idShort": "some_property", + "valueType": "xs:int", + "value": "1984", + "modelType": "Property" + }, + { + "idShort": "some_blob", + "value": "3q2+7w==", + "contentType": "application/octet-stream", + "modelType": "Blob" + }, + { + "idShort": "ExampleSubmodelList", + "typeValueListElement": "SubmodelElementList", + "value": [ + { + "idShort": "list_1", + "value": "3q2+7w==", + "contentType": "application/octet-stream", + "modelType": "Blob" + }, + { + "idShort": "list_2", + "value": "3q2+7w==", + "contentType": "application/octet-stream", + "modelType": "Blob" + } + ], + "modelType": "SubmodelElementList" + } + ], + "modelType": "Submodel" +} diff --git a/api/test/examples/submodel/submodel_element.json b/api/test/examples/submodel/submodel_element.json new file mode 100644 index 0000000..2fabdfa --- /dev/null +++ b/api/test/examples/submodel/submodel_element.json @@ -0,0 +1,6 @@ +{ + "idShort": "some_property_123", + "valueType": "xs:int", + "value": "8419", + "modelType": "Property" +} diff --git a/api/test/examples/submodel/submodel_element_new.json b/api/test/examples/submodel/submodel_element_new.json new file mode 100644 index 0000000..2fabdfa --- /dev/null +++ b/api/test/examples/submodel/submodel_element_new.json @@ -0,0 +1,6 @@ +{ + "idShort": "some_property_123", + "valueType": "xs:int", + "value": "8419", + "modelType": "Property" +} diff --git a/api/test/examples/submodel/submodel_modified.json b/api/test/examples/submodel/submodel_modified.json new file mode 100644 index 0000000..80233be --- /dev/null +++ b/api/test/examples/submodel/submodel_modified.json @@ -0,0 +1,37 @@ +{ + "id": "urn:x-test:submodel1", + "submodelElements": [ + { + "idShort": "some_property", + "valueType": "xs:int", + "value": "8419", + "modelType": "Property" + }, + { + "idShort": "some_blob", + "value": "3q2+7w==", + "contentType": "application/octet-stream", + "modelType": "Blob" + }, + { + "idShort": "ExampleSubmodelList", + "typeValueListElement": "SubmodelElementList", + "value": [ + { + "idShort": "list_1", + "value": "481563", + "contentType": "application/octet-stream", + "modelType": "Blob" + }, + { + "idShort": "list_2", + "value": "&/6453=(", + "contentType": "application/octet-stream", + "modelType": "Blob" + } + ], + "modelType": "SubmodelElementList" + } + ], + "modelType": "Submodel" +} diff --git a/api/test/examples/submodel/submodel_with_new_element.json b/api/test/examples/submodel/submodel_with_new_element.json new file mode 100644 index 0000000..4a2686b --- /dev/null +++ b/api/test/examples/submodel/submodel_with_new_element.json @@ -0,0 +1,43 @@ +{ + "id": "urn:x-test:submodel1", + "submodelElements": [ + { + "idShort": "some_property", + "valueType": "xs:int", + "value": "1984", + "modelType": "Property" + }, + { + "idShort": "some_blob", + "value": "3q2+7w==", + "contentType": "application/octet-stream", + "modelType": "Blob" + }, + { + "idShort": "ExampleSubmodelList", + "typeValueListElement": "SubmodelElementList", + "value": [ + { + "idShort": "list_1", + "value": "3q2+7w==", + "contentType": "application/octet-stream", + "modelType": "Blob" + }, + { + "idShort": "list_2", + "value": "3q2+7w==", + "contentType": "application/octet-stream", + "modelType": "Blob" + } + ], + "modelType": "SubmodelElementList" + }, + { + "idShort": "some_property_123", + "valueType": "xs:int", + "value": "8419", + "modelType": "Property" + } + ], + "modelType": "Submodel" +} diff --git a/api/test/test_aas_service.py b/api/test/test_aas_service.py new file mode 100644 index 0000000..96028b0 --- /dev/null +++ b/api/test/test_aas_service.py @@ -0,0 +1,166 @@ +import os +import json +import unittest +from fastapi.testclient import TestClient + +from server import app + +BASE_URL = "/api/v3.0/" + + +class TestAASService(unittest.TestCase): + def setUp(self): + base_path = os.path.dirname(os.path.abspath(__file__)) + self.client = TestClient(app) + + with open(os.path.join(base_path, "examples/aas", "aas.json"), encoding="utf-8") as f: + self.aas_example = json.load(f) + + with open(os.path.join(base_path, "examples/aas", "asset_information.json"), encoding="utf-8") as f: + self.asset_information_example = json.load(f) + + with open(os.path.join(base_path, "examples/aas", "asset_information_modified.json"), encoding="utf-8") as f: + self.asset_information_example_modified = json.load(f) + + with open(os.path.join(base_path, "examples/aas", "asset_information_no_thumbnail.json"), encoding="utf-8") as f: + self.asset_information_example_no_thumbnail = json.load(f) + + with open(os.path.join(base_path, "examples/aas", "thumbnail.json"), encoding="utf-8") as f: + self.thumbnail_example = json.load(f) + + with open(os.path.join(base_path, "examples/aas", "thumbnail_modified.json"), encoding="utf-8") as f: + self.thumbnail_example_modified = json.load(f) + + # FIXME: modified AAS should contain more complex types but deserialization seems to fail + with open(os.path.join(base_path, "examples/aas", "aas_modified.json"), encoding="utf-8") as f: + self.aas_example_modified = json.load(f) + + self.shell_example_id = self.aas_example["id"] + self.invalid_shell_example_id = "some_other_id" + + def test_aas_post(self): + response = self.client.post(BASE_URL + "aas/shells", json=self.aas_example) + self.assertEqual(response.status_code, 200) + + # Teardown + self.client.delete(BASE_URL + "aas/shells/" + self.shell_example_id) + + def test_get_all_shells(self): + response = self.client.get(BASE_URL + "aas/shells") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), []) + + # Setup + self.client.post(BASE_URL + "aas/shells", json=self.aas_example) + + response = self.client.get(BASE_URL + "aas/shells") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), [self.aas_example]) + + # Teardown + self.client.delete(BASE_URL + "ass/shells/" + self.shell_example_id) + + def test_get_specific_shell(self): + # Setup + self.client.post(BASE_URL + "aas/shells", json=self.aas_example) + + response_test_undefined = self.client.get(BASE_URL + "aas/shells/" + self.invalid_shell_example_id) + response_test_entry = self.client.get(BASE_URL + "aas/shells/" + self.shell_example_id) + + self.assertEqual(response_test_undefined.status_code, 404) + self.assertEqual(response_test_entry.status_code, 200) + self.assertEqual(response_test_entry.json(), self.aas_example) + + # Teardown + self.client.delete(BASE_URL + "aas/shells/" + self.shell_example_id) + + def test_put_shell(self): + # Setup and Preconditions + self.assertNotEqual(self.aas_example, self.aas_example_modified) + self.client.post(BASE_URL + "aas/shells", json=self.aas_example) + + response_normal = self.client.get(BASE_URL + "aas/shells/" + self.shell_example_id) + self.assertEqual(response_normal.json(), self.aas_example) + + response = self.client.put(BASE_URL + "aas/shells/" + self.shell_example_id, json=self.aas_example_modified) + self.assertEqual(response.status_code, 200) + response_overwritten = self.client.get(BASE_URL + "aas/shells/" + self.shell_example_id) + self.assertEqual(response_overwritten.status_code, 200) + self.assertEqual(response_overwritten.json(), self.aas_example_modified) + + # Teardown + self.client.delete(BASE_URL + "aas/shells/" + self.shell_example_id) + + # FIXME: Technically a test_delete_shell would be added here. Is this really necessary? Kinda covered by the rest + + # Asset Information Endpoints + + def test_get_asset_information(self): + # Setup + self.client.post(BASE_URL + "aas/shells", json=self.aas_example) + + response = self.client.get(BASE_URL + "aas/shells/" + self.shell_example_id + "/asset-information") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), self.asset_information_example) + + # Teardown + self.client.delete(BASE_URL + "aas/shells/" + self.shell_example_id) + + def test_put_asset_information(self): + # Setup + self.client.post(BASE_URL + "aas/shells", json=self.aas_example) + + # Replace the asset-information converting aas_example to aas_example_modified + # FIXME: This should probably have more complex asset-information to test + response = self.client.put(BASE_URL + "aas/shells/" + self.shell_example_id + "/asset-information", + json=self.asset_information_example_modified) + self.assertEqual(response.status_code, 200) + + response_modified = self.client.get(BASE_URL + "aas/shells/" + self.shell_example_id + "/asset-information") + self.assertEqual(response_modified.status_code, 200) + self.assertEqual(response_modified.json(), self.asset_information_example_modified) + + # Teardown + self.client.delete(BASE_URL + "aas/shells/" + self.shell_example_id) + + def test_get_asset_information_thumbnail(self): + # Setup + self.client.post(BASE_URL + "aas/shells", json=self.aas_example) + + response = self.client.get(BASE_URL + "aas/shells/" + self.shell_example_id + "/asset-information/thumbnail") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), self.thumbnail_example) + + # Teardown + self.client.delete(BASE_URL + "aas/shells/" + self.shell_example_id) + + def test_put_asset_information_thumbnail(self): + # Setup + self.client.post(BASE_URL + "aas/shells", json=self.aas_example) + + response = self.client.put(BASE_URL + "aas/shells/" + self.shell_example_id + "/asset-information/thumbnail", + json=self.thumbnail_example_modified) + self.assertEqual(response.status_code, 200) + + response_modified = self.client.get( + BASE_URL + "aas/shells/" + self.shell_example_id + "/asset-information/thumbnail") + self.assertEqual(response.status_code, 200) + self.assertEqual(response_modified.json(), self.thumbnail_example_modified) + + # Teardown + self.client.delete(BASE_URL + "aas/shells/" + self.shell_example_id) + + def test_delete_asset_information_thumbnail(self): + # Setup + self.client.post(BASE_URL + "aas/shells", json=self.aas_example) + + self.client.delete(BASE_URL + "aas/shells/" + self.shell_example_id + "/asset-information/thumbnail") + response = self.client.get(BASE_URL + "aas/shells/" + self.shell_example_id + "/asset-information") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), self.asset_information_example_no_thumbnail) + + # Teardown + self.client.delete(BASE_URL + "aas/shells/" + self.shell_example_id) + +if __name__ == "__main__": + unittest.main() diff --git a/api/test/test_aasx_file_server_service.py b/api/test/test_aasx_file_server_service.py new file mode 100644 index 0000000..260837e --- /dev/null +++ b/api/test/test_aasx_file_server_service.py @@ -0,0 +1,67 @@ +import os +import json +import unittest +from fastapi.testclient import TestClient + +from server import app +from aas_core3 import jsonization + +client = TestClient(app) +BASE_URL = "/api/v3.0/" + + +class TestFastAPIEndpoints(unittest.TestCase): + def setUp(self): + base_path = os.path.dirname(os.path.abspath(__file__)) + self.client = TestClient(app) + + with open(os.path.join(base_path, "examples/aasx", "aasx.json"), encoding="utf-8") as f: + self.aasx_json = json.load(f) + + self.test_aasx_id = self.aasx_json["id"] + self.aasx = jsonization.asset_administration_shell_from_jsonable(self.aasx_json) + + def test_get_all_aasx_package_ids(self): + # Test empty + response = self.client.get(BASE_URL + "aasx") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), []) + + # Setup + self.client.post(BASE_URL + "aasx", json=self.aasx_json) + + response = self.client.get(BASE_URL + "aasx") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), [self.test_aasx_id]) + + # Teardown + self.client.delete(BASE_URL + "aasx/" + self.test_aasx_id) + + def test_post_aasx_package(self): + response = client.post(BASE_URL + "aasx", json=self.aasx_json) + self.assertEqual(response.status_code, 200) + + # Teardown + self.client.delete(BASE_URL + "aasx/" + self.test_aasx_id) + + def test_get_aasx_package(self): + # Setup + self.client.post(BASE_URL + "aasx", json=self.aasx_json) + + response_test_entry = client.get(BASE_URL + "aasx/" + self.test_aasx_id) + self.assertEqual(response_test_entry.status_code, 200) + self.assertEqual(response_test_entry.json(), self.aasx_json) + + # Teardown + self.client.delete(BASE_URL + "aasx/" + self.test_aasx_id) + + def test_delete_aasx_package(self): + pass + + def test_put_aasx_package(self): + pass + +# FIXME: Add missing tests + +if __name__ == "__main__": + unittest.main() diff --git a/api/test/test_submodel_service.py b/api/test/test_submodel_service.py new file mode 100644 index 0000000..2a86c7e --- /dev/null +++ b/api/test/test_submodel_service.py @@ -0,0 +1,126 @@ +import os +import unittest +import json + +from fastapi.testclient import TestClient + +from server import app + +client = TestClient(app) +BASE_URL = "/api/v3.0/" + + +class TestSubmodelService(unittest.TestCase): + def setUp(self): + base_path = os.path.dirname(os.path.abspath(__file__)) + self.client = TestClient(app) + + with open(os.path.join(base_path, "examples/submodel", "submodel.json"), encoding="utf-8") as f: + self.submodel_example = json.load(f) + with open(os.path.join(base_path, "examples/submodel", "submodel_modified.json"), encoding="utf-8") as f: + self.test_submodel_modified = json.load(f) + with open(os.path.join(base_path, "examples/submodel", "submodel_element.json"), encoding="utf-8") as f: + self.submodel_element = json.load(f) + with open(os.path.join(base_path, "examples/submodel", "submodel_element_new.json"), encoding="utf-8") as f: + self.submodel_element_new = json.load(f) + with open(os.path.join(base_path, "examples/submodel", "submodel_with_new_element.json"), encoding="utf-8") as f: + self.submodel_with_new_element = json.load(f) + + self.submodel_example_id = self.submodel_example["id"] + self.invalid_submodel_id = "some_id" + self.invalid_submodel_element_id = "some_unknown_element_id" + + # Test submodel items + def test_get_all_submodels(self): + response = self.client.get(BASE_URL + "submodels") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), []) + + # Setup + self.client.post(BASE_URL + "submodels", json=self.submodel_example) + + response = self.client.get(BASE_URL + "submodels") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), [self.submodel_example]) + + # Teardown + self.client.delete(BASE_URL + "submodels/" + self.submodel_example_id) + + def test_post_submodel(self): + response = self.client.post(BASE_URL + "submodels", json=self.submodel_example) + self.assertEqual(response.status_code, 200) + self.client.delete(BASE_URL + "submodels/" + self.submodel_example_id) + + def test_get_specific_submodel(self): + # Setup + self.client.post(BASE_URL + "submodels", json=self.submodel_example) + + response_test_undefined = self.client.get(BASE_URL + "submodels/" + self.invalid_submodel_id) + response_test_entry = self.client.get(BASE_URL + "submodels/" + self.submodel_example_id) + + self.assertEqual(response_test_undefined.status_code, 404) + self.assertEqual(response_test_entry.status_code, 200) + self.assertEqual(response_test_entry.json(), self.submodel_example) + + # Teardown + self.client.delete(BASE_URL + "submodels/" + self.submodel_example_id + "/") + + def test_get_specific_submodel_element(self): + # Setup + self.client.post(BASE_URL + "submodels", json=self.submodel_example) + + response = self.client.get( + BASE_URL + "submodels/" + self.submodel_example_id + "/submodel-elements/" + "list_1") + self.assertEqual(response.status_code, 200) + + response_none = self.client.get(BASE_URL + "submodels/" + self.submodel_example_id + "/submodel-elements/" + + "list_undefined") + self.assertEqual(response_none.status_code, 404) + + # Teardown + self.client.delete(BASE_URL + "submodels/" + self.submodel_example_id + "/") + + def test_post_submodel_element(self): + # Setup + self.client.post(BASE_URL + "submodels", json=self.submodel_example) + + response = self.client.post(BASE_URL + "submodels/" + self.submodel_example_id + "/submodel-elements", + json=self.submodel_element_new) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), self.submodel_element_new) + + + response_already_exists = self.client.post(BASE_URL + "submodels/" + self.submodel_example_id + "/submodel-elements", + json=self.submodel_element_new) + self.assertEqual(response_already_exists.status_code, 400) + + new_submodel = self.client.get(BASE_URL + "submodels/" + self.submodel_example_id) + self.assertEqual(new_submodel.status_code, 200) + self.assertEqual(new_submodel.json(), self.submodel_with_new_element) + + def test_delete_submodel_element(self): + # Setup + self.client.post(BASE_URL + "submodels", json=self.submodel_example) + self.client.post(BASE_URL + "submodels/" + self.submodel_example_id + "/submodel-elements", + json=self.submodel_element_new) + + + response = self.client.delete( + BASE_URL + "submodels/" + self.submodel_example_id + "/submodel-elements/" + "some_property_123") + self.assertEqual(response.status_code, 200) + + response_not_found = self.client.delete( + BASE_URL + "submodels/" + self.submodel_example_id + "/submodel-elements/" + "some_property_unknown") + self.assertEqual(response_not_found.status_code, 404) + + new_submodel = self.client.get(BASE_URL + "submodels/" + self.submodel_example_id) + self.assertEqual(new_submodel.status_code, 200) + self.assertEqual(new_submodel.json(), self.submodel_example) + + # Teardown + self.client.delete(BASE_URL + "submodels/" + self.submodel_example_id + "/") + + + +if __name__ == "__main__": + unittest.main() diff --git a/sdk/basyx/object_store.py b/sdk/basyx/object_store.py index 73bc68d..b94c03d 100644 --- a/sdk/basyx/object_store.py +++ b/sdk/basyx/object_store.py @@ -10,7 +10,7 @@ """ import abc -from typing import MutableSet, Iterator, Generic, TypeVar, Dict, List, Optional, Iterable +from typing import MutableSet, Iterator, Generic, TypeVar, Dict, List, Optional, Iterable, Type from aas_core3.types import Identifiable, Referable, Class @@ -131,6 +131,15 @@ def discard(self, x: _IdentifiableType) -> None: if self._backend.get(x.id) is x: del self._backend[x.id] + def delete(self, x: str) -> None: + """ + Discard identifiable from the Objectstore + + :param x: Id of Identifiable instance to discard + """ + if self._backend.get(x): + del self._backend[x] + def get_referable(self, identifier: str, id_short: str) -> Referable: """ Get referable by using its id_short and the identifier of the identifiable it refers to @@ -181,6 +190,19 @@ def get_parent_referable(self, id_short: str) -> Referable: return element raise KeyError("there is no parent Identifiable for id_short {}".format(id_short)) + def get_identifiables_by_type(self, t: Type) -> List[Type]: + """ + Get all identifiables of the specified type. + + :param t: The Type to filter by. For example, we can filter by "aas_core3.types.ConceptDescription" + :return: The list of the filtered identifiables + """ + filtered_identifiables = [] + for identifiable in self._backend.values(): + if isinstance(identifiable, t): + filtered_identifiables.append(identifiable) + return filtered_identifiables + def __contains__(self, x: object) -> bool: if isinstance(x, str): return x in self._backend