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.";
}