diff --git a/basyx.aasrepository/basyx.aasrepository-feature-registry-integration/Readme.md b/basyx.aasrepository/basyx.aasrepository-feature-registry-integration/Readme.md index 7a39bd2c2..cf7f50774 100644 --- a/basyx.aasrepository/basyx.aasrepository-feature-registry-integration/Readme.md +++ b/basyx.aasrepository/basyx.aasrepository-feature-registry-integration/Readme.md @@ -1,6 +1,6 @@ # AssetAdministrationShell Repository - Registry Integration -This feature automatically integrates the Descriptor with the Registry while creation of the Shell at Repository.
-It also automatically removes the Descriptor from the Registry when the Shell is removed from the Repository. +This feature automatically creates and updates the Descriptor in the Registry when a Shell or its Asset Information changes in the Repository.
+It also automatically removes the Descriptor from the Registry when the Shell is removed from the Repository. To enable this feature, the following two properties should be configured: @@ -31,4 +31,14 @@ basyx.aasrepository.feature.registryintegration.authorization.client-secret = new Object()); } @Override @@ -79,31 +84,45 @@ public AssetAdministrationShell getAas(String shellId) throws ElementDoesNotExis @Override public void createAas(AssetAdministrationShell shell) throws CollidingIdentifierException { - AssetAdministrationShellDescriptor descriptor = new AasDescriptorFactory(aasRepositoryRegistryLink.getAasRepositoryBaseURLs(), attributeMapper).create(shell); + synchronized (getShellMutationLock(shell.getId())) { + AssetAdministrationShellDescriptor descriptor = createDescriptor(shell); - decorated.createAas(shell); + decorated.createAas(shell); - boolean registrationSuccessful = false; + boolean registrationSuccessful = false; - try { - registerAas(descriptor); - registrationSuccessful = true; - } finally { - if (!registrationSuccessful) - decorated.deleteAas(shell.getId()); + try { + registerAas(descriptor); + registrationSuccessful = true; + } finally { + if (!registrationSuccessful) + decorated.deleteAas(shell.getId()); + } } } @Override public void updateAas(String shellId, AssetAdministrationShell shell) { - decorated.updateAas(shellId, shell); + synchronized (getShellMutationLock(shellId)) { + AssetAdministrationShell previousShell = decorated.getAas(shellId); + + decorated.updateAas(shellId, shell); + + try { + updateDescriptor(shellId, shell); + } catch (RuntimeException e) { + rollbackAasUpdate(shellId, previousShell, e); + throw e; + } + } } @Override public void deleteAas(String shellId) { - deleteFromRegistry(shellId); - - decorated.deleteAas(shellId); + synchronized (getShellMutationLock(shellId)) { + deleteFromRegistry(shellId); + decorated.deleteAas(shellId); + } } @Override @@ -118,17 +137,32 @@ public CursorResult> getSubmodelReferences(String shellId, Pagin @Override public void addSubmodelReference(String shellId, Reference submodelReference) { - decorated.addSubmodelReference(shellId, submodelReference); + synchronized (getShellMutationLock(shellId)) { + decorated.addSubmodelReference(shellId, submodelReference); + } } @Override public void removeSubmodelReference(String shellId, String submodelId) { - decorated.removeSubmodelReference(shellId, submodelId); + synchronized (getShellMutationLock(shellId)) { + decorated.removeSubmodelReference(shellId, submodelId); + } } @Override public void setAssetInformation(String shellId, AssetInformation shellInfo) throws ElementDoesNotExistException { - decorated.setAssetInformation(shellId, shellInfo); + synchronized (getShellMutationLock(shellId)) { + AssetInformation previousAssetInformation = decorated.getAssetInformation(shellId); + + decorated.setAssetInformation(shellId, shellInfo); + + try { + updateDescriptor(shellId, decorated.getAas(shellId)); + } catch (RuntimeException e) { + rollbackAssetInformationUpdate(shellId, previousAssetInformation, e); + throw e; + } + } } @Override @@ -144,38 +178,135 @@ private void registerAas(AssetAdministrationShellDescriptor descriptor) { logger.info("Shell '{}' has been automatically linked with the Registry", descriptor.getId()); } catch (ApiException e) { - throw new RepositoryRegistryLinkException(descriptor.getId(), e); + throw createRegistryLinkException(descriptor.getId(), "creation", e); } } - private void deleteFromRegistry(String shellId) { + private void updateDescriptor(String shellId, AssetAdministrationShell shell) { RegistryAndDiscoveryInterfaceApi registryApi = aasRepositoryRegistryLink.getRegistryApi(); - - if (!shellExistsOnRegistry(shellId, registryApi)) { - logger.error("Unable to un-link the AAS descriptor '{}' from the Registry because it does not exist on the Registry.", shellId); - - return; + AssetAdministrationShellDescriptor existingDescriptor; + + try { + existingDescriptor = registryApi.getAssetAdministrationShellDescriptorById(shellId); + } catch (ApiException e) { + throw createRegistryLinkException(shellId, "update", e); } + AssetAdministrationShellDescriptor updatedDescriptor = createDescriptor(shell); + preserveRegistryManagedAttributes(existingDescriptor, updatedDescriptor); + try { - registryApi.deleteAssetAdministrationShellDescriptorById(shellId); + registryApi.putAssetAdministrationShellDescriptorById(shellId, updatedDescriptor); - logger.info("Shell '{}' has been automatically un-linked from the Registry.", shellId); + logger.info("Shell descriptor '{}' has been automatically updated in the Registry", shellId); } catch (ApiException e) { - throw new RepositoryRegistryUnlinkException(shellId, e); + if (descriptorMatchesAasDerivedState(shellId, updatedDescriptor, registryApi, e)) { + logger.warn("Registry update for shell descriptor '{}' returned an error, but a read-back confirmed the requested AAS-derived state.", shellId); + return; + } + + throw createRegistryLinkException(shellId, "update", e); } } - - private boolean shellExistsOnRegistry(String shellId, RegistryAndDiscoveryInterfaceApi registryApi) { + + private void preserveRegistryManagedAttributes(AssetAdministrationShellDescriptor existingDescriptor, AssetAdministrationShellDescriptor updatedDescriptor) { + if (existingDescriptor.getEndpoints() != null) + updatedDescriptor.setEndpoints(existingDescriptor.getEndpoints()); + + updatedDescriptor.setSubmodelDescriptors(existingDescriptor.getSubmodelDescriptors()); + } + + private boolean descriptorMatchesAasDerivedState(String shellId, AssetAdministrationShellDescriptor expectedDescriptor, RegistryAndDiscoveryInterfaceApi registryApi, ApiException updateException) { try { - registryApi.getAssetAdministrationShellDescriptorById(shellId); - - return true; - } catch (ApiException e) { + AssetAdministrationShellDescriptor actualDescriptor = registryApi.getAssetAdministrationShellDescriptorById(shellId); + return aasDerivedAttributesEqual(expectedDescriptor, actualDescriptor); + } catch (ApiException verificationException) { + updateException.addSuppressed(verificationException); return false; } } + private boolean aasDerivedAttributesEqual(AssetAdministrationShellDescriptor expected, AssetAdministrationShellDescriptor actual) { + return actual != null + && Objects.equals(expected.getAdministration(), actual.getAdministration()) + && Objects.equals(expected.getAssetKind(), actual.getAssetKind()) + && Objects.equals(expected.getAssetType(), actual.getAssetType()) + && Objects.equals(expected.getDescription(), actual.getDescription()) + && Objects.equals(expected.getDisplayName(), actual.getDisplayName()) + && Objects.equals(expected.getExtensions(), actual.getExtensions()) + && Objects.equals(expected.getGlobalAssetId(), actual.getGlobalAssetId()) + && Objects.equals(expected.getId(), actual.getId()) + && Objects.equals(expected.getIdShort(), actual.getIdShort()) + && Objects.equals(expected.getSpecificAssetIds(), actual.getSpecificAssetIds()); + } + + private AssetAdministrationShellDescriptor createDescriptor(AssetAdministrationShell shell) { + return new AasDescriptorFactory(aasRepositoryRegistryLink.getAasRepositoryBaseURLs(), attributeMapper).create(shell); + } + + private RepositoryRegistryLinkException createRegistryLinkException(String shellId, String operation, ApiException cause) { + return new RepositoryRegistryLinkException(shellId, createRegistryFailureDetails(operation, cause), cause); + } + + private RepositoryRegistryUnlinkException createRegistryUnlinkException(String shellId, ApiException cause) { + return new RepositoryRegistryUnlinkException(shellId, createRegistryFailureDetails("deletion", cause), cause); + } + + private String createRegistryFailureDetails(String operation, ApiException cause) { + StringBuilder details = new StringBuilder("Registry descriptor ").append(operation).append(" failed"); + + if (cause.getCode() > 0) + details.append(" with HTTP status ").append(cause.getCode()); + + String responseDetails = cause.getResponseBody(); + if (responseDetails == null || responseDetails.isBlank()) + responseDetails = cause.getMessage(); + + if (responseDetails != null && !responseDetails.isBlank()) + details.append(": ").append(responseDetails); + + return details.toString(); + } + + private void rollbackAasUpdate(String shellId, AssetAdministrationShell previousShell, RuntimeException updateException) { + try { + decorated.updateAas(shellId, previousShell); + } catch (RuntimeException rollbackException) { + updateException.addSuppressed(rollbackException); + logger.error("Unable to restore AAS '{}' after Registry synchronization failed.", shellId, rollbackException); + } + } + + private void rollbackAssetInformationUpdate(String shellId, AssetInformation previousAssetInformation, RuntimeException updateException) { + try { + decorated.setAssetInformation(shellId, previousAssetInformation); + } catch (RuntimeException rollbackException) { + updateException.addSuppressed(rollbackException); + logger.error("Unable to restore Asset Information for AAS '{}' after Registry synchronization failed.", shellId, rollbackException); + } + } + + private void deleteFromRegistry(String shellId) { + RegistryAndDiscoveryInterfaceApi registryApi = aasRepositoryRegistryLink.getRegistryApi(); + + try { + registryApi.deleteAssetAdministrationShellDescriptorById(shellId); + + logger.info("Shell '{}' has been automatically un-linked from the Registry.", shellId); + } catch (ApiException e) { + if (e.getCode() == 404) { + logger.info("Shell descriptor '{}' was already absent from the Registry.", shellId); + return; + } + + throw createRegistryUnlinkException(shellId, e); + } + } + + private Object getShellMutationLock(String shellId) { + return shellMutationLocks[Math.floorMod(Objects.hashCode(shellId), shellMutationLocks.length)]; + } + @Override public File getThumbnail(String aasId) { return decorated.getThumbnail(aasId); diff --git a/basyx.aasrepository/basyx.aasrepository-feature-registry-integration/src/test/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/registry/integration/AasRepositoryRegistryLinkTestSuite.java b/basyx.aasrepository/basyx.aasrepository-feature-registry-integration/src/test/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/registry/integration/AasRepositoryRegistryLinkTestSuite.java index d95ab87ea..7d5a9c477 100644 --- a/basyx.aasrepository/basyx.aasrepository-feature-registry-integration/src/test/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/registry/integration/AasRepositoryRegistryLinkTestSuite.java +++ b/basyx.aasrepository/basyx.aasrepository-feature-registry-integration/src/test/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/registry/integration/AasRepositoryRegistryLinkTestSuite.java @@ -32,9 +32,11 @@ import java.util.List; import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse; +import org.apache.hc.core5.http.ParseException; import org.eclipse.digitaltwin.basyx.aasregistry.client.ApiException; import org.eclipse.digitaltwin.basyx.aasregistry.client.api.RegistryAndDiscoveryInterfaceApi; import org.eclipse.digitaltwin.basyx.aasregistry.client.model.AssetAdministrationShellDescriptor; +import org.eclipse.digitaltwin.basyx.aasregistry.client.model.AssetKind; import org.eclipse.digitaltwin.basyx.aasregistry.client.model.GetAssetAdministrationShellDescriptorsResult; import org.eclipse.digitaltwin.basyx.aasregistry.main.client.mapper.DummyAasDescriptorFactory; import org.eclipse.digitaltwin.basyx.core.RepositoryUrlHelper; @@ -93,6 +95,64 @@ public void deleteAas() throws FileNotFoundException, IOException, ApiException assertDescriptionDeletionAtRegistry(); } + @Test + public void updateAasUpdatesDescriptor() throws FileNotFoundException, IOException, ApiException { + String updatedAas = getAas1JSONString() + .replace("\"ExampleMotor\"", "\"UpdatedMotor\"") + .replace("\"Instance\"", "\"Type\""); + + try (CloseableHttpResponse creationResponse = createAasOnRepo(getAas1JSONString())) { + assertEquals(HttpStatus.CREATED.value(), creationResponse.getCode()); + } + + try { + try (CloseableHttpResponse updateResponse = updateAasOnRepo(updatedAas)) { + assertEquals(HttpStatus.NO_CONTENT.value(), updateResponse.getCode()); + } + + AssetAdministrationShellDescriptor descriptor = retrieveDescriptorFromRegistry(); + assertEquals("UpdatedMotor", descriptor.getIdShort()); + assertEquals(AssetKind.TYPE, descriptor.getAssetKind()); + } finally { + resetRepository(); + } + } + + @Test + public void updateAssetInformationUpdatesDescriptor() throws FileNotFoundException, IOException, ApiException { + try (CloseableHttpResponse creationResponse = createAasOnRepo(getAas1JSONString())) { + assertEquals(HttpStatus.CREATED.value(), creationResponse.getCode()); + } + + try { + try (CloseableHttpResponse updateResponse = updateAssetInformationOnRepo("{\"assetKind\":\"Type\",\"globalAssetId\":\"globalAssetId\"}")) { + assertEquals(HttpStatus.NO_CONTENT.value(), updateResponse.getCode()); + } + + assertEquals(AssetKind.TYPE, retrieveDescriptorFromRegistry().getAssetKind()); + } finally { + resetRepository(); + } + } + + @Test + public void preExistingDescriptorReturnsRegistryFailureDetailsAndRollsBackAas() throws IOException, ApiException, ParseException { + getAasRegistryApi().postAssetAdministrationShellDescriptor(DUMMY_DESCRIPTOR); + + try { + try (CloseableHttpResponse creationResponse = createAasOnRepo(getAas1JSONString())) { + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR.value(), creationResponse.getCode()); + assertTrue(BaSyxHttpTestUtils.getResponseAsString(creationResponse).contains(Integer.toString(HttpStatus.CONFLICT.value()))); + } + + try (CloseableHttpResponse retrievalResponse = BaSyxHttpTestUtils.executeGetOnURL(getSpecificAasAccessURL(DUMMY_AAS_ID))) { + assertEquals(HttpStatus.NOT_FOUND.value(), retrievalResponse.getCode()); + } + } finally { + getAasRegistryApi().deleteAssetAdministrationShellDescriptorById(DUMMY_AAS_ID); + } + } + private AssetAdministrationShellDescriptor retrieveDescriptorFromRegistry() throws ApiException { RegistryAndDiscoveryInterfaceApi api = getAasRegistryApi(); @@ -125,10 +185,17 @@ private String getAas1JSONString() throws FileNotFoundException, IOException { } private CloseableHttpResponse createAasOnRepo(String aasJsonContent) throws IOException { - String url = RepositoryUrlHelper.createRepositoryUrl(getFirstAasRepoBaseUrl(), AAS_REPOSITORY_PATH); return BaSyxHttpTestUtils.executePostOnURL(RepositoryUrlHelper.createRepositoryUrl(getFirstAasRepoBaseUrl(), AAS_REPOSITORY_PATH), aasJsonContent); } + private CloseableHttpResponse updateAasOnRepo(String aasJsonContent) throws IOException { + return BaSyxHttpTestUtils.executePutOnURL(getSpecificAasAccessURL(DUMMY_AAS_ID), aasJsonContent); + } + + private CloseableHttpResponse updateAssetInformationOnRepo(String assetInformationJson) throws IOException { + return BaSyxHttpTestUtils.executePutOnURL(getSpecificAasAccessURL(DUMMY_AAS_ID) + "/asset-information", assetInformationJson); + } + private String getSpecificAasAccessURL(String aasId) { return RepositoryUrlHelper.createRepositoryUrl(getFirstAasRepoBaseUrl(), AAS_REPOSITORY_PATH) + "/" + Base64UrlEncodedIdentifier.encodeIdentifier(aasId); @@ -138,4 +205,4 @@ private String getFirstAasRepoBaseUrl() { return getAasRepoBaseUrls()[0]; } -} \ No newline at end of file +} diff --git a/basyx.aasrepository/basyx.aasrepository-feature-registry-integration/src/test/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/registry/integration/RegistryIntegrationAasRepositoryTest.java b/basyx.aasrepository/basyx.aasrepository-feature-registry-integration/src/test/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/registry/integration/RegistryIntegrationAasRepositoryTest.java new file mode 100644 index 000000000..98a9d191c --- /dev/null +++ b/basyx.aasrepository/basyx.aasrepository-feature-registry-integration/src/test/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/registry/integration/RegistryIntegrationAasRepositoryTest.java @@ -0,0 +1,478 @@ +/******************************************************************************* + * Copyright (C) 2026 the Eclipse BaSyx Authors + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + * SPDX-License-Identifier: MIT + ******************************************************************************/ + +package org.eclipse.digitaltwin.basyx.aasrepository.feature.registry.integration; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import org.eclipse.digitaltwin.aas4j.v3.model.AssetAdministrationShell; +import org.eclipse.digitaltwin.aas4j.v3.model.AssetInformation; +import org.eclipse.digitaltwin.aas4j.v3.model.AssetKind; +import org.eclipse.digitaltwin.aas4j.v3.model.KeyTypes; +import org.eclipse.digitaltwin.aas4j.v3.model.Reference; +import org.eclipse.digitaltwin.aas4j.v3.model.ReferenceTypes; +import org.eclipse.digitaltwin.aas4j.v3.model.impl.DefaultAssetAdministrationShell; +import org.eclipse.digitaltwin.aas4j.v3.model.impl.DefaultAssetInformation; +import org.eclipse.digitaltwin.aas4j.v3.model.impl.DefaultKey; +import org.eclipse.digitaltwin.aas4j.v3.model.impl.DefaultReference; +import org.eclipse.digitaltwin.basyx.aasregistry.client.ApiException; +import org.eclipse.digitaltwin.basyx.aasregistry.client.api.RegistryAndDiscoveryInterfaceApi; +import org.eclipse.digitaltwin.basyx.aasregistry.client.model.AssetAdministrationShellDescriptor; +import org.eclipse.digitaltwin.basyx.aasregistry.client.model.Endpoint; +import org.eclipse.digitaltwin.basyx.aasregistry.client.model.ProtocolInformation; +import org.eclipse.digitaltwin.basyx.aasregistry.client.model.SubmodelDescriptor; +import org.eclipse.digitaltwin.basyx.aasregistry.main.client.mapper.AttributeMapper; +import org.eclipse.digitaltwin.basyx.aasrepository.AasRepository; +import org.eclipse.digitaltwin.basyx.aasrepository.backend.CrudAasRepositoryFactory; +import org.eclipse.digitaltwin.basyx.aasservice.backend.InMemoryAasBackend; +import org.eclipse.digitaltwin.basyx.core.exceptions.ElementDoesNotExistException; +import org.eclipse.digitaltwin.basyx.core.exceptions.RepositoryRegistryLinkException; +import org.eclipse.digitaltwin.basyx.core.exceptions.RepositoryRegistryUnlinkException; +import org.eclipse.digitaltwin.basyx.core.filerepository.InMemoryFileRepository; +import org.eclipse.digitaltwin.basyx.core.pagination.PaginationInfo; +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; + +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * Unit tests for registry synchronization in {@link RegistryIntegrationAasRepository}. + */ +public class RegistryIntegrationAasRepositoryTest { + private static final String AAS_ID = "aas-id"; + private static final String REPOSITORY_URL = "http://localhost:8081"; + + private AasRepository decorated; + private RegistryAndDiscoveryInterfaceApi registryApi; + private RegistryIntegrationAasRepository repository; + + @Before + public void setUp() { + decorated = CrudAasRepositoryFactory.builder().backend(new InMemoryAasBackend()).fileRepository(new InMemoryFileRepository()).create(); + registryApi = mock(RegistryAndDiscoveryInterfaceApi.class); + + AasRepositoryRegistryLink registryLink = mock(AasRepositoryRegistryLink.class); + when(registryLink.getRegistryApi()).thenReturn(registryApi); + when(registryLink.getAasRepositoryBaseURLs()).thenReturn(List.of(REPOSITORY_URL)); + + repository = new RegistryIntegrationAasRepository(decorated, registryLink, new AttributeMapper(new ObjectMapper())); + } + + @Test + public void updateAasAddsIdShortToDescriptor() throws ApiException { + AssetAdministrationShell initialShell = createShell(null, AssetKind.INSTANCE); + decorated.createAas(initialShell); + when(registryApi.getAssetAdministrationShellDescriptorById(AAS_ID)).thenReturn(createExistingDescriptor()); + + repository.updateAas(AAS_ID, createShell("added-id-short", AssetKind.INSTANCE)); + + AssetAdministrationShellDescriptor updatedDescriptor = captureUpdatedDescriptor(); + assertEquals("added-id-short", updatedDescriptor.getIdShort()); + } + + @Test + public void updateAasUpdatesAssetKindInDescriptor() throws ApiException { + decorated.createAas(createShell("id-short", AssetKind.INSTANCE)); + when(registryApi.getAssetAdministrationShellDescriptorById(AAS_ID)).thenReturn(createExistingDescriptor()); + + repository.updateAas(AAS_ID, createShell("id-short", AssetKind.TYPE)); + + AssetAdministrationShellDescriptor updatedDescriptor = captureUpdatedDescriptor(); + assertEquals(org.eclipse.digitaltwin.basyx.aasregistry.client.model.AssetKind.TYPE, updatedDescriptor.getAssetKind()); + } + + @Test + public void setAssetInformationUpdatesAssetKindInDescriptor() throws ApiException { + decorated.createAas(createShell("id-short", AssetKind.INSTANCE)); + when(registryApi.getAssetAdministrationShellDescriptorById(AAS_ID)).thenReturn(createExistingDescriptor()); + + repository.setAssetInformation(AAS_ID, createAssetInformation(AssetKind.TYPE)); + + AssetAdministrationShellDescriptor updatedDescriptor = captureUpdatedDescriptor(); + assertEquals(org.eclipse.digitaltwin.basyx.aasregistry.client.model.AssetKind.TYPE, updatedDescriptor.getAssetKind()); + } + + @Test + public void updateAasPreservesSubmodelDescriptors() throws ApiException { + decorated.createAas(createShell("old-id-short", AssetKind.INSTANCE)); + AssetAdministrationShellDescriptor existingDescriptor = createExistingDescriptor(); + SubmodelDescriptor submodelDescriptor = new SubmodelDescriptor(); + submodelDescriptor.setId("submodel-id"); + existingDescriptor.setSubmodelDescriptors(List.of(submodelDescriptor)); + when(registryApi.getAssetAdministrationShellDescriptorById(AAS_ID)).thenReturn(existingDescriptor); + + repository.updateAas(AAS_ID, createShell("new-id-short", AssetKind.INSTANCE)); + + assertEquals(List.of(submodelDescriptor), captureUpdatedDescriptor().getSubmodelDescriptors()); + } + + @Test + public void updateAasPreservesRegistryEndpointMetadata() throws ApiException { + decorated.createAas(createShell("old-id-short", AssetKind.INSTANCE)); + AssetAdministrationShellDescriptor existingDescriptor = createExistingDescriptor(); + ProtocolInformation protocolInformation = new ProtocolInformation() + .href("https://registry.example/shell") + .endpointProtocol("https") + .endpointProtocolVersion(List.of("1.1")) + .subprotocol("custom-subprotocol"); + Endpoint endpoint = new Endpoint()._interface("AAS-3.0").protocolInformation(protocolInformation); + existingDescriptor.setEndpoints(List.of(endpoint)); + when(registryApi.getAssetAdministrationShellDescriptorById(AAS_ID)).thenReturn(existingDescriptor); + + repository.updateAas(AAS_ID, createShell("new-id-short", AssetKind.INSTANCE)); + + assertEquals(List.of(endpoint), captureUpdatedDescriptor().getEndpoints()); + } + + @Test + public void failedDescriptorUpdateRestoresPreviousAas() throws ApiException { + AssetAdministrationShell initialShell = createShell("old-id-short", AssetKind.INSTANCE); + decorated.createAas(initialShell); + when(registryApi.getAssetAdministrationShellDescriptorById(AAS_ID)).thenReturn(createExistingDescriptor()); + doThrow(new ApiException(503, "registry unavailable")).when(registryApi).putAssetAdministrationShellDescriptorById(eq(AAS_ID), any()); + + assertThrows(RepositoryRegistryLinkException.class, () -> repository.updateAas(AAS_ID, createShell("new-id-short", AssetKind.TYPE))); + + AssetAdministrationShell restoredShell = decorated.getAas(AAS_ID); + assertEquals("old-id-short", restoredShell.getIdShort()); + assertEquals(AssetKind.INSTANCE, restoredShell.getAssetInformation().getAssetKind()); + } + + @Test + public void failedAssetInformationUpdateRestoresPreviousAssetInformation() throws ApiException { + decorated.createAas(createShell("id-short", AssetKind.INSTANCE)); + when(registryApi.getAssetAdministrationShellDescriptorById(AAS_ID)).thenReturn(createExistingDescriptor()); + doThrow(new ApiException(503, "registry unavailable")).when(registryApi).putAssetAdministrationShellDescriptorById(eq(AAS_ID), any()); + + assertThrows(RepositoryRegistryLinkException.class, () -> repository.setAssetInformation(AAS_ID, createAssetInformation(AssetKind.TYPE))); + + assertEquals(AssetKind.INSTANCE, decorated.getAssetInformation(AAS_ID).getAssetKind()); + } + + @Test + public void committedDescriptorUpdateWithLostResponseDoesNotRollBackAas() throws ApiException { + decorated.createAas(createShell("old-id-short", AssetKind.INSTANCE)); + AtomicReference registryDescriptor = new AtomicReference<>(createExistingDescriptor()); + when(registryApi.getAssetAdministrationShellDescriptorById(AAS_ID)).thenAnswer(invocation -> registryDescriptor.get()); + doAnswer(invocation -> { + registryDescriptor.set(invocation.getArgument(1)); + throw new ApiException(503, "response lost"); + }).when(registryApi).putAssetAdministrationShellDescriptorById(eq(AAS_ID), any()); + + repository.updateAas(AAS_ID, createShell("new-id-short", AssetKind.TYPE)); + + assertEquals("new-id-short", decorated.getAas(AAS_ID).getIdShort()); + assertEquals("new-id-short", registryDescriptor.get().getIdShort()); + } + + @Test + public void concurrentUpdatesThroughSameIntegrationInstanceRemainOrdered() throws Exception { + decorated.createAas(createShell("initial", AssetKind.INSTANCE)); + AtomicReference registryDescriptor = new AtomicReference<>(createExistingDescriptor()); + AtomicInteger putCount = new AtomicInteger(); + CountDownLatch firstPutStarted = new CountDownLatch(1); + CountDownLatch releaseFirstPut = new CountDownLatch(1); + CountDownLatch secondUpdateInvoked = new CountDownLatch(1); + CountDownLatch secondPutStarted = new CountDownLatch(1); + when(registryApi.getAssetAdministrationShellDescriptorById(AAS_ID)).thenAnswer(invocation -> registryDescriptor.get()); + doAnswer(invocation -> { + AssetAdministrationShellDescriptor descriptor = invocation.getArgument(1); + if (putCount.incrementAndGet() == 1) { + firstPutStarted.countDown(); + releaseFirstPut.await(2, TimeUnit.SECONDS); + } else { + secondPutStarted.countDown(); + } + registryDescriptor.set(descriptor); + return null; + }).when(registryApi).putAssetAdministrationShellDescriptorById(eq(AAS_ID), any()); + + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future firstUpdate = executor.submit(() -> repository.updateAas(AAS_ID, createShell("first", AssetKind.INSTANCE))); + assertTrue(firstPutStarted.await(1, TimeUnit.SECONDS)); + Future secondUpdate = executor.submit(() -> { + secondUpdateInvoked.countDown(); + repository.updateAas(AAS_ID, createShell("second", AssetKind.TYPE)); + }); + assertTrue(secondUpdateInvoked.await(1, TimeUnit.SECONDS)); + assertFalse(secondPutStarted.await(200, TimeUnit.MILLISECONDS)); + releaseFirstPut.countDown(); + + firstUpdate.get(2, TimeUnit.SECONDS); + secondUpdate.get(2, TimeUnit.SECONDS); + + assertEquals("second", decorated.getAas(AAS_ID).getIdShort()); + assertEquals("second", registryDescriptor.get().getIdShort()); + } finally { + releaseFirstPut.countDown(); + executor.shutdownNow(); + } + } + + @Test + public void failedUpdateRollbackDoesNotOverwriteLaterUpdateThroughSameIntegrationInstance() throws Exception { + decorated.createAas(createShell("initial", AssetKind.INSTANCE)); + AtomicReference registryDescriptor = new AtomicReference<>(createExistingDescriptor()); + AtomicInteger putCount = new AtomicInteger(); + CountDownLatch firstPutStarted = new CountDownLatch(1); + CountDownLatch releaseFirstPut = new CountDownLatch(1); + CountDownLatch secondUpdateInvoked = new CountDownLatch(1); + CountDownLatch secondPutStarted = new CountDownLatch(1); + when(registryApi.getAssetAdministrationShellDescriptorById(AAS_ID)).thenAnswer(invocation -> registryDescriptor.get()); + doAnswer(invocation -> { + AssetAdministrationShellDescriptor descriptor = invocation.getArgument(1); + if (putCount.incrementAndGet() == 1) { + firstPutStarted.countDown(); + releaseFirstPut.await(2, TimeUnit.SECONDS); + throw new ApiException(503, "registry unavailable"); + } + + secondPutStarted.countDown(); + registryDescriptor.set(descriptor); + return null; + }).when(registryApi).putAssetAdministrationShellDescriptorById(eq(AAS_ID), any()); + + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future failedUpdate = executor.submit(() -> repository.updateAas(AAS_ID, createShell("failed", AssetKind.INSTANCE))); + assertTrue(firstPutStarted.await(1, TimeUnit.SECONDS)); + Future successfulUpdate = executor.submit(() -> { + secondUpdateInvoked.countDown(); + repository.updateAas(AAS_ID, createShell("successful", AssetKind.TYPE)); + }); + assertTrue(secondUpdateInvoked.await(1, TimeUnit.SECONDS)); + assertFalse(secondPutStarted.await(200, TimeUnit.MILLISECONDS)); + releaseFirstPut.countDown(); + + ExecutionException exception = assertThrows(ExecutionException.class, () -> failedUpdate.get(2, TimeUnit.SECONDS)); + assertTrue(exception.getCause() instanceof RepositoryRegistryLinkException); + successfulUpdate.get(2, TimeUnit.SECONDS); + + assertEquals("successful", decorated.getAas(AAS_ID).getIdShort()); + assertEquals("successful", registryDescriptor.get().getIdShort()); + } finally { + releaseFirstPut.countDown(); + executor.shutdownNow(); + } + } + + @Test + public void failedUpdateRollbackDoesNotOverwriteConcurrentSubmodelReferenceChange() throws Exception { + decorated.createAas(createShell("initial", AssetKind.INSTANCE)); + CountDownLatch putStarted = new CountDownLatch(1); + CountDownLatch releasePut = new CountDownLatch(1); + CountDownLatch addReferenceInvoked = new CountDownLatch(1); + CountDownLatch submodelReferenceAdded = new CountDownLatch(1); + when(registryApi.getAssetAdministrationShellDescriptorById(AAS_ID)).thenReturn(createExistingDescriptor()); + doAnswer(invocation -> { + putStarted.countDown(); + releasePut.await(2, TimeUnit.SECONDS); + throw new ApiException(503, "registry unavailable"); + }).when(registryApi).putAssetAdministrationShellDescriptorById(eq(AAS_ID), any()); + + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future failedUpdate = executor.submit(() -> repository.updateAas(AAS_ID, createShell("failed", AssetKind.TYPE))); + assertTrue(putStarted.await(1, TimeUnit.SECONDS)); + Future addReference = executor.submit(() -> { + addReferenceInvoked.countDown(); + repository.addSubmodelReference(AAS_ID, createSubmodelReference("submodel-id")); + submodelReferenceAdded.countDown(); + }); + assertTrue(addReferenceInvoked.await(1, TimeUnit.SECONDS)); + assertFalse(submodelReferenceAdded.await(200, TimeUnit.MILLISECONDS)); + releasePut.countDown(); + + ExecutionException exception = assertThrows(ExecutionException.class, () -> failedUpdate.get(2, TimeUnit.SECONDS)); + assertTrue(exception.getCause() instanceof RepositoryRegistryLinkException); + addReference.get(2, TimeUnit.SECONDS); + + assertEquals(List.of(createSubmodelReference("submodel-id")), decorated.getSubmodelReferences(AAS_ID, PaginationInfo.NO_LIMIT).getResult()); + } finally { + releasePut.countDown(); + executor.shutdownNow(); + } + } + + @Test + public void concurrentCreateAndDeleteThroughSameIntegrationInstanceRemainOrdered() throws Exception { + AtomicBoolean descriptorExists = new AtomicBoolean(); + CountDownLatch registrationStarted = new CountDownLatch(1); + CountDownLatch releaseRegistration = new CountDownLatch(1); + CountDownLatch deleteInvoked = new CountDownLatch(1); + CountDownLatch deletionAttempted = new CountDownLatch(1); + doAnswer(invocation -> { + registrationStarted.countDown(); + releaseRegistration.await(2, TimeUnit.SECONDS); + descriptorExists.set(true); + return null; + }).when(registryApi).postAssetAdministrationShellDescriptor(any()); + doAnswer(invocation -> { + deletionAttempted.countDown(); + if (!descriptorExists.getAndSet(false)) + throw new ApiException(404, "descriptor not found"); + return null; + }).when(registryApi).deleteAssetAdministrationShellDescriptorById(AAS_ID); + + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future create = executor.submit(() -> repository.createAas(createShell("id-short", AssetKind.INSTANCE))); + assertTrue(registrationStarted.await(1, TimeUnit.SECONDS)); + Future delete = executor.submit(() -> { + deleteInvoked.countDown(); + repository.deleteAas(AAS_ID); + }); + assertTrue(deleteInvoked.await(1, TimeUnit.SECONDS)); + assertFalse(deletionAttempted.await(200, TimeUnit.MILLISECONDS)); + releaseRegistration.countDown(); + + create.get(2, TimeUnit.SECONDS); + delete.get(2, TimeUnit.SECONDS); + + assertThrows(ElementDoesNotExistException.class, () -> decorated.getAas(AAS_ID)); + assertFalse(descriptorExists.get()); + } finally { + releaseRegistration.countDown(); + executor.shutdownNow(); + } + } + + @Test + public void missingRegistryDescriptorStillDeletesAasWithoutReadingIt() throws ApiException { + decorated.createAas(createShell("id-short", AssetKind.INSTANCE)); + doThrow(new ApiException(404, "descriptor not found")).when(registryApi).deleteAssetAdministrationShellDescriptorById(AAS_ID); + + repository.deleteAas(AAS_ID); + + assertThrows(ElementDoesNotExistException.class, () -> decorated.getAas(AAS_ID)); + verify(registryApi).deleteAssetAdministrationShellDescriptorById(AAS_ID); + verify(registryApi, never()).getAssetAdministrationShellDescriptorById(AAS_ID); + } + + @Test + public void registryDeleteFailureKeepsAasAndReportsDetails() throws ApiException { + decorated.createAas(createShell("id-short", AssetKind.INSTANCE)); + doThrow(new ApiException(503, "registry request failed", null, "registry unavailable")).when(registryApi).deleteAssetAdministrationShellDescriptorById(AAS_ID); + + RepositoryRegistryUnlinkException exception = assertThrows(RepositoryRegistryUnlinkException.class, () -> repository.deleteAas(AAS_ID)); + + assertTrue(exception.getMessage().contains("503")); + assertTrue(exception.getMessage().contains("registry unavailable")); + assertEquals("id-short", decorated.getAas(AAS_ID).getIdShort()); + verify(registryApi, never()).getAssetAdministrationShellDescriptorById(AAS_ID); + } + + @Test + public void registryReadPermissionFailureRollsBackAasAndReportsDetails() throws ApiException { + decorated.createAas(createShell("old-id-short", AssetKind.INSTANCE)); + when(registryApi.getAssetAdministrationShellDescriptorById(AAS_ID)).thenThrow(new ApiException(403, "forbidden", null, "read permission required")); + + RepositoryRegistryLinkException exception = assertThrows(RepositoryRegistryLinkException.class, + () -> repository.updateAas(AAS_ID, createShell("new-id-short", AssetKind.TYPE))); + + assertTrue(exception.getMessage().contains("403")); + assertTrue(exception.getMessage().contains("read permission required")); + assertEquals("old-id-short", decorated.getAas(AAS_ID).getIdShort()); + verify(registryApi, never()).putAssetAdministrationShellDescriptorById(eq(AAS_ID), any()); + } + + @Test + public void createAasReportsRegistryConflictDetailsAndRollsBackRepository() throws ApiException { + assertCreateFailureContainsRegistryDetails(409, "descriptor already exists"); + } + + @Test + public void createAasReportsRegistryServiceFailureDetailsAndRollsBackRepository() throws ApiException { + assertCreateFailureContainsRegistryDetails(503, "registry unavailable"); + } + + private void assertCreateFailureContainsRegistryDetails(int status, String detail) throws ApiException { + doThrow(new ApiException(status, "registry request failed", null, detail)).when(registryApi).postAssetAdministrationShellDescriptor(any()); + + RepositoryRegistryLinkException exception = assertThrows(RepositoryRegistryLinkException.class, () -> repository.createAas(createShell("id-short", AssetKind.INSTANCE))); + + assertTrue(exception.getMessage().contains(Integer.toString(status))); + assertTrue(exception.getMessage().contains(detail)); + assertThrows(ElementDoesNotExistException.class, () -> decorated.getAas(AAS_ID)); + } + + private AssetAdministrationShellDescriptor captureUpdatedDescriptor() throws ApiException { + ArgumentCaptor descriptorCaptor = ArgumentCaptor.forClass(AssetAdministrationShellDescriptor.class); + verify(registryApi).putAssetAdministrationShellDescriptorById(eq(AAS_ID), descriptorCaptor.capture()); + return descriptorCaptor.getValue(); + } + + private AssetAdministrationShellDescriptor createExistingDescriptor() { + AssetAdministrationShellDescriptor descriptor = new AssetAdministrationShellDescriptor(); + descriptor.setId(AAS_ID); + return descriptor; + } + + private AssetAdministrationShell createShell(String idShort, AssetKind assetKind) { + return new DefaultAssetAdministrationShell.Builder() + .id(AAS_ID) + .idShort(idShort) + .assetInformation(createAssetInformation(assetKind)) + .build(); + } + + private AssetInformation createAssetInformation(AssetKind assetKind) { + return new DefaultAssetInformation.Builder() + .assetKind(assetKind) + .globalAssetId("global-asset-id") + .build(); + } + + private Reference createSubmodelReference(String submodelId) { + return new DefaultReference.Builder() + .type(ReferenceTypes.MODEL_REFERENCE) + .keys(new DefaultKey.Builder().type(KeyTypes.SUBMODEL).value(submodelId).build()) + .build(); + } +} diff --git a/basyx.common/basyx.core/src/main/java/org/eclipse/digitaltwin/basyx/core/exceptions/RepositoryRegistryLinkException.java b/basyx.common/basyx.core/src/main/java/org/eclipse/digitaltwin/basyx/core/exceptions/RepositoryRegistryLinkException.java index 2138dfa66..da6becbc7 100644 --- a/basyx.common/basyx.core/src/main/java/org/eclipse/digitaltwin/basyx/core/exceptions/RepositoryRegistryLinkException.java +++ b/basyx.common/basyx.core/src/main/java/org/eclipse/digitaltwin/basyx/core/exceptions/RepositoryRegistryLinkException.java @@ -39,8 +39,16 @@ public RepositoryRegistryLinkException(String id, Throwable th) { super(getMessage(id), th); } + public RepositoryRegistryLinkException(String id, String details, Throwable th) { + super(getMessage(id, details), th); + } + private static String getMessage(String id) { return "Unable to link the element with id '" + id + "' with the Registry."; } + private static String getMessage(String id, String details) { + return getMessage(id) + " " + details; + } + } diff --git a/basyx.common/basyx.core/src/main/java/org/eclipse/digitaltwin/basyx/core/exceptions/RepositoryRegistryUnlinkException.java b/basyx.common/basyx.core/src/main/java/org/eclipse/digitaltwin/basyx/core/exceptions/RepositoryRegistryUnlinkException.java index 11d7d91e2..1159e73da 100644 --- a/basyx.common/basyx.core/src/main/java/org/eclipse/digitaltwin/basyx/core/exceptions/RepositoryRegistryUnlinkException.java +++ b/basyx.common/basyx.core/src/main/java/org/eclipse/digitaltwin/basyx/core/exceptions/RepositoryRegistryUnlinkException.java @@ -38,6 +38,10 @@ public RepositoryRegistryUnlinkException(String id, Throwable th) { super(getMessage(id), th); } + public RepositoryRegistryUnlinkException(String id, String details, Throwable th) { + super(getMessage(id) + " " + details, th); + } + private static String getMessage(String id) { return "Unable to unlink the element with id '" + id + "' from the Registry."; }