diff --git a/server/app/interfaces/base.py b/server/app/interfaces/base.py index d3231237..865b599b 100644 --- a/server/app/interfaces/base.py +++ b/server/app/interfaces/base.py @@ -10,7 +10,7 @@ import io import itertools import json -from typing import Any, Callable, Dict, Iterable, Iterator, List, Optional, Tuple, Type, TypeVar, Union +from typing import Any, Callable, Dict, Iterable, Iterator, List, Optional, Protocol, Tuple, Type, TypeVar, Union, runtime_checkable import werkzeug.exceptions import werkzeug.routing @@ -295,6 +295,24 @@ def http_exception_to_response( return response_type(result, status=exception.code, headers=headers) +@runtime_checkable +class QueryableObjectStore(Protocol): + """Structural protocol for object stores that support AASQL querying. + + Implement ``query(aasql_body, return_var)`` to advertise query support. + No explicit inheritance required — duck-typing via ``isinstance`` works at runtime. + """ + + def query(self, aasql_body: str, return_var: str) -> List[dict]: + """Execute an AASQL query and return matching serialized AAS objects. + + :param aasql_body: raw AASQL JSON string + :param return_var: Cypher return variable name (``"sm"`` or ``"aas"``) + :return: list of serialized AAS/Submodel dicts + """ + ... + + class ObjectStoreWSGIApp(BaseWSGIApp): object_store: AbstractObjectStore diff --git a/server/app/interfaces/repository.py b/server/app/interfaces/repository.py index c604408e..59622920 100644 --- a/server/app/interfaces/repository.py +++ b/server/app/interfaces/repository.py @@ -24,7 +24,7 @@ from app.interfaces.base import PagingMetadata from app.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, QueryableObjectStore, T from app.model import ServiceSpecificationProfileEnum, ServiceDescription SUPPORTED_PROFILES: ServiceDescription = ServiceDescription([ @@ -32,6 +32,8 @@ ServiceSpecificationProfileEnum.SUBMODEL_REPOSITORY_FULL, ServiceSpecificationProfileEnum.AAS_REPOSITORY_READ, ServiceSpecificationProfileEnum.SUBMODEL_REPOSITORY_READ, + # ServiceSpecificationProfileEnum.AAS_REPOSITORY_QUERY, + # ServiceSpecificationProfileEnum.SUBMODEL_REPOSITORY_QUERY, ]) @@ -49,6 +51,8 @@ def __init__( Submount( base_path, [ + Rule("/query/shells", methods=["POST"], endpoint=self.query_shells), + Rule("/query/submodels", methods=["POST"], endpoint=self.query_submodels), Rule("/serialization", methods=["GET"], endpoint=self.not_implemented), Rule("/description", methods=["GET"], endpoint=self.get_description), Rule("/shells", methods=["GET"], endpoint=self.get_aas_all), @@ -514,12 +518,42 @@ def _get_submodel_submodel_elements_id_short_path(self, url_args: Dict) -> model def _get_concept_description(self, url_args): return self._get_obj_ts(url_args["concept_id"], model.ConceptDescription) + def query_submodels(self, request: Request, url_args: Dict, **_kwargs) -> Response: + if not isinstance(self.object_store, QueryableObjectStore): + raise werkzeug.exceptions.NotImplemented("The current store does not support AASQL queries") + try: + results = self.object_store.query(request.get_data(as_text=True), "sm") + except (json.JSONDecodeError, ValueError) as e: + raise BadRequest(f"Invalid AASQL query: {e}") from e + return Response( + json.dumps({"paging_metadata": {"resultType": "Submodel"}, "result": results}), + content_type="application/json", + ) + + def query_shells(self, request: Request, url_args: Dict, **_kwargs) -> Response: + if not isinstance(self.object_store, QueryableObjectStore): + raise werkzeug.exceptions.NotImplemented("The current store does not support AASQL queries") + try: + results = self.object_store.query(request.get_data(as_text=True), "aas") + except (json.JSONDecodeError, ValueError) as e: + raise BadRequest(f"Invalid AASQL query: {e}") from e + return Response( + json.dumps({"paging_metadata": {"resultType": "AssetAdministrationShell"}, "result": results}), + content_type="application/json", + ) + # ------ all not implemented ROUTES ------- 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: - return response_t(SUPPORTED_PROFILES.to_dict()) + profiles = SUPPORTED_PROFILES.to_dict() + if isinstance(self.object_store, QueryableObjectStore): + profiles["profiles"].extend([ + ServiceSpecificationProfileEnum.AAS_REPOSITORY_QUERY.value, + ServiceSpecificationProfileEnum.SUBMODEL_REPOSITORY_QUERY.value, + ]) + return response_t(profiles) # ------ AAS REPO ROUTES ------- def get_aas_all(self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs) -> Response: @@ -796,23 +830,25 @@ def post_submodel_submodel_elements_id_short_path( def put_submodel_submodel_elements_id_short_path( self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs ) -> Response: - submodel_element = self._get_submodel_submodel_elements_id_short_path(url_args) + submodel = self._get_submodel(url_args) + submodel_element = self._get_nested_submodel_element(submodel, url_args["id_shorts"]) # TODO: remove the following type: ignore comment when mypy supports abstract types for Type[T] # see https://github.com/python/mypy/issues/5374 new_submodel_element = HTTPApiDecoder.request_body( request, model.SubmodelElement, is_stripped_request(request) # type: ignore[type-abstract] ) submodel_element.update_from(new_submodel_element) - self.object_store.commit(self._get_submodel(url_args)) + self.object_store.commit(submodel) return response_t() def delete_submodel_submodel_elements_id_short_path( self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs ) -> Response: - sm_or_se = self._get_submodel_or_nested_submodel_element(url_args) + submodel = self._get_submodel(url_args) + sm_or_se = self._get_nested_submodel_element(submodel, url_args["id_shorts"]) parent: model.UniqueIdShortNamespace = self._expect_namespace(sm_or_se.parent, sm_or_se.id_short) self._namespace_submodel_element_op(parent, parent.remove_referable, sm_or_se.id_short) - self.object_store.commit(self._get_submodel(url_args)) + self.object_store.commit(submodel) return response_t() def get_submodel_submodel_element_attachment(self, request: Request, url_args: Dict, **_kwargs) -> Response: diff --git a/server/app/model/provider.py b/server/app/model/provider.py index 472f0997..8bef91e5 100644 --- a/server/app/model/provider.py +++ b/server/app/model/provider.py @@ -52,6 +52,12 @@ def __iter__(self) -> Iterator[_DESCRIPTOR_TYPE]: return iter(self._backend.values()) +_DESCRIPTOR_KEY_TO_CLS = ( + ("assetAdministrationShellDescriptors", descriptor.AssetAdministrationShellDescriptor), + ("submodelDescriptors", descriptor.SubmodelDescriptor), +) + + def load_directory(directory: Union[Path, str]) -> DictDescriptorStore: """ Load AAS/Submodel descriptor JSON files from a directory into a :class:`DictDescriptorStore`. @@ -67,17 +73,12 @@ def load_directory(directory: Union[Path, str]) -> DictDescriptorStore: continue with open(file) as f: data = json.load(f, cls=adapter.ServerAASFromJsonDecoder) - for item in data.get("assetAdministrationShellDescriptors", []): - if isinstance(item, descriptor.AssetAdministrationShellDescriptor): - try: - store.add(item) - except KeyError: - pass - for item in data.get("submodelDescriptors", []): - if isinstance(item, descriptor.SubmodelDescriptor): - try: - store.add(item) - except KeyError: - pass + for key, cls in _DESCRIPTOR_KEY_TO_CLS: + for item in data.get(key, []): + if isinstance(item, cls): + try: + store.add(item) + except KeyError: + pass return store