From e223bd99fed1b0c9d1e871c680e1339ad472745a Mon Sep 17 00:00:00 2001 From: Joshua Benning Date: Wed, 23 Sep 2026 19:02:32 +0200 Subject: [PATCH 1/3] 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/3] 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/3] 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)