From e223bd99fed1b0c9d1e871c680e1339ad472745a Mon Sep 17 00:00:00 2001 From: Joshua Benning Date: Wed, 23 Sep 2026 19:02:32 +0200 Subject: [PATCH 1/4] server/app/itnerfaces/discovery.py: Adjust status codes Previous status code were not in agreement with the respective specification. This adjust HTTP status codes returned to align. --- server/app/interfaces/discovery.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/server/app/interfaces/discovery.py b/server/app/interfaces/discovery.py index 47e20205..86f0eedb 100644 --- a/server/app/interfaces/discovery.py +++ b/server/app/interfaces/discovery.py @@ -228,12 +228,15 @@ def post_all_asset_links_by_id( for asset_id in specific_asset_ids: self.persistent_store._add_aas_id_to_specific_asset_id(asset_id, aas_identifier) updated = {aas_identifier: self.persistent_store.get_all_specific_asset_ids_by_aas_id(aas_identifier)} - return response_t(updated) + return response_t(updated, status=201) + def delete_all_asset_links_by_id( self, request: Request, url_args: dict, response_t: Type[APIResponse], **_kwargs ) -> Response: aas_identifier = str(url_args["aas_id"]) + if aas_identifier not in self.persistent_store.aas_id_to_asset_ids: + raise werkzeug.exceptions.NotFound(f"No AssetAdministrationShell with {aas_identifier} found!") self.persistent_store.delete_specific_asset_ids_by_aas_id(aas_identifier) for key in list(self.persistent_store.asset_id_to_aas_ids.keys()): self.persistent_store.asset_id_to_aas_ids[key].discard(aas_identifier) From b29094241748db532c615b81ef576270ba8e7e67 Mon Sep 17 00:00:00 2001 From: Joshua Benning Date: Mon, 2 Nov 2026 23:58:05 +0100 Subject: [PATCH 2/4] server.discovery: Replace asset links on POST Previously, `post_all_asset_links_by_id` merged the posted asset links into any already stored for the AAS. The spec however defines `PostAllAssetLinksById` as "creates or replaces" all asset links associated, so the current behaviour is incorrect. Additionally both handlers maintained the reverse index independently. This change: - Folds reverse-index into two central methods - Remves the private helpers - Implemets replace semantics in post_all_asset_links_by_id by calling delete, then add - Flips the test previously covering the "wrong" behaviour --- server/app/interfaces/discovery.py | 57 ++++++++---------------- server/test/interfaces/test_discovery.py | 28 ++++++------ 2 files changed, 33 insertions(+), 52 deletions(-) diff --git a/server/app/interfaces/discovery.py b/server/app/interfaces/discovery.py index 86f0eedb..2646ccd3 100644 --- a/server/app/interfaces/discovery.py +++ b/server/app/interfaces/discovery.py @@ -44,17 +44,18 @@ def get_all_specific_asset_ids_by_aas_id(self, aas_id: model.Identifier) -> List return list(self.aas_id_to_asset_ids.get(aas_id, set())) def add_specific_asset_ids_to_aas(self, aas_id: model.Identifier, asset_ids: List[model.SpecificAssetId]) -> None: - - if aas_id not in self.aas_id_to_asset_ids: - self.aas_id_to_asset_ids[aas_id] = set() - - for asset in asset_ids: - self.aas_id_to_asset_ids[aas_id].add(asset) + self.aas_id_to_asset_ids.setdefault(aas_id, set()).update(asset_ids) + for asset_id in asset_ids: + self.asset_id_to_aas_ids.setdefault(asset_id, set()).add(aas_id) def delete_specific_asset_ids_by_aas_id(self, aas_id: model.Identifier) -> None: - key = aas_id - if key in self.aas_id_to_asset_ids: - del self.aas_id_to_asset_ids[key] + for asset_id in self.aas_id_to_asset_ids.pop(aas_id, set()): + aas_ids = self.asset_id_to_aas_ids.get(asset_id) + if aas_ids is None: + continue + aas_ids.discard(aas_id) + if not aas_ids: + del self.asset_id_to_aas_ids[asset_id] def search_aas_ids_by_asset_link(self, asset_link: server_model.AssetLink) -> List[model.Identifier]: result = [] @@ -63,16 +64,6 @@ def search_aas_ids_by_asset_link(self, asset_link: server_model.AssetLink) -> Li result.extend(list(aas_ids)) return result - def _add_aas_id_to_specific_asset_id(self, asset_id: model.SpecificAssetId, aas_id: model.Identifier) -> None: - if asset_id in self.asset_id_to_aas_ids: - self.asset_id_to_aas_ids[asset_id].add(aas_id) - else: - self.asset_id_to_aas_ids[asset_id] = {aas_id} - - def _delete_aas_id_from_specific_asset_ids(self, asset_id: model.SpecificAssetId, aas_id: model.Identifier) -> None: - if asset_id in self.asset_id_to_aas_ids: - self.asset_id_to_aas_ids[asset_id].discard(aas_id) - @classmethod def from_file(cls, filename: str) -> "DiscoveryStore": """ @@ -88,21 +79,13 @@ def from_file(cls, filename: str) -> "DiscoveryStore": discovery_store = DiscoveryStore() for aas_id, asset_ids in data.get("aas_id_to_asset_ids", {}).items(): - parsed_asset_ids = set() - - for asset_id in asset_ids: - if isinstance(asset_id, model.SpecificAssetId): - parsed_asset_id = asset_id - else: - parsed_asset_id = model.SpecificAssetId( - name=asset_id["name"], - value=asset_id["value"], - ) - - parsed_asset_ids.add(parsed_asset_id) - discovery_store._add_aas_id_to_specific_asset_id(parsed_asset_id, aas_id) - - discovery_store.aas_id_to_asset_ids[aas_id] = parsed_asset_ids + parsed_asset_ids = [ + asset_id + if isinstance(asset_id, model.SpecificAssetId) + else model.SpecificAssetId(name=asset_id["name"], value=asset_id["value"]) + for asset_id in asset_ids + ] + discovery_store.add_specific_asset_ids_to_aas(aas_id, parsed_asset_ids) return discovery_store @@ -224,13 +207,11 @@ def post_all_asset_links_by_id( ) -> Response: aas_identifier = str(url_args["aas_id"]) specific_asset_ids = HTTPApiDecoder.request_body_list(request, model.SpecificAssetId, False) + self.persistent_store.delete_specific_asset_ids_by_aas_id(aas_identifier) self.persistent_store.add_specific_asset_ids_to_aas(aas_identifier, specific_asset_ids) - for asset_id in specific_asset_ids: - self.persistent_store._add_aas_id_to_specific_asset_id(asset_id, aas_identifier) updated = {aas_identifier: self.persistent_store.get_all_specific_asset_ids_by_aas_id(aas_identifier)} return response_t(updated, status=201) - def delete_all_asset_links_by_id( self, request: Request, url_args: dict, response_t: Type[APIResponse], **_kwargs ) -> Response: @@ -238,8 +219,6 @@ def delete_all_asset_links_by_id( if aas_identifier not in self.persistent_store.aas_id_to_asset_ids: raise werkzeug.exceptions.NotFound(f"No AssetAdministrationShell with {aas_identifier} found!") self.persistent_store.delete_specific_asset_ids_by_aas_id(aas_identifier) - for key in list(self.persistent_store.asset_id_to_aas_ids.keys()): - self.persistent_store.asset_id_to_aas_ids[key].discard(aas_identifier) return response_t() diff --git a/server/test/interfaces/test_discovery.py b/server/test/interfaces/test_discovery.py index e5e551a1..056d0b48 100644 --- a/server/test/interfaces/test_discovery.py +++ b/server/test/interfaces/test_discovery.py @@ -71,8 +71,6 @@ def setUp(self) -> None: def register(self, aas_id: str, asset_ids: List[Tuple[str, str]]) -> None: assets: list[SpecificAssetId] = [SpecificAssetId(name, value) for name, value in asset_ids] self.store.add_specific_asset_ids_to_aas(aas_id, assets) - for asset in assets: - self.store._add_aas_id_to_specific_asset_id(asset, aas_id) # ====================================================================== /description @@ -233,8 +231,7 @@ def test_post_creates_asset_links(self) -> None: obj=[{"name": "serial", "value": "123"}], ) - # Spec documents 201 for creation; this server responds 200 with the updated mapping. - self.assert_ok(response) + self.assertEqual(201, response.status_code, msg=response.get_data(as_text=True)) body = self.format_client.parse_object(response) self.assertEqual([("serial", "123")], [(a["name"], a["value"]) for a in body[self.AAS_ID]]) self.assertEqual( @@ -249,22 +246,30 @@ def test_post_accepts_single_object_body(self) -> None: f"/lookup/shells/{base64url_encode(self.AAS_ID)}", obj={"name": "serial", "value": "123"} ) - self.assert_ok(response) + self.assertEqual(201, response.status_code, msg=response.get_data(as_text=True)) self.assertEqual( [("serial", "123")], [(a["name"], a["value"]) for a in self.format_client.parse_object(response)[self.AAS_ID]], ) - def test_post_is_additive(self) -> None: + def test_post_replaces_existing_asset_links(self) -> None: + # Spec: PostAllAssetLinksById "Creates or replaces all asset links associated to the + # Asset Administration Shell". Prior links must be dropped, not merged. self.register(self.AAS_ID, [("serial", "123")]) response = self.format_client.post( f"/lookup/shells/{base64url_encode(self.AAS_ID)}", obj=[{"name": "batch", "value": "xyz"}] ) - self.assert_ok(response) + self.assertEqual(201, response.status_code, msg=response.get_data(as_text=True)) stored = {(a["name"], a["value"]) for a in self.format_client.parse_object(response)[self.AAS_ID]} - self.assertEqual({("serial", "123"), ("batch", "xyz")}, stored) + self.assertEqual({("batch", "xyz")}, stored) + self.assertEqual( + [], + self.format_client.parse_collection( + self.format_client.post("/lookup/shellsByAssetLink", obj=[{"name": "serial", "value": "123"}]) + ), + ) def test_post_malformed_json_returns_400(self) -> None: response = self.format_client.post( @@ -292,11 +297,8 @@ def test_delete_removes_asset_links(self) -> None: ), ) - def test_delete_unknown_aas_returns_204(self) -> None: - # Spec allows 404 here; this server always returns 204. - response = self.format_client.delete(f"/lookup/shells/{base64url_encode(self.UNKNOWN_ID)}") - - self.assertEqual(204, response.status_code, msg=response.get_data(as_text=True)) + def test_delete_unknown_aas_returns_404(self) -> None: + self.assert_error(self.format_client.delete(f"/lookup/shells/{base64url_encode(self.UNKNOWN_ID)}"), 404) # ====================================================================== DiscoveryStore.from_file()/to_file() From 2c3829d6ff09cb04207f1af809a74c5ae6b03862 Mon Sep 17 00:00:00 2001 From: Joshua Benning Date: Tue, 3 Nov 2026 00:10:55 +0100 Subject: [PATCH 3/4] server.discovery: Fix Docker integration tests for new status codes This updates `test_asset_links_roundtrip` to assert 201 and makes tearDown handle 404. --- .../test_docker_integration_discovery.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/server/test/docker_integration/test_docker_integration_discovery.py b/server/test/docker_integration/test_docker_integration_discovery.py index fdb168d7..72959eed 100644 --- a/server/test/docker_integration/test_docker_integration_discovery.py +++ b/server/test/docker_integration/test_docker_integration_discovery.py @@ -46,7 +46,11 @@ def tearDown(self) -> None: delete_request = urllib.request.Request( f"{SERVER_BASE_URL}/lookup/shells/{base64url_encode(self.AAS_ID)}", method="DELETE" ) - urllib.request.urlopen(delete_request).close() + try: + urllib.request.urlopen(delete_request).close() + except urllib.error.HTTPError as exc: + if exc.code != 404: + raise # ------------------------------------------------------------------ GET /description @@ -68,7 +72,7 @@ def test_asset_link_roundtrip(self): aas_asset_links_path, data=body, headers={"Content-Type": "application/json"}, method="POST" ) with urllib.request.urlopen(post_request) as response: - self.assertEqual(200, response.status) + self.assertEqual(201, response.status) with urllib.request.urlopen(aas_asset_links_path) as response: self.assertEqual(200, response.status) From 63d48b66f3659c0baf218b66685457351993c4d8 Mon Sep 17 00:00:00 2001 From: s-heppner Date: Tue, 10 Nov 2026 08:52:32 +0100 Subject: [PATCH 4/4] server.discovery: Fix 404 message on DELETE Previously, `delete_all_asset_links_by_id` responded with "No AssetAdministrationShell with ... found!" when no asset links existed for the given AAS. The discovery service does not store shells, only their asset links, so the message was misleading. This change rewords the message to say that no asset links were found for the AAS. --- server/app/interfaces/discovery.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/app/interfaces/discovery.py b/server/app/interfaces/discovery.py index 2646ccd3..dddc6ec4 100644 --- a/server/app/interfaces/discovery.py +++ b/server/app/interfaces/discovery.py @@ -217,7 +217,7 @@ def delete_all_asset_links_by_id( ) -> Response: aas_identifier = str(url_args["aas_id"]) if aas_identifier not in self.persistent_store.aas_id_to_asset_ids: - raise werkzeug.exceptions.NotFound(f"No AssetAdministrationShell with {aas_identifier} found!") + raise werkzeug.exceptions.NotFound(f"No asset links found for AAS {aas_identifier}!") self.persistent_store.delete_specific_asset_ids_by_aas_id(aas_identifier) return response_t()