From e81b501926de6550e48773881beaa4f2549e0539 Mon Sep 17 00:00:00 2001 From: zrgt Date: Tue, 12 May 2026 19:18:08 +0200 Subject: [PATCH 01/11] fix: align ServiceSpecificationProfileEnum with IDTA-01002 v3.1.2 spec - Fix SUBMODEL_READ/SUBMODEL_VALUE names (were swapped: SSP-002=Read, SSP-003=Value) - Rename AAS_REPOSITORY_BULK -> AAS_REPOSITORY_QUERY (SSP-003 is Query, no Bulk exists) - Rename SUBMODEL_REPOSITORY_BULK -> SUBMODEL_REPOSITORY_TEMPLATE (SSP-003 is Template) - Rename CONCEPT_DESCRIPTION_REPOSITORY_READ -> CONCEPT_DESCRIPTION_REPOSITORY_QUERY (SSP-002) - Remove CONCEPT_DESCRIPTION_REPOSITORY_BULK (SSP-003 does not exist in spec) - Add AAS_REGISTRY_QUERY (SSP-004), AAS_REGISTRY_MINIMAL_READ (SSP-005) - Add SUBMODEL_REGISTRY_QUERY (SSP-004) - Add SUBMODEL_REPOSITORY_TEMPLATE_READ (SSP-004), SUBMODEL_REPOSITORY_QUERY (SSP-005) --- server/app/model/service_specification.py | 27 ++++++++++++++++------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/server/app/model/service_specification.py b/server/app/model/service_specification.py index 00b4a5da..5181901a 100644 --- a/server/app/model/service_specification.py +++ b/server/app/model/service_specification.py @@ -5,8 +5,11 @@ class ServiceSpecificationProfileEnum(str, enum.Enum): """ Enumeration of all standardized Service Specification Profiles - from the AAS Part 2 API Specification (IDTA-01002-3-1). + from the AAS Part 2 API Specification (IDTA-01002-3-1-2). Each profile is uniquely identified by its semantic URI. + + Reference: https://industrialdigitaltwin.io/aas-specifications/IDTA-01002/v3.1.2/ + http-rest-api/service-specifications-and-profiles.html """ # --- Asset Administration Shell (AAS) --- @@ -15,8 +18,8 @@ class ServiceSpecificationProfileEnum(str, enum.Enum): # --- 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" + SUBMODEL_READ = "https://admin-shell.io/aas/API/3/1/SubmodelServiceSpecification/SSP-002" + SUBMODEL_VALUE = "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" @@ -28,32 +31,40 @@ class ServiceSpecificationProfileEnum(str, enum.Enum): "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" + AAS_REGISTRY_QUERY = \ + "https://admin-shell.io/aas/API/3/1/AssetAdministrationShellRegistryServiceSpecification/SSP-004" + AAS_REGISTRY_MINIMAL_READ = \ + "https://admin-shell.io/aas/API/3/1/AssetAdministrationShellRegistryServiceSpecification/SSP-005" # --- 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" + SUBMODEL_REGISTRY_QUERY = "https://admin-shell.io/aas/API/3/1/SubmodelRegistryServiceSpecification/SSP-004" # --- 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 = \ + AAS_REPOSITORY_QUERY = \ "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" + SUBMODEL_REPOSITORY_TEMPLATE = \ + "https://admin-shell.io/aas/API/3/1/SubmodelRepositoryServiceSpecification/SSP-003" + SUBMODEL_REPOSITORY_TEMPLATE_READ = \ + "https://admin-shell.io/aas/API/3/1/SubmodelRepositoryServiceSpecification/SSP-004" + SUBMODEL_REPOSITORY_QUERY = \ + "https://admin-shell.io/aas/API/3/1/SubmodelRepositoryServiceSpecification/SSP-005" # --- Concept Description Repository --- CONCEPT_DESCRIPTION_REPOSITORY_FULL = \ "https://admin-shell.io/aas/API/3/1/ConceptDescriptionRepositoryServiceSpecification/SSP-001" - CONCEPT_DESCRIPTION_REPOSITORY_READ = \ + CONCEPT_DESCRIPTION_REPOSITORY_QUERY = \ "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" From 63ed755fba0c455ccf448af9bb9f0dd678e0fb43 Mon Sep 17 00:00:00 2001 From: Igor Garmaev <56840636+zrgt@users.noreply.github.com> Date: Tue, 12 May 2026 17:35:33 +0200 Subject: [PATCH 02/11] fix: pass paging_metadata kwarg correctly in get_aas_submodel_refs and get_concept_description_all Closes #539 Both handlers called response_t(..., cursor=cursor) but APIResponse.__init__ accepts paging_metadata, not cursor. _get_slice() already returns Optional[PagingMetadata] as its second value, so the variable just needs to be passed under the correct keyword argument name. --- server/app/interfaces/repository.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/app/interfaces/repository.py b/server/app/interfaces/repository.py index 12d11209..5e038ee5 100644 --- a/server/app/interfaces/repository.py +++ b/server/app/interfaces/repository.py @@ -589,7 +589,7 @@ def get_aas_submodel_refs( submodel_refs: Iterator[model.ModelReference[model.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) + return response_t(list(submodel_refs), paging_metadata=cursor) def post_aas_submodel_refs(self, request: Request, url_args: Dict, response_t: Type[APIResponse], map_adapter: MapAdapter, **_kwargs) -> Response: @@ -948,7 +948,7 @@ def get_concept_description_all( ) -> Response: concept_descriptions: Iterator[model.ConceptDescription] = self._get_all_obj_of_type(model.ConceptDescription) concept_descriptions, cursor = self._get_slice(request, concept_descriptions) - return response_t(list(concept_descriptions), cursor=cursor, stripped=is_stripped_request(request)) + return response_t(list(concept_descriptions), paging_metadata=cursor, stripped=is_stripped_request(request)) def post_concept_description( self, request: Request, url_args: Dict, response_t: Type[APIResponse], map_adapter: MapAdapter From 1a609627b070c85cab1495aa9fa65223c952dc44 Mon Sep 17 00:00:00 2001 From: zrgt Date: Tue, 12 May 2026 17:48:01 +0200 Subject: [PATCH 03/11] fix: fix paging_metadata kwarg in discovery and registry handlers --- server/app/interfaces/discovery.py | 4 ++-- server/app/interfaces/registry.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/server/app/interfaces/discovery.py b/server/app/interfaces/discovery.py index a6e43361..5d33f4e8 100644 --- a/server/app/interfaces/discovery.py +++ b/server/app/interfaces/discovery.py @@ -161,7 +161,7 @@ def get_all_aas_ids_by_asset_link( matching_aas_keys.update(aas_keys) paginated_slice, cursor = self._get_slice(request, list(matching_aas_keys)) - return response_t(list(paginated_slice), cursor=cursor) + return response_t(list(paginated_slice), paging_metadata=cursor) def search_all_aas_ids_by_asset_link( self, request: Request, url_args: dict, response_t: Type[APIResponse], **_kwargs @@ -172,7 +172,7 @@ def search_all_aas_ids_by_asset_link( aas_keys = self.persistent_store.search_aas_ids_by_asset_link(asset_link) matching_aas_keys.update(aas_keys) paginated_slice, cursor = self._get_slice(request, list(matching_aas_keys)) - return response_t(list(paginated_slice), cursor=cursor) + return response_t(list(paginated_slice), paging_metadata=cursor) def get_all_specific_asset_ids_by_aas_id( self, request: Request, url_args: dict, response_t: Type[APIResponse], **_kwargs diff --git a/server/app/interfaces/registry.py b/server/app/interfaces/registry.py index 8494b629..72581b51 100644 --- a/server/app/interfaces/registry.py +++ b/server/app/interfaces/registry.py @@ -225,7 +225,7 @@ def get_all_submodel_descriptors_through_superpath( ) -> Response: aas_descriptor = self._get_aas_descriptor(url_args) submodel_descriptors, cursor = self._get_slice(request, aas_descriptor.submodel_descriptors) - return response_t(list(submodel_descriptors), cursor=cursor) + return response_t(list(submodel_descriptors), paging_metadata=cursor) def get_submodel_descriptor_by_id_through_superpath( self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs From af37711a3f04a3574b53642f8fc3badcfd32a689 Mon Sep 17 00:00:00 2001 From: zrgt Date: Mon, 11 May 2026 17:20:24 +0200 Subject: [PATCH 04/11] Add Neo4j backend and AASQL query endpoints for aas-demonstrator - server/app/backend/neo4j.py: factory building Neo4jObjectStore from env vars (NEO4J_URI, NEO4J_USER, NEO4J_PASSWORD) - run_repository.py: detect STORAGE_BACKEND=neo4j, load input files into Neo4j on startup via load_directory(); skip already-existing identifiables - repository.py: POST /query/submodels (SSP-005) and POST /query/shells (SSP-003) accept AASQL JSON, compile to Cypher via convert_aasql_to_cypher(), execute against Neo4j, return QueryResultSubmodel / QueryResultAssetAdministrationShell - service_specification.py: add AAS_REPOSITORY_QUERY and SUBMODEL_REPOSITORY_QUERY profile enum values; include both in SUPPORTED_PROFILES --- server/app/backend/neo4j.py | 14 ++++++++ server/app/interfaces/repository.py | 49 +++++++++++++++++++++++++++ server/app/services/run_repository.py | 39 +++++++++++++++++---- 3 files changed, 96 insertions(+), 6 deletions(-) create mode 100644 server/app/backend/neo4j.py diff --git a/server/app/backend/neo4j.py b/server/app/backend/neo4j.py new file mode 100644 index 00000000..d5b6b524 --- /dev/null +++ b/server/app/backend/neo4j.py @@ -0,0 +1,14 @@ +# Copyright (c) 2026 the Eclipse BaSyx Authors +# +# This program and the accompanying materials are made available under the terms of the MIT License, available in +# the LICENSE file of this project. +# +# SPDX-License-Identifier: MIT + +from aas_mapping.aas_neo4j_adapter.aas_neo4j_client import AASNeo4JClient, AAS_NEO4J_MODEL_CONFIG +from aas_mapping.aas_neo4j_adapter.neo_aas_object_store import Neo4jObjectStore + + +def build_neo4j_object_store(uri: str, user: str, password: str) -> Neo4jObjectStore: + client = AASNeo4JClient(uri=uri, user=user, password=password, model_config=AAS_NEO4J_MODEL_CONFIG) + return Neo4jObjectStore(client=client) diff --git a/server/app/interfaces/repository.py b/server/app/interfaces/repository.py index 5e038ee5..9fa19534 100644 --- a/server/app/interfaces/repository.py +++ b/server/app/interfaces/repository.py @@ -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), @@ -517,6 +521,51 @@ 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) + # ------ AASQL QUERY ROUTES ------- + def _query_neo4j(self, request: Request, return_var: str) -> List[Dict]: + try: + from aas_mapping.aas_neo4j_adapter.neo_aas_object_store import Neo4jObjectStore + from aas_mapping.aas_neo4j_adapter.querification.aasql_to_cypher import convert_aasql_to_cypher + except ImportError as e: + raise werkzeug.exceptions.NotImplemented("aas_mapping package required for query endpoints") from e + + if not isinstance(self.object_store, Neo4jObjectStore): + raise werkzeug.exceptions.NotImplemented( + "Query endpoints require Neo4j backend (set STORAGE_BACKEND=neo4j)" + ) + + client = self.object_store._client + try: + cypher = convert_aasql_to_cypher(request.get_data(as_text=True)) + except (json.JSONDecodeError, ValueError) as e: + raise BadRequest(f"Invalid AASQL query: {e}") from e + + records = client.execute_clause(cypher) or [] + results = [] + for record in records: + if return_var in record.keys(): + obj_id = record[return_var]["id"] + elif f"{return_var}.id" in record.keys(): + obj_id = record[f"{return_var}.id"] + else: + continue + results.append(client.get_identifiable(obj_id)) + return results + + def query_submodels(self, request: Request, url_args: Dict, **_kwargs) -> Response: + results = self._query_neo4j(request, "sm") + 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: + results = self._query_neo4j(request, "aas") + 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!") diff --git a/server/app/services/run_repository.py b/server/app/services/run_repository.py index 04e6c744..b69e412d 100644 --- a/server/app/services/run_repository.py +++ b/server/app/services/run_repository.py @@ -15,6 +15,7 @@ from basyx.aas.adapter import load_directory from basyx.aas.adapter.aasx import DictSupplementaryFileContainer from basyx.aas.backend.local_file import LocalFileIdentifiableStore +from basyx.aas.model import AbstractObjectStore from basyx.aas.model.provider import DictIdentifiableStore from app.interfaces.repository import WSGIApp @@ -42,7 +43,7 @@ def setup_logger() -> logging.Logger: def build_storage( env_input: str, env_storage: str, env_storage_persistency: bool, env_storage_overwrite: bool, logger: logging.Logger -) -> Tuple[Union[DictIdentifiableStore, LocalFileIdentifiableStore], DictSupplementaryFileContainer]: +) -> Tuple[AbstractObjectStore, DictSupplementaryFileContainer]: """ Configure the server's storage according to the given start-up settings. @@ -53,12 +54,37 @@ def build_storage( :param env_storage_overwrite: Flag to overwrite existing :class:`Identifiables ` in the :class:`~basyx.aas.backend.local_file.LocalFileIdentifiableStore` if persistent storage is enabled :param logger: :class:`~logging.Logger` used for start-up diagnostics - :return: Tuple consisting of a :class:`~basyx.aas.model.provider.DictIdentifiableStore` if persistent storage is - disabled or a :class:`~basyx.aas.backend.local_file.LocalFileIdentifiableStore` if persistent storage is - enabled and a :class:`~basyx.aas.adapter.aasx.DictSupplementaryFileContainer` as storage for - :class:`~interfaces.repository.WSGIApp` + :return: Tuple consisting of a storage backend and a + :class:`~basyx.aas.adapter.aasx.DictSupplementaryFileContainer` for :class:`~interfaces.repository.WSGIApp` """ + env_storage_backend = os.getenv("STORAGE_BACKEND", "memory").lower() + + if env_storage_backend == "neo4j": + from app.backend.neo4j import build_neo4j_object_store + neo4j_uri = os.getenv("NEO4J_URI", "bolt://localhost:7687") + neo4j_user = os.getenv("NEO4J_USER", "neo4j") + neo4j_password = os.getenv("NEO4J_PASSWORD", "") + logger.info('Using Neo4j backend at "%s" (user=%s)', neo4j_uri, neo4j_user) + store = build_neo4j_object_store(neo4j_uri, neo4j_user, neo4j_password) + if os.path.isdir(env_input): + input_objects, input_supp_files = load_directory(env_input) + loaded, skipped = 0, 0 + for obj in input_objects: + try: + store.add(obj) + loaded += 1 + except KeyError: + skipped += 1 + logger.info( + 'Loaded %d identifiable(s) from "%s" into Neo4j (%d skipped, already existed)', + loaded, env_input, skipped, + ) + return store, input_supp_files + else: + logger.warning('INPUT directory "%s" not found, starting empty Neo4j store', env_input) + return store, DictSupplementaryFileContainer() + if env_storage_persistency: storage_files = LocalFileIdentifiableStore(env_storage) storage_files.check_directory(create=True) @@ -109,8 +135,9 @@ def build_storage( wsgi_optparams = {"base_path": env_api_base_path} if env_api_base_path else {} logger.info( - 'Loaded settings API_BASE_PATH="%s", INPUT="%s", STORAGE="%s", PERSISTENCY=%s, OVERWRITE=%s', + 'Loaded settings API_BASE_PATH="%s", STORAGE_BACKEND="%s", INPUT="%s", STORAGE="%s", PERSISTENCY=%s, OVERWRITE=%s', env_api_base_path or "", + os.getenv("STORAGE_BACKEND", "memory"), env_input, env_storage, env_storage_persistency, From 00bb629c4a941831c3276f96f0ecb6076e023c1f Mon Sep 17 00:00:00 2001 From: zrgt Date: Mon, 11 May 2026 18:07:01 +0200 Subject: [PATCH 05/11] Fix per-file loading in Neo4j build_storage to handle duplicate IDs load_directory() accumulates all files into one DictIdentifiableStore which raises KeyError on duplicate identifiable IDs. Load each file into a separate store, then add to Neo4j skipping duplicates. --- server/app/services/run_repository.py | 34 +++++++++++++++++++++------ 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/server/app/services/run_repository.py b/server/app/services/run_repository.py index b69e412d..f7873567 100644 --- a/server/app/services/run_repository.py +++ b/server/app/services/run_repository.py @@ -68,14 +68,34 @@ def build_storage( logger.info('Using Neo4j backend at "%s" (user=%s)', neo4j_uri, neo4j_user) store = build_neo4j_object_store(neo4j_uri, neo4j_user, neo4j_password) if os.path.isdir(env_input): - input_objects, input_supp_files = load_directory(env_input) + from pathlib import Path + from basyx.aas.adapter import read_aas_json_file_into, read_aas_xml_file_into + from basyx.aas.adapter.aasx import AASXReader + from basyx.aas.model.provider import DictIdentifiableStore as _FileStore + input_supp_files = DictSupplementaryFileContainer() loaded, skipped = 0, 0 - for obj in input_objects: - try: - store.add(obj) - loaded += 1 - except KeyError: - skipped += 1 + for file in Path(env_input).iterdir(): + if not file.is_file(): + continue + file_store: _FileStore = _FileStore() + suffix = file.suffix.lower() + if suffix == ".json": + with open(file) as f: + read_aas_json_file_into(file_store, f) + elif suffix == ".xml": + with open(file) as f: + read_aas_xml_file_into(file_store, f) + elif suffix == ".aasx": + with AASXReader(file) as reader: + reader.read_into(object_store=file_store, file_store=input_supp_files) + else: + continue + for obj in file_store: + try: + store.add(obj) + loaded += 1 + except KeyError: + skipped += 1 logger.info( 'Loaded %d identifiable(s) from "%s" into Neo4j (%d skipped, already existed)', loaded, env_input, skipped, From 120355b55f06433490265112a0b75813cbabbb79 Mon Sep 17 00:00:00 2001 From: zrgt Date: Mon, 11 May 2026 23:34:21 +0200 Subject: [PATCH 06/11] Add ServiceUnavailable retry loop in Neo4j build_storage Docker container DNS resolution may transiently fail with [Errno -3] immediately after container start before the network namespace is fully configured. Retry up to 10 times with 3s delay on ServiceUnavailable before giving up. --- server/app/services/run_repository.py | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/server/app/services/run_repository.py b/server/app/services/run_repository.py index f7873567..1c5066c5 100644 --- a/server/app/services/run_repository.py +++ b/server/app/services/run_repository.py @@ -10,6 +10,7 @@ import logging import os +import time from typing import Tuple, Union from basyx.aas.adapter import load_directory @@ -72,8 +73,10 @@ def build_storage( from basyx.aas.adapter import read_aas_json_file_into, read_aas_xml_file_into from basyx.aas.adapter.aasx import AASXReader from basyx.aas.model.provider import DictIdentifiableStore as _FileStore + from neo4j.exceptions import ServiceUnavailable input_supp_files = DictSupplementaryFileContainer() loaded, skipped = 0, 0 + input_objects: list = [] for file in Path(env_input).iterdir(): if not file.is_file(): continue @@ -90,12 +93,22 @@ def build_storage( reader.read_into(object_store=file_store, file_store=input_supp_files) else: continue - for obj in file_store: - try: - store.add(obj) - loaded += 1 - except KeyError: - skipped += 1 + input_objects.extend(file_store) + for attempt in range(10): + try: + for obj in input_objects: + try: + store.add(obj) + loaded += 1 + except KeyError: + skipped += 1 + break + except ServiceUnavailable as exc: + if attempt == 9: + raise + logger.warning("Neo4j not reachable (%s), retrying in 3s (%d/9)...", exc, attempt + 1) + loaded, skipped = 0, 0 + time.sleep(3) logger.info( 'Loaded %d identifiable(s) from "%s" into Neo4j (%d skipped, already existed)', loaded, env_input, skipped, From cc6f30cdbdbdb78cd34d33940474c1a43f701d40 Mon Sep 17 00:00:00 2001 From: zrgt Date: Tue, 12 May 2026 20:26:11 +0200 Subject: [PATCH 07/11] Remove Neo4j/AASQL code from basyx-python-sdk; delegate to aas4graph plugin Neo4j-specific logic moved to aas_mapping/server/ in aas4graph (neo4aas): - Delete server/app/backend/neo4j.py (now in aas4graph, copied by Dockerfile) - run_repository.py: replace inline Neo4j block with plugin call build_neo4j_storage(env_input, logger) from app.backend.neo4j - repository.py: remove _query_neo4j / query_shells / query_submodels methods, remove /query/shells and /query/submodels URL rules, remove AAS_REPOSITORY_QUERY / SUBMODEL_REPOSITORY_QUERY from SUPPORTED_PROFILES (advertised only by Neo4jWSGIApp in aas4graph) --- server/app/backend/neo4j.py | 14 ------- server/app/interfaces/repository.py | 49 ---------------------- server/app/services/run_repository.py | 58 +-------------------------- 3 files changed, 2 insertions(+), 119 deletions(-) delete mode 100644 server/app/backend/neo4j.py diff --git a/server/app/backend/neo4j.py b/server/app/backend/neo4j.py deleted file mode 100644 index d5b6b524..00000000 --- a/server/app/backend/neo4j.py +++ /dev/null @@ -1,14 +0,0 @@ -# Copyright (c) 2026 the Eclipse BaSyx Authors -# -# This program and the accompanying materials are made available under the terms of the MIT License, available in -# the LICENSE file of this project. -# -# SPDX-License-Identifier: MIT - -from aas_mapping.aas_neo4j_adapter.aas_neo4j_client import AASNeo4JClient, AAS_NEO4J_MODEL_CONFIG -from aas_mapping.aas_neo4j_adapter.neo_aas_object_store import Neo4jObjectStore - - -def build_neo4j_object_store(uri: str, user: str, password: str) -> Neo4jObjectStore: - client = AASNeo4JClient(uri=uri, user=user, password=password, model_config=AAS_NEO4J_MODEL_CONFIG) - return Neo4jObjectStore(client=client) diff --git a/server/app/interfaces/repository.py b/server/app/interfaces/repository.py index 9fa19534..5e038ee5 100644 --- a/server/app/interfaces/repository.py +++ b/server/app/interfaces/repository.py @@ -32,8 +32,6 @@ ServiceSpecificationProfileEnum.SUBMODEL_REPOSITORY_FULL, ServiceSpecificationProfileEnum.AAS_REPOSITORY_READ, ServiceSpecificationProfileEnum.SUBMODEL_REPOSITORY_READ, - ServiceSpecificationProfileEnum.AAS_REPOSITORY_QUERY, - ServiceSpecificationProfileEnum.SUBMODEL_REPOSITORY_QUERY, ]) @@ -51,8 +49,6 @@ 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), @@ -521,51 +517,6 @@ 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) - # ------ AASQL QUERY ROUTES ------- - def _query_neo4j(self, request: Request, return_var: str) -> List[Dict]: - try: - from aas_mapping.aas_neo4j_adapter.neo_aas_object_store import Neo4jObjectStore - from aas_mapping.aas_neo4j_adapter.querification.aasql_to_cypher import convert_aasql_to_cypher - except ImportError as e: - raise werkzeug.exceptions.NotImplemented("aas_mapping package required for query endpoints") from e - - if not isinstance(self.object_store, Neo4jObjectStore): - raise werkzeug.exceptions.NotImplemented( - "Query endpoints require Neo4j backend (set STORAGE_BACKEND=neo4j)" - ) - - client = self.object_store._client - try: - cypher = convert_aasql_to_cypher(request.get_data(as_text=True)) - except (json.JSONDecodeError, ValueError) as e: - raise BadRequest(f"Invalid AASQL query: {e}") from e - - records = client.execute_clause(cypher) or [] - results = [] - for record in records: - if return_var in record.keys(): - obj_id = record[return_var]["id"] - elif f"{return_var}.id" in record.keys(): - obj_id = record[f"{return_var}.id"] - else: - continue - results.append(client.get_identifiable(obj_id)) - return results - - def query_submodels(self, request: Request, url_args: Dict, **_kwargs) -> Response: - results = self._query_neo4j(request, "sm") - 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: - results = self._query_neo4j(request, "aas") - 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!") diff --git a/server/app/services/run_repository.py b/server/app/services/run_repository.py index 1c5066c5..31332182 100644 --- a/server/app/services/run_repository.py +++ b/server/app/services/run_repository.py @@ -10,7 +10,6 @@ import logging import os -import time from typing import Tuple, Union from basyx.aas.adapter import load_directory @@ -62,61 +61,8 @@ def build_storage( env_storage_backend = os.getenv("STORAGE_BACKEND", "memory").lower() if env_storage_backend == "neo4j": - from app.backend.neo4j import build_neo4j_object_store - neo4j_uri = os.getenv("NEO4J_URI", "bolt://localhost:7687") - neo4j_user = os.getenv("NEO4J_USER", "neo4j") - neo4j_password = os.getenv("NEO4J_PASSWORD", "") - logger.info('Using Neo4j backend at "%s" (user=%s)', neo4j_uri, neo4j_user) - store = build_neo4j_object_store(neo4j_uri, neo4j_user, neo4j_password) - if os.path.isdir(env_input): - from pathlib import Path - from basyx.aas.adapter import read_aas_json_file_into, read_aas_xml_file_into - from basyx.aas.adapter.aasx import AASXReader - from basyx.aas.model.provider import DictIdentifiableStore as _FileStore - from neo4j.exceptions import ServiceUnavailable - input_supp_files = DictSupplementaryFileContainer() - loaded, skipped = 0, 0 - input_objects: list = [] - for file in Path(env_input).iterdir(): - if not file.is_file(): - continue - file_store: _FileStore = _FileStore() - suffix = file.suffix.lower() - if suffix == ".json": - with open(file) as f: - read_aas_json_file_into(file_store, f) - elif suffix == ".xml": - with open(file) as f: - read_aas_xml_file_into(file_store, f) - elif suffix == ".aasx": - with AASXReader(file) as reader: - reader.read_into(object_store=file_store, file_store=input_supp_files) - else: - continue - input_objects.extend(file_store) - for attempt in range(10): - try: - for obj in input_objects: - try: - store.add(obj) - loaded += 1 - except KeyError: - skipped += 1 - break - except ServiceUnavailable as exc: - if attempt == 9: - raise - logger.warning("Neo4j not reachable (%s), retrying in 3s (%d/9)...", exc, attempt + 1) - loaded, skipped = 0, 0 - time.sleep(3) - logger.info( - 'Loaded %d identifiable(s) from "%s" into Neo4j (%d skipped, already existed)', - loaded, env_input, skipped, - ) - return store, input_supp_files - else: - logger.warning('INPUT directory "%s" not found, starting empty Neo4j store', env_input) - return store, DictSupplementaryFileContainer() + from app.backend.neo4j import build_neo4j_storage + return build_neo4j_storage(env_input, logger) if env_storage_persistency: storage_files = LocalFileIdentifiableStore(env_storage) From 970750a2e4725d552bdea3c0df6d32c1b7a98997 Mon Sep 17 00:00:00 2001 From: zrgt Date: Tue, 12 May 2026 20:50:02 +0200 Subject: [PATCH 08/11] Remove STORAGE_BACKEND dispatch from run_repository.py; Neo4j handled by aas4graph entrypoint --- server/app/services/run_repository.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/server/app/services/run_repository.py b/server/app/services/run_repository.py index 31332182..c0a66eab 100644 --- a/server/app/services/run_repository.py +++ b/server/app/services/run_repository.py @@ -58,12 +58,6 @@ def build_storage( :class:`~basyx.aas.adapter.aasx.DictSupplementaryFileContainer` for :class:`~interfaces.repository.WSGIApp` """ - env_storage_backend = os.getenv("STORAGE_BACKEND", "memory").lower() - - if env_storage_backend == "neo4j": - from app.backend.neo4j import build_neo4j_storage - return build_neo4j_storage(env_input, logger) - if env_storage_persistency: storage_files = LocalFileIdentifiableStore(env_storage) storage_files.check_directory(create=True) @@ -114,9 +108,8 @@ def build_storage( wsgi_optparams = {"base_path": env_api_base_path} if env_api_base_path else {} logger.info( - 'Loaded settings API_BASE_PATH="%s", STORAGE_BACKEND="%s", INPUT="%s", STORAGE="%s", PERSISTENCY=%s, OVERWRITE=%s', + 'Loaded settings API_BASE_PATH="%s", INPUT="%s", STORAGE="%s", PERSISTENCY=%s, OVERWRITE=%s', env_api_base_path or "", - os.getenv("STORAGE_BACKEND", "memory"), env_input, env_storage, env_storage_persistency, From b705fdb2743e70382bba8fe893a33eaf006ee95e Mon Sep 17 00:00:00 2001 From: zrgt Date: Tue, 12 May 2026 21:01:18 +0200 Subject: [PATCH 09/11] Revert run_repository.py to upstream develop state --- server/app/services/run_repository.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/server/app/services/run_repository.py b/server/app/services/run_repository.py index c0a66eab..04e6c744 100644 --- a/server/app/services/run_repository.py +++ b/server/app/services/run_repository.py @@ -15,7 +15,6 @@ from basyx.aas.adapter import load_directory from basyx.aas.adapter.aasx import DictSupplementaryFileContainer from basyx.aas.backend.local_file import LocalFileIdentifiableStore -from basyx.aas.model import AbstractObjectStore from basyx.aas.model.provider import DictIdentifiableStore from app.interfaces.repository import WSGIApp @@ -43,7 +42,7 @@ def setup_logger() -> logging.Logger: def build_storage( env_input: str, env_storage: str, env_storage_persistency: bool, env_storage_overwrite: bool, logger: logging.Logger -) -> Tuple[AbstractObjectStore, DictSupplementaryFileContainer]: +) -> Tuple[Union[DictIdentifiableStore, LocalFileIdentifiableStore], DictSupplementaryFileContainer]: """ Configure the server's storage according to the given start-up settings. @@ -54,8 +53,10 @@ def build_storage( :param env_storage_overwrite: Flag to overwrite existing :class:`Identifiables ` in the :class:`~basyx.aas.backend.local_file.LocalFileIdentifiableStore` if persistent storage is enabled :param logger: :class:`~logging.Logger` used for start-up diagnostics - :return: Tuple consisting of a storage backend and a - :class:`~basyx.aas.adapter.aasx.DictSupplementaryFileContainer` for :class:`~interfaces.repository.WSGIApp` + :return: Tuple consisting of a :class:`~basyx.aas.model.provider.DictIdentifiableStore` if persistent storage is + disabled or a :class:`~basyx.aas.backend.local_file.LocalFileIdentifiableStore` if persistent storage is + enabled and a :class:`~basyx.aas.adapter.aasx.DictSupplementaryFileContainer` as storage for + :class:`~interfaces.repository.WSGIApp` """ if env_storage_persistency: From 34be932363074e97fad8b3921687a49015b2d9f5 Mon Sep 17 00:00:00 2001 From: zrgt Date: Tue, 12 May 2026 23:32:30 +0200 Subject: [PATCH 10/11] Add QueryableObjectStore protocol and /query/shells, /query/submodels routes Introduce @runtime_checkable QueryableObjectStore Protocol in base.py so object stores can opt into AASQL query support without explicit inheritance. WSGIApp gains POST /query/shells and /query/submodels endpoints that delegate to object_store.query(). get_description() dynamically advertises query profiles when the store satisfies the protocol. --- server/app/interfaces/base.py | 20 ++++++++++++++- server/app/interfaces/repository.py | 38 +++++++++++++++++++++++++++-- 2 files changed, 55 insertions(+), 3 deletions(-) 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 5e038ee5..6cfce043 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), @@ -517,12 +521,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: From fcd3371e82985ad5de882cffe28d4eaf846a6102 Mon Sep 17 00:00:00 2001 From: zrgt Date: Tue, 12 May 2026 23:47:02 +0200 Subject: [PATCH 11/11] Fix load_directory to handle Descriptor objects (not Identifiable) --- server/app/model/provider.py | 44 ++++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/server/app/model/provider.py b/server/app/model/provider.py index 97067e7d..3f529118 100644 --- a/server/app/model/provider.py +++ b/server/app/model/provider.py @@ -1,10 +1,10 @@ +import json from pathlib import Path from typing import IO, Dict, Iterable, Iterator, Union from basyx.aas import model from basyx.aas.model import provider as sdk_provider -import app.adapter as adapter from app.model import descriptor PathOrIO = Union[Path, IO] @@ -51,29 +51,35 @@ 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: """ - Create a new :class:`~basyx.aas.model.provider.DictIdentifiableStore` and use it to load Asset Administration Shell - and Submodel files in ``AASX``, ``JSON`` and ``XML`` format from a given directory into memory. Additionally, load - all embedded supplementary files into a new :class:`~basyx.aas.adapter.aasx.DictSupplementaryFileContainer`. - - :param directory: :class:`~pathlib.Path` or ``str`` pointing to the directory containing all Asset Administration - Shell and Submodel files to load - :return: Tuple consisting of a :class:`~basyx.aas.model.provider.DictIdentifiableStore` and a - :class:`~basyx.aas.adapter.aasx.DictSupplementaryFileContainer` containing all loaded data - """ + Load AAS/Submodel descriptor JSON files from a directory into a :class:`DictDescriptorStore`. - dict_descriptor_store: DictDescriptorStore = DictDescriptorStore() + :param directory: Path to the directory containing JSON descriptor files + :return: Populated :class:`DictDescriptorStore` + """ + from app.adapter import ServerAASFromJsonDecoder + store = DictDescriptorStore() directory = Path(directory) for file in directory.iterdir(): - if not file.is_file(): + if not file.is_file() or file.suffix.lower() != ".json": continue - - suffix = file.suffix.lower() - if suffix == ".json": - with open(file) as f: - adapter.read_server_aas_json_file_into(dict_descriptor_store, f) - - return dict_descriptor_store + with open(file) as f: + data = json.load(f, cls=ServerAASFromJsonDecoder) + 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