From ebd5ee0e9c5ae9fbae72240e9fa00eef166a96a8 Mon Sep 17 00:00:00 2001 From: Frank Schnicke Date: Thu, 21 Oct 2021 15:37:46 +0200 Subject: [PATCH 01/55] Initial commit of development branch Signed-off-by: Frank Schnicke --- .../aggregator/AASAggregatorAPIHelper.java | 47 + .../aas/aggregator/api/IAASAggregator.java | 2 +- .../observing/IAASAggregatorObserver.java | 40 + .../observing/ObservableAASAggregator.java | 72 ++ .../aggregator/proxy/AASAggregatorProxy.java | 24 +- .../restapi/AASAggregatorProvider.java | 6 +- .../eclipse/basyx/aas/bundle/AASBundle.java | 65 ++ .../bundle/AASBundleDescriptorFactory.java | 45 + .../basyx/aas/bundle/AASBundleFactory.java | 136 +++ .../basyx/aas/bundle/AASBundleHelper.java | 139 +++ .../aasx/AASXToMetamodelConverter.java | 300 +++++++ .../aasx/MetamodelToAASXConverter.java | 309 +++++++ .../aasx/SubmodelFileEndpointLoader.java | 100 +++ .../factory/json/JSONAASBundleFactory.java | 62 ++ ...SXPackageExplorerCompatibilityHandler.java | 118 +++ .../aas/factory/xml/XMLAASBundleFactory.java | 67 ++ .../factory/xml/XMLToMetamodelConverter.java | 6 - .../xml/api/parts/AssetXMLConverter.java | 4 + ...nectedAssetAdministrationShellManager.java | 23 +- .../api/IAssetAdministrationShellManager.java | 1 - .../api/IAssetAdministrationShell.java | 6 +- .../api/parts/IConceptDictionary.java | 8 +- .../metamodel/api/parts/asset/AssetKind.java | 2 +- .../basyx/aas/metamodel/map/AasEnv.java | 43 +- .../map/AssetAdministrationShell.java | 4 +- .../map/descriptor/AASDescriptor.java | 3 +- .../map/descriptor/ModelDescriptor.java | 58 +- .../metamodel/map/descriptor/ModelUrn.java | 2 +- .../basyx/aas/metamodel/map/parts/Asset.java | 8 +- .../map/parts/ConceptDictionary.java | 2 +- .../basyx/aas/metamodel/map/parts/View.java | 4 +- .../AccessControlPolicyPoints.java | 2 +- .../aas/metamodel/map/security/Security.java | 2 +- .../eclipse/basyx/aas/observer/IObserver.java | 22 + .../basyx/aas/observer/Observable.java | 47 + .../registration/AASRegistryAPIHelper.java | 68 ++ .../IAASRegistryServiceObserver.java | 50 ++ .../ObservableAASRegistryService.java | 82 ++ .../registration/proxy/AASRegistryProxy.java | 36 +- .../restapi/AASRegistryModelProvider.java | 4 +- .../basyx/aas/restapi/AASAPIHelper.java | 38 + .../aas/restapi/MultiSubmodelProvider.java | 86 +- .../basyx/aas/restapi/vab/VABAASAPI.java | 11 +- .../aasxupload/AASAggregatorAASXUpload.java | 86 ++ .../api/IAASAggregatorAASXUpload.java | 32 + .../proxy/AASAggregatorAASXUploadProxy.java | 45 + .../AASAggregatorAASXUploadProvider.java | 63 ++ .../mqtt/MqttAASAggregatorObserver.java | 87 ++ .../tagged/api/IAASTaggedDirectory.java | 2 +- .../tagged/api/TaggedAASDescriptor.java | 2 +- .../tagged/map/MapTaggedDirectory.java | 4 +- .../mqtt/MqttAASRegistryServiceObserver.java | 113 +++ .../shared/mqtt/MqttEventService.java | 34 +- .../submodel/mqtt/MqttSubmodelAPI.java | 106 +-- .../mqtt/MqttSubmodelAPIObserver.java | 197 +++++ ...DataSpecificationIEC61360XMLConverter.java | 2 +- .../HasDataSpecificationXMLConverter.java | 2 +- .../operation/OperationXMLConverter.java | 18 +- ...otatedRelationshipElementXMLConverter.java | 2 +- .../submodel/metamodel/api/ISubmodel.java | 6 +- .../api/identifier/IdentifierType.java | 6 +- .../api/parts/IConceptDescription.java | 4 +- .../api/qualifier/IHasSemantics.java | 4 +- .../metamodel/api/qualifier/IReferable.java | 4 +- .../api/qualifier/haskind/ModelingKind.java | 2 +- .../metamodel/api/reference/IKey.java | 2 +- .../metamodel/api/reference/IReference.java | 4 +- .../api/reference/enums/KeyElements.java | 4 +- .../api/reference/enums/KeyType.java | 4 +- .../api/submodelelement/ICapability.java | 2 +- .../ISubmodelElementCollection.java | 7 + .../submodelelement/dataelement/IBlob.java | 8 +- .../submodelelement/dataelement/IRange.java | 8 +- .../submodelelement/event/IBasicEvent.java | 2 +- .../api/submodelelement/event/IEvent.java | 2 +- .../submodelelement/operation/IOperation.java | 20 +- .../ConnectedSubmodelElementCollection.java | 5 + .../ConnectedSubmodelElementFactory.java | 3 +- .../operation/ConnectedOperation.java | 88 +- .../facade/ElementContainerValuesHelper.java | 67 ++ ...SubmodelElementMapCollectionConverter.java | 18 +- .../facade/SubmodelFacadeCustomSemantics.java | 4 +- .../facade/SubmodelFacadeIRDISemantics.java | 2 +- .../facade/SubmodelValuesHelper.java | 69 +- .../SubmodelElementFacadeFactory.java | 2 +- .../submodel/metamodel/map/Submodel.java | 8 +- .../DataSpecificationContent.java | 2 +- .../DataSpecificationIEC61360Content.java | 2 +- .../EmbeddedDataSpecification.java | 2 +- .../dataspecification/ValueReferencePair.java | 7 +- .../metamodel/map/identifier/Identifier.java | 2 +- .../metamodel/map/modeltype/ModelType.java | 4 +- .../map/parts/ConceptDescription.java | 2 +- .../qualifier/AdministrativeInformation.java | 2 +- .../map/qualifier/HasDataSpecification.java | 2 +- .../metamodel/map/qualifier/HasSemantics.java | 2 +- .../metamodel/map/qualifier/Identifiable.java | 2 +- .../metamodel/map/qualifier/LangString.java | 2 +- .../metamodel/map/qualifier/LangStrings.java | 42 +- .../metamodel/map/qualifier/Referable.java | 2 +- .../map/qualifier/haskind/HasKind.java | 5 +- .../map/qualifier/qualifiable/Formula.java | 2 +- .../qualifier/qualifiable/Qualifiable.java | 2 +- .../map/qualifier/qualifiable/Qualifier.java | 2 +- .../submodel/metamodel/map/reference/Key.java | 9 +- .../metamodel/map/reference/Reference.java | 13 +- .../map/reference/ReferenceHelper.java | 4 +- .../SubmodelElementCollection.java | 8 +- .../map/submodelelement/dataelement/Blob.java | 2 +- .../map/submodelelement/dataelement/File.java | 4 +- .../dataelement/ReferenceElement.java | 2 +- .../property/valuetype/ValueType.java | 4 +- .../property/valuetype/ValueTypeHelper.java | 4 +- .../operation/AsyncInvocation.java | 18 +- .../submodelelement/operation/Operation.java | 257 ++++-- .../operation/OperationCheckHelper.java | 137 +++ .../operation/OperationHelper.java | 65 ++ .../AnnotatedRelationshipElement.java | 2 +- .../relationship/RelationshipElement.java | 2 +- .../restapi/MultiSubmodelElementProvider.java | 2 +- .../submodel/restapi/OperationProvider.java | 219 +++-- .../submodel/restapi/SubmodelAPIHelper.java | 78 ++ .../SubmodelElementCollectionProvider.java | 78 +- .../observing/ISubmodelAPIObserver.java | 45 + .../observing/ObservableSubmodelAPI.java | 99 +++ .../operation/AsyncOperationHandler.java | 21 +- .../operation/DelegatedInvocationHelper.java | 87 ++ .../restapi/operation/InvocationRequest.java | 23 + .../submodel/restapi/vab/VABSubmodelAPI.java | 33 +- .../DigitalNameplateSubmodel.java | 2 +- .../address/Address.java | 8 +- .../address/Email.java | 2 +- .../address/Fax.java | 2 +- .../address/Phone.java | 2 +- .../AssetSpecificProperties.java | 2 +- .../GuidelineSpecificProperties.java | 2 +- .../markings/Marking.java | 2 +- .../markings/Markings.java | 4 +- .../technicaldata/TechnicalDataSubmodel.java | 9 +- .../GeneralInformation.java | 2 +- .../ProductClassificationItem.java | 2 +- .../TechnicalProperties.java | 2 +- .../vab/coder/json/metaprotocol/Message.java | 8 + .../metaprotocol/MetaprotocolHandler.java | 2 +- .../vab/coder/json/metaprotocol/Result.java | 14 + .../vab/coder/json/provider/JSONProvider.java | 24 +- .../coder/json/serialization/GSONTools.java | 9 +- .../provider/MalformedRequestException.java | 10 +- .../exception/provider/ProviderException.java | 17 + .../ResourceAlreadyExistsException.java | 8 + .../provider/ResourceNotFoundException.java | 7 + .../WrongNumberOfParametersException.java | 11 +- .../basyx/vab/factory/xml/XmlParser.java | 44 +- .../vab/gateway/ConnectorProviderMapper.java | 2 +- .../eclipse/basyx/vab/model/VABModelMap.java | 2 +- .../vab/modelprovider/VABElementProxy.java | 6 +- .../basyx/vab/modelprovider/VABPathTools.java | 24 +- .../consistency/ConsistencyProvider.java | 10 +- .../filesystem/FileSystemProvider.java | 2 +- .../filesystem/filesystem/FileSystem.java | 16 +- .../generic/VABModelProvider.java | 62 +- .../lambda/VABLambdaProvider.java | 8 +- .../protocol/basyx/server/BaSyxTCPServer.java | 2 - .../http/connector/HTTPConnector.java | 82 +- .../http/helper/HTTPUploadHelper.java | 91 ++ .../protocol/http/server/BaSyxContext.java | 14 + .../server/ExceptionToHTTPCodeMapper.java | 24 + .../http/server/VABHTTPInterface.java | 126 ++- .../vab/protocol/opcua/CertificateHelper.java | 270 ++++++ .../opcua/connector/ClientConfiguration.java | 247 ++++++ .../opcua/connector/IOpcUaClient.java | 517 +++++++++++ .../opcua/connector/OpcUaConnector.java | 347 ++++---- .../connector/OpcUaConnectorFactory.java | 63 ++ .../connector/OpcUaConnectorProvider.java | 27 +- .../connector/milo/BrowsePathHelper.java | 271 ++++++ .../opcua/connector/milo/MiloOpcUaClient.java | 816 ++++++++++++++++++ .../AmbiguousBrowsePathException.java | 35 + .../opcua/exception/OpcUaException.java | 38 + .../opcua/server/BaSyxOpcUaClient.java | 4 + .../opcua/server/BaSyxOpcUaClientRunner.java | 6 + .../opcua/server/KeyStoreLoaderClient.java | 12 + .../opcua/types/MessageSecurityMode.java | 19 + .../vab/protocol/opcua/types/NodeId.java | 233 +++++ .../protocol/opcua/types/SecurityPolicy.java | 22 + .../protocol/opcua/types/UnsignedByte.java | 216 +++++ .../protocol/opcua/types/UnsignedInteger.java | 182 ++++ .../protocol/opcua/types/UnsignedLong.java | 168 ++++ .../protocol/opcua/types/UnsignedShort.java | 194 +++++ .../vab/support/TypeDestroyingProvider.java | 2 +- .../aggregator/TestAASAggregatorProxy.java | 4 +- .../ObservableAASAggregatorTest.java | 120 +++ .../TestAASBundleDescriptorFactory.java | 56 ++ .../aas/bundle/TestAASBundleFactory.java | 39 + .../aas/bundle/TestAASBundleHelper.java | 174 ++++ ...TestAASXToMetamodelConverterFromBaSyx.java | 401 +++++++++ .../TestAASXToMetamodelConverterFromFile.java | 353 ++++++++ .../aasx/TestMetamodelToAASXConverter.java | 224 +++++ .../TestSubmodelFileEndpointerLoader.java | 100 +++ .../TestAASXPackageExplorerCompatibility.java | 64 ++ .../regression/aas/manager/TestAASHTTP.java | 29 +- ...nectedAssetAdministrationShellManager.java | 11 +- .../descriptor/ModelDescriptorTestSuite.java | 57 ++ .../map/descriptor/TestAASDescriptor.java | 7 +- .../descriptor/TestSubmodelDescriptor.java | 8 +- .../ObservableAASRegistryServiceTest.java | 168 ++++ .../TestAASAggregatorAASXUpload.java | 28 + .../TestAASAggregatorAASXUploadSuite.java | 74 ++ ...estAASAggregatorProxyWithAASXProvider.java | 65 ++ .../mqtt/TestMqttAASAggregatorObserver.java | 122 +++ .../TestMqttAASRegistryServiceObserver.java | 148 ++++ .../mqtt/MqttSubmodelAPIObserverTest.java | 145 ++++ .../connected/TestConnectedSubmodel.java | 3 +- ...estConnectedSubmodelElementCollection.java | 24 +- .../TestConnectedOperationInput.java | 43 + .../TestConnectedOperationParameter.java | 44 + .../map/qualifier/TestLangStrings.java | 18 + .../TestSubmodelElementCollection.java | 40 +- .../operation/TestOperation.java | 65 +- .../operation/TestOperationInput.java | 28 + .../operation/TestOperationInputSuite.java | 217 +++++ .../operation/TestOperationParameter.java | 29 + .../TestOperationParameterSuite.java | 175 ++++ .../operation/TestOperationSuite.java | 460 ++++++++-- .../restapi/OperationProviderTest.java | 300 +++++++ .../submodel/restapi/SimpleAASSubmodel.java | 5 +- .../restapi/SubmodelProviderTest.java | 5 +- ...TestSubmodelElementCollectionProvider.java | 107 +++ .../observing/ObservableSubmodelAPITest.java | 138 +++ .../address/TestAddress.java | 4 +- .../vab/modelprovider/Exceptions.java | 17 - .../vab/modelprovider/MapInvoke.java | 88 +- .../vab/modelprovider/SimpleVABElement.java | 113 ++- .../vab/modelprovider/VABPathToolsTest.java | 10 + .../vab/protocol/http/TestVABHTTP.java | 66 +- .../protocol/opcua/BrowsePathHelperTest.java | 242 ++++++ .../vab/protocol/opcua/TestVABOpcUa.java | 6 +- .../vab/support/RecordingProvider.java | 18 +- .../resources/aas/factory/aasx/01_Festo.aasx | Bin 0 -> 7697369 bytes .../aas/factory/xml/inWorkarounds.xml | 65 ++ 239 files changed, 12428 insertions(+), 1260 deletions(-) create mode 100644 src/main/java/org/eclipse/basyx/aas/aggregator/AASAggregatorAPIHelper.java create mode 100644 src/main/java/org/eclipse/basyx/aas/aggregator/observing/IAASAggregatorObserver.java create mode 100644 src/main/java/org/eclipse/basyx/aas/aggregator/observing/ObservableAASAggregator.java create mode 100644 src/main/java/org/eclipse/basyx/aas/bundle/AASBundle.java create mode 100644 src/main/java/org/eclipse/basyx/aas/bundle/AASBundleDescriptorFactory.java create mode 100644 src/main/java/org/eclipse/basyx/aas/bundle/AASBundleFactory.java create mode 100644 src/main/java/org/eclipse/basyx/aas/bundle/AASBundleHelper.java create mode 100644 src/main/java/org/eclipse/basyx/aas/factory/aasx/AASXToMetamodelConverter.java create mode 100644 src/main/java/org/eclipse/basyx/aas/factory/aasx/MetamodelToAASXConverter.java create mode 100644 src/main/java/org/eclipse/basyx/aas/factory/aasx/SubmodelFileEndpointLoader.java create mode 100644 src/main/java/org/eclipse/basyx/aas/factory/json/JSONAASBundleFactory.java create mode 100644 src/main/java/org/eclipse/basyx/aas/factory/xml/AASXPackageExplorerCompatibilityHandler.java create mode 100644 src/main/java/org/eclipse/basyx/aas/factory/xml/XMLAASBundleFactory.java create mode 100644 src/main/java/org/eclipse/basyx/aas/observer/IObserver.java create mode 100644 src/main/java/org/eclipse/basyx/aas/observer/Observable.java create mode 100644 src/main/java/org/eclipse/basyx/aas/registration/AASRegistryAPIHelper.java create mode 100644 src/main/java/org/eclipse/basyx/aas/registration/observing/IAASRegistryServiceObserver.java create mode 100644 src/main/java/org/eclipse/basyx/aas/registration/observing/ObservableAASRegistryService.java create mode 100644 src/main/java/org/eclipse/basyx/aas/restapi/AASAPIHelper.java create mode 100644 src/main/java/org/eclipse/basyx/extensions/aas/aggregator/aasxupload/AASAggregatorAASXUpload.java create mode 100644 src/main/java/org/eclipse/basyx/extensions/aas/aggregator/aasxupload/api/IAASAggregatorAASXUpload.java create mode 100644 src/main/java/org/eclipse/basyx/extensions/aas/aggregator/aasxupload/proxy/AASAggregatorAASXUploadProxy.java create mode 100644 src/main/java/org/eclipse/basyx/extensions/aas/aggregator/aasxupload/restapi/AASAggregatorAASXUploadProvider.java create mode 100644 src/main/java/org/eclipse/basyx/extensions/aas/aggregator/mqtt/MqttAASAggregatorObserver.java create mode 100644 src/main/java/org/eclipse/basyx/extensions/aas/registration/mqtt/MqttAASRegistryServiceObserver.java create mode 100644 src/main/java/org/eclipse/basyx/extensions/submodel/mqtt/MqttSubmodelAPIObserver.java create mode 100644 src/main/java/org/eclipse/basyx/submodel/metamodel/facade/ElementContainerValuesHelper.java create mode 100644 src/main/java/org/eclipse/basyx/submodel/metamodel/map/submodelelement/operation/OperationCheckHelper.java create mode 100644 src/main/java/org/eclipse/basyx/submodel/restapi/SubmodelAPIHelper.java create mode 100644 src/main/java/org/eclipse/basyx/submodel/restapi/observing/ISubmodelAPIObserver.java create mode 100644 src/main/java/org/eclipse/basyx/submodel/restapi/observing/ObservableSubmodelAPI.java create mode 100644 src/main/java/org/eclipse/basyx/submodel/restapi/operation/DelegatedInvocationHelper.java create mode 100644 src/main/java/org/eclipse/basyx/vab/protocol/http/helper/HTTPUploadHelper.java create mode 100644 src/main/java/org/eclipse/basyx/vab/protocol/opcua/CertificateHelper.java create mode 100644 src/main/java/org/eclipse/basyx/vab/protocol/opcua/connector/ClientConfiguration.java create mode 100644 src/main/java/org/eclipse/basyx/vab/protocol/opcua/connector/IOpcUaClient.java create mode 100644 src/main/java/org/eclipse/basyx/vab/protocol/opcua/connector/OpcUaConnectorFactory.java create mode 100644 src/main/java/org/eclipse/basyx/vab/protocol/opcua/connector/milo/BrowsePathHelper.java create mode 100644 src/main/java/org/eclipse/basyx/vab/protocol/opcua/connector/milo/MiloOpcUaClient.java create mode 100644 src/main/java/org/eclipse/basyx/vab/protocol/opcua/exception/AmbiguousBrowsePathException.java create mode 100644 src/main/java/org/eclipse/basyx/vab/protocol/opcua/exception/OpcUaException.java create mode 100644 src/main/java/org/eclipse/basyx/vab/protocol/opcua/types/MessageSecurityMode.java create mode 100644 src/main/java/org/eclipse/basyx/vab/protocol/opcua/types/NodeId.java create mode 100644 src/main/java/org/eclipse/basyx/vab/protocol/opcua/types/SecurityPolicy.java create mode 100644 src/main/java/org/eclipse/basyx/vab/protocol/opcua/types/UnsignedByte.java create mode 100644 src/main/java/org/eclipse/basyx/vab/protocol/opcua/types/UnsignedInteger.java create mode 100644 src/main/java/org/eclipse/basyx/vab/protocol/opcua/types/UnsignedLong.java create mode 100644 src/main/java/org/eclipse/basyx/vab/protocol/opcua/types/UnsignedShort.java create mode 100644 src/test/java/org/eclipse/basyx/testsuite/regression/aas/aggregator/observing/ObservableAASAggregatorTest.java create mode 100644 src/test/java/org/eclipse/basyx/testsuite/regression/aas/bundle/TestAASBundleDescriptorFactory.java create mode 100644 src/test/java/org/eclipse/basyx/testsuite/regression/aas/bundle/TestAASBundleFactory.java create mode 100644 src/test/java/org/eclipse/basyx/testsuite/regression/aas/bundle/TestAASBundleHelper.java create mode 100644 src/test/java/org/eclipse/basyx/testsuite/regression/aas/factory/aasx/TestAASXToMetamodelConverterFromBaSyx.java create mode 100644 src/test/java/org/eclipse/basyx/testsuite/regression/aas/factory/aasx/TestAASXToMetamodelConverterFromFile.java create mode 100644 src/test/java/org/eclipse/basyx/testsuite/regression/aas/factory/aasx/TestMetamodelToAASXConverter.java create mode 100644 src/test/java/org/eclipse/basyx/testsuite/regression/aas/factory/aasx/TestSubmodelFileEndpointerLoader.java create mode 100644 src/test/java/org/eclipse/basyx/testsuite/regression/aas/factory/xml/TestAASXPackageExplorerCompatibility.java create mode 100644 src/test/java/org/eclipse/basyx/testsuite/regression/aas/metamodel/map/descriptor/ModelDescriptorTestSuite.java create mode 100644 src/test/java/org/eclipse/basyx/testsuite/regression/aas/registration/observing/ObservableAASRegistryServiceTest.java create mode 100644 src/test/java/org/eclipse/basyx/testsuite/regression/extensions/aas/aggregator/aasxupload/TestAASAggregatorAASXUpload.java create mode 100644 src/test/java/org/eclipse/basyx/testsuite/regression/extensions/aas/aggregator/aasxupload/TestAASAggregatorAASXUploadSuite.java create mode 100644 src/test/java/org/eclipse/basyx/testsuite/regression/extensions/aas/aggregator/aasxupload/TestAASAggregatorProxyWithAASXProvider.java create mode 100644 src/test/java/org/eclipse/basyx/testsuite/regression/extensions/aas/aggregator/mqtt/TestMqttAASAggregatorObserver.java create mode 100644 src/test/java/org/eclipse/basyx/testsuite/regression/extensions/aas/registration/mqtt/TestMqttAASRegistryServiceObserver.java create mode 100644 src/test/java/org/eclipse/basyx/testsuite/regression/extensions/submodel/mqtt/MqttSubmodelAPIObserverTest.java create mode 100644 src/test/java/org/eclipse/basyx/testsuite/regression/submodel/metamodel/connected/submodelelement/operation/TestConnectedOperationInput.java create mode 100644 src/test/java/org/eclipse/basyx/testsuite/regression/submodel/metamodel/connected/submodelelement/operation/TestConnectedOperationParameter.java create mode 100644 src/test/java/org/eclipse/basyx/testsuite/regression/submodel/metamodel/map/submodelelement/operation/TestOperationInput.java create mode 100644 src/test/java/org/eclipse/basyx/testsuite/regression/submodel/metamodel/map/submodelelement/operation/TestOperationInputSuite.java create mode 100644 src/test/java/org/eclipse/basyx/testsuite/regression/submodel/metamodel/map/submodelelement/operation/TestOperationParameter.java create mode 100644 src/test/java/org/eclipse/basyx/testsuite/regression/submodel/metamodel/map/submodelelement/operation/TestOperationParameterSuite.java create mode 100644 src/test/java/org/eclipse/basyx/testsuite/regression/submodel/restapi/OperationProviderTest.java create mode 100644 src/test/java/org/eclipse/basyx/testsuite/regression/submodel/restapi/TestSubmodelElementCollectionProvider.java create mode 100644 src/test/java/org/eclipse/basyx/testsuite/regression/submodel/restapi/observing/ObservableSubmodelAPITest.java create mode 100644 src/test/java/org/eclipse/basyx/testsuite/regression/vab/protocol/opcua/BrowsePathHelperTest.java create mode 100644 src/test/resources/aas/factory/aasx/01_Festo.aasx create mode 100644 src/test/resources/aas/factory/xml/inWorkarounds.xml diff --git a/src/main/java/org/eclipse/basyx/aas/aggregator/AASAggregatorAPIHelper.java b/src/main/java/org/eclipse/basyx/aas/aggregator/AASAggregatorAPIHelper.java new file mode 100644 index 00000000..f42d2c68 --- /dev/null +++ b/src/main/java/org/eclipse/basyx/aas/aggregator/AASAggregatorAPIHelper.java @@ -0,0 +1,47 @@ +/******************************************************************************* +* Copyright (C) 2021 the Eclipse BaSyx Authors +* +* This program and the accompanying materials are made +* available under the terms of the Eclipse Public License 2.0 +* which is available at https://www.eclipse.org/legal/epl-2.0/ + +* +* SPDX-License-Identifier: EPL-2.0 +******************************************************************************/ + +package org.eclipse.basyx.aas.aggregator; + +import org.eclipse.basyx.aas.aggregator.restapi.AASAggregatorProvider; +import org.eclipse.basyx.submodel.metamodel.api.identifier.IIdentifier; +import org.eclipse.basyx.vab.modelprovider.VABPathTools; + +/** + * API helper for AAS Aggregator + * @author haque + * + */ +public class AASAggregatorAPIHelper { + public static final String AAS_SUFFIX = "aas"; + + public static String getAggregatorPath() { + return AASAggregatorProvider.PREFIX; + } + + /** + * Retrieves access path for creating, updating, deleting single AAS + * @param aasId + * @return + */ + public static String getAASEntryPath(IIdentifier aasId) { + return VABPathTools.concatenatePaths(getAggregatorPath(), VABPathTools.encodePathElement(aasId.getId())); + } + + /** + * Retrieves access path for getting single AAS + * @param aasId + * @return + */ + public static String getAASAccessPath(IIdentifier aasId) { + return VABPathTools.concatenatePaths(getAASEntryPath(aasId), AASAggregatorAPIHelper.AAS_SUFFIX); + } +} diff --git a/src/main/java/org/eclipse/basyx/aas/aggregator/api/IAASAggregator.java b/src/main/java/org/eclipse/basyx/aas/aggregator/api/IAASAggregator.java index 2a103753..a8241e25 100644 --- a/src/main/java/org/eclipse/basyx/aas/aggregator/api/IAASAggregator.java +++ b/src/main/java/org/eclipse/basyx/aas/aggregator/api/IAASAggregator.java @@ -19,7 +19,7 @@ /** - * Interface for the Asset Administration Shell Aggregator API
+ * Interface for the Asset Administration Shell Aggregator API
* It is used to manage multiple AASs at the same endpoint * * @author conradi diff --git a/src/main/java/org/eclipse/basyx/aas/aggregator/observing/IAASAggregatorObserver.java b/src/main/java/org/eclipse/basyx/aas/aggregator/observing/IAASAggregatorObserver.java new file mode 100644 index 00000000..927feb37 --- /dev/null +++ b/src/main/java/org/eclipse/basyx/aas/aggregator/observing/IAASAggregatorObserver.java @@ -0,0 +1,40 @@ +/******************************************************************************* +* Copyright (C) 2021 the Eclipse BaSyx Authors +* +* This program and the accompanying materials are made +* available under the terms of the Eclipse Public License 2.0 +* which is available at https://www.eclipse.org/legal/epl-2.0/ + +* +* SPDX-License-Identifier: EPL-2.0 +******************************************************************************/ + +package org.eclipse.basyx.aas.aggregator.observing; + +import org.eclipse.basyx.aas.observer.IObserver; + +/** + * Interface for an observer of {@link ObservableAASAggregator} + * @author haque + * + */ +public interface IAASAggregatorObserver extends IObserver { + + /** + * Is called when an AAS is created + * @param aasId id of the created AAS + */ + public void aasCreated(String aasId); + + /** + * Is called when an AAS is updated + * @param aasId id of the updated AAS + */ + public void aasUpdated(String aasId); + + /** + * Is called when an AAS is deleted + * @param aasId id of the deleted AAS + */ + public void aasDeleted(String aasId); +} diff --git a/src/main/java/org/eclipse/basyx/aas/aggregator/observing/ObservableAASAggregator.java b/src/main/java/org/eclipse/basyx/aas/aggregator/observing/ObservableAASAggregator.java new file mode 100644 index 00000000..2de7469d --- /dev/null +++ b/src/main/java/org/eclipse/basyx/aas/aggregator/observing/ObservableAASAggregator.java @@ -0,0 +1,72 @@ +/******************************************************************************* +* Copyright (C) 2021 the Eclipse BaSyx Authors +* +* This program and the accompanying materials are made +* available under the terms of the Eclipse Public License 2.0 +* which is available at https://www.eclipse.org/legal/epl-2.0/ + +* +* SPDX-License-Identifier: EPL-2.0 +******************************************************************************/ + +package org.eclipse.basyx.aas.aggregator.observing; + +import java.util.Collection; + +import org.eclipse.basyx.aas.aggregator.api.IAASAggregator; +import org.eclipse.basyx.aas.metamodel.api.IAssetAdministrationShell; +import org.eclipse.basyx.aas.metamodel.map.AssetAdministrationShell; +import org.eclipse.basyx.aas.observer.Observable; +import org.eclipse.basyx.submodel.metamodel.api.identifier.IIdentifier; +import org.eclipse.basyx.vab.exception.provider.ResourceNotFoundException; +import org.eclipse.basyx.vab.modelprovider.api.IModelProvider; + +/** + * + * Implementation of {@link IAASAggregator} that calls back registered {@link IAASAggregatorObserver} + * when changes on AAS occur + * + * @author haque + * + */ +public class ObservableAASAggregator extends Observable implements IAASAggregator { + + private IAASAggregator aasAggregator; + + public ObservableAASAggregator(IAASAggregator aggregator) { + this.aasAggregator = aggregator; + } + + @Override + public Collection getAASList() { + return aasAggregator.getAASList(); + } + + @Override + public IAssetAdministrationShell getAAS(IIdentifier aasId) throws ResourceNotFoundException { + return aasAggregator.getAAS(aasId); + } + + @Override + public IModelProvider getAASProvider(IIdentifier aasId) throws ResourceNotFoundException { + return aasAggregator.getAASProvider(aasId); + } + + @Override + public void createAAS(AssetAdministrationShell aas) { + aasAggregator.createAAS(aas); + observers.stream().forEach(o -> o.aasCreated(aas.getIdentification().getId())); + } + + @Override + public void updateAAS(AssetAdministrationShell aas) throws ResourceNotFoundException { + aasAggregator.updateAAS(aas); + observers.stream().forEach(o -> o.aasUpdated(aas.getIdentification().getId())); + } + + @Override + public void deleteAAS(IIdentifier aasId) { + aasAggregator.deleteAAS(aasId); + observers.stream().forEach(o -> o.aasDeleted(aasId.getId())); + } +} diff --git a/src/main/java/org/eclipse/basyx/aas/aggregator/proxy/AASAggregatorProxy.java b/src/main/java/org/eclipse/basyx/aas/aggregator/proxy/AASAggregatorProxy.java index c3fc9a72..8a7eb957 100644 --- a/src/main/java/org/eclipse/basyx/aas/aggregator/proxy/AASAggregatorProxy.java +++ b/src/main/java/org/eclipse/basyx/aas/aggregator/proxy/AASAggregatorProxy.java @@ -14,6 +14,7 @@ import java.util.stream.Collectors; import org.eclipse.basyx.aas.aggregator.api.IAASAggregator; +import org.eclipse.basyx.aas.aggregator.AASAggregatorAPIHelper; import org.eclipse.basyx.aas.aggregator.restapi.AASAggregatorProvider; import org.eclipse.basyx.aas.metamodel.api.IAssetAdministrationShell; import org.eclipse.basyx.aas.metamodel.connected.ConnectedAssetAdministrationShell; @@ -29,8 +30,8 @@ import org.slf4j.LoggerFactory; public class AASAggregatorProxy implements IAASAggregator { + protected IModelProvider provider; private static Logger logger = LoggerFactory.getLogger(AASRegistryProxy.class); - private IModelProvider provider; /** * Constructor for an AAS aggregator proxy based on a HTTP connection @@ -53,19 +54,19 @@ public AASAggregatorProxy(IModelProvider provider) { } /** - * Adds the "/shells" suffix if it does not exist + * Removes the "/shells" suffix if it exists * * @param url * @return */ private static String harmonizeURL(String url) { - return VABPathTools.harmonizePathWithSuffix(url, AASAggregatorProvider.PREFIX); + return VABPathTools.stripFromPath(url, AASAggregatorProvider.PREFIX); } @SuppressWarnings("unchecked") @Override public Collection getAASList() { - Collection> collection = (Collection>) provider.getValue(""); + Collection> collection = (Collection>) provider.getValue(AASAggregatorAPIHelper.getAggregatorPath()); logger.debug("Getting all AAS"); return collection.stream().map(m -> AssetAdministrationShell.createAsFacade(m)).map(aas -> getConnectedAAS(aas.getIdentification(), aas)).collect(Collectors.toList()); } @@ -91,36 +92,31 @@ private ConnectedAssetAdministrationShell getConnectedAAS(IIdentifier aasId, Ass private VABElementProxy getAASProxy(IIdentifier aasId) { - String path = VABPathTools.concatenatePaths(getEncodedIdentifier(aasId), "aas"); + String path = AASAggregatorAPIHelper.getAASAccessPath(aasId); VABElementProxy proxy = new VABElementProxy(path, provider); return proxy; } @Override public void createAAS(AssetAdministrationShell aas) { - provider.setValue(getEncodedIdentifier(aas.getIdentification()), aas); + provider.setValue(AASAggregatorAPIHelper.getAASEntryPath(aas.getIdentification()), aas); logger.info("AAS with Id " + aas.getIdentification().getId() + " created"); } @Override public void updateAAS(AssetAdministrationShell aas) { - provider.setValue(getEncodedIdentifier(aas.getIdentification()), aas); + provider.setValue(AASAggregatorAPIHelper.getAASEntryPath(aas.getIdentification()), aas); logger.info("AAS with Id " + aas.getIdentification().getId() + " updated"); } @Override public void deleteAAS(IIdentifier aasId) { - provider.deleteValue(getEncodedIdentifier(aasId)); + provider.deleteValue(AASAggregatorAPIHelper.getAASEntryPath(aasId)); logger.info("AAS with Id " + aasId.getId() + " deleted"); } @Override public IModelProvider getAASProvider(IIdentifier aasId) { - return new VABElementProxy(getEncodedIdentifier(aasId), provider); + return new VABElementProxy(AASAggregatorAPIHelper.getAASEntryPath(aasId), provider); } - - private String getEncodedIdentifier(IIdentifier aasId) { - return VABPathTools.encodePathElement(aasId.getId()); - } - } diff --git a/src/main/java/org/eclipse/basyx/aas/aggregator/restapi/AASAggregatorProvider.java b/src/main/java/org/eclipse/basyx/aas/aggregator/restapi/AASAggregatorProvider.java index e4fc3a1a..5cf0a503 100644 --- a/src/main/java/org/eclipse/basyx/aas/aggregator/restapi/AASAggregatorProvider.java +++ b/src/main/java/org/eclipse/basyx/aas/aggregator/restapi/AASAggregatorProvider.java @@ -33,7 +33,7 @@ */ public class AASAggregatorProvider implements IModelProvider { - private IAASAggregator aggregator; + protected IAASAggregator aggregator; public static final String PREFIX = "shells"; @@ -49,7 +49,7 @@ public AASAggregatorProvider(IAASAggregator aggregator) { * @return * @throws MalformedRequestException */ - private String stripPrefix(String path) throws MalformedRequestException { + protected String stripPrefix(String path) throws MalformedRequestException { path = VABPathTools.stripSlashes(path); if (!path.startsWith(PREFIX)) { throw new MalformedRequestException("Path " + path + " not recognized as aggregator path. Has to start with " + PREFIX); @@ -60,7 +60,7 @@ private String stripPrefix(String path) throws MalformedRequestException { } /** - * Makes sure, that given Object is an AAS by checking its ModelType
+ * Makes sure, that given Object is an AAS by checking its ModelType
* Creates a new AAS with the content of the given Map * * @param value diff --git a/src/main/java/org/eclipse/basyx/aas/bundle/AASBundle.java b/src/main/java/org/eclipse/basyx/aas/bundle/AASBundle.java new file mode 100644 index 00000000..0f816bbd --- /dev/null +++ b/src/main/java/org/eclipse/basyx/aas/bundle/AASBundle.java @@ -0,0 +1,65 @@ +/******************************************************************************* + * Copyright (C) 2021 the Eclipse BaSyx Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + ******************************************************************************/ +package org.eclipse.basyx.aas.bundle; + +import java.util.Objects; +import java.util.Set; + +import org.eclipse.basyx.aas.metamodel.api.IAssetAdministrationShell; +import org.eclipse.basyx.submodel.metamodel.api.ISubmodel; + +/** + * Helper class to bundle an AAS with its corresponding submodels, e.g. for + * passing them to a server environment + * + * @author schnicke + * + */ +public class AASBundle { + private IAssetAdministrationShell aas; + private Set submodels; + + public AASBundle(IAssetAdministrationShell aas, Set submodels) { + super(); + this.aas = aas; + this.submodels = submodels; + } + + public IAssetAdministrationShell getAAS() { + return aas; + } + + public Set getSubmodels() { + return submodels; + } + + @Override + public int hashCode() { + return Objects.hash(aas, submodels); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj == null) + return false; + if (getClass() != obj.getClass()) + return false; + AASBundle other = (AASBundle) obj; + return Objects.equals(this.aas, other.aas) && Objects.equals(this.submodels, other.submodels); + } + + @Override + public String toString() { + return "AASBundle [aas=" + aas + ", submodels=" + submodels + "]"; + } + +} diff --git a/src/main/java/org/eclipse/basyx/aas/bundle/AASBundleDescriptorFactory.java b/src/main/java/org/eclipse/basyx/aas/bundle/AASBundleDescriptorFactory.java new file mode 100644 index 00000000..654cc2ee --- /dev/null +++ b/src/main/java/org/eclipse/basyx/aas/bundle/AASBundleDescriptorFactory.java @@ -0,0 +1,45 @@ +/******************************************************************************* + * Copyright (C) 2021 the Eclipse BaSyx Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + ******************************************************************************/ +package org.eclipse.basyx.aas.bundle; + +import org.eclipse.basyx.aas.metamodel.map.descriptor.AASDescriptor; +import org.eclipse.basyx.aas.metamodel.map.descriptor.SubmodelDescriptor; +import org.eclipse.basyx.vab.modelprovider.VABPathTools; + +/** + * Helper class that supports AASDescriptor utilization for an AASBundle + * + * @author schnicke + * + */ +public class AASBundleDescriptorFactory { + /** + * Creates the AASDescriptor for the given bundle and hostPath + * + * @param bundle + * @param hostBasePath + * @return + */ + public static AASDescriptor createAASDescriptor(AASBundle bundle, String hostBasePath) { + // Normalize hostBasePath to ensure consistent usage of / + String nHostBasePath = VABPathTools.stripSlashes(hostBasePath); + + // Create AASDescriptor + String endpointId = bundle.getAAS().getIdentification().getId(); + endpointId = VABPathTools.encodePathElement(endpointId); + String aasBase = VABPathTools.concatenatePaths(nHostBasePath, endpointId, "aas"); + AASDescriptor desc = new AASDescriptor(bundle.getAAS(), aasBase); + bundle.getSubmodels().stream().forEach(s -> { + SubmodelDescriptor smDesc = new SubmodelDescriptor(s, VABPathTools.concatenatePaths(aasBase, "submodels", s.getIdShort(), "submodel")); + desc.addSubmodelDescriptor(smDesc); + }); + return desc; + } +} diff --git a/src/main/java/org/eclipse/basyx/aas/bundle/AASBundleFactory.java b/src/main/java/org/eclipse/basyx/aas/bundle/AASBundleFactory.java new file mode 100644 index 00000000..69f52be6 --- /dev/null +++ b/src/main/java/org/eclipse/basyx/aas/bundle/AASBundleFactory.java @@ -0,0 +1,136 @@ +/******************************************************************************* + * Copyright (C) 2021 the Eclipse BaSyx Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + ******************************************************************************/ + +package org.eclipse.basyx.aas.bundle; + +import java.util.Collection; +import java.util.HashSet; +import java.util.Optional; +import java.util.Set; + +import org.eclipse.basyx.aas.metamodel.api.IAssetAdministrationShell; +import org.eclipse.basyx.aas.metamodel.api.parts.asset.IAsset; +import org.eclipse.basyx.aas.metamodel.map.AssetAdministrationShell; +import org.eclipse.basyx.aas.metamodel.map.parts.Asset; +import org.eclipse.basyx.submodel.metamodel.api.ISubmodel; +import org.eclipse.basyx.submodel.metamodel.api.qualifier.IIdentifiable; +import org.eclipse.basyx.submodel.metamodel.api.reference.IKey; +import org.eclipse.basyx.submodel.metamodel.api.reference.IReference; +import org.eclipse.basyx.vab.exception.provider.ResourceNotFoundException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Factory for creation of AASBundles from Sets of AAS, Submodels and Assets + * + * @author schnicke + * + */ +public class AASBundleFactory { + private static Logger logger = LoggerFactory.getLogger(AASBundleFactory.class); + + /** + * Creates from a collection of AAS, Submodels and Assets the appropriate set of + * AASBundles + * + * @param shells + * @param submodels + * @param assets + * @return + */ + public Set create(Collection shells, + Collection submodels, Collection assets) { + Set bundles = new HashSet<>(); + + for (IAssetAdministrationShell shell : shells) { + if (shouldSetAsset(shell)) { + setAsset(assets, shell); + } + + // Retrieve submodels + Set currentSM = retrieveSubmodelsForAAS(submodels, shell); + bundles.add(new AASBundle(shell, currentSM)); + } + + return bundles; + } + + private boolean shouldSetAsset(IAssetAdministrationShell shell) { + return shell.getAsset() == null && shell.getAssetReference() != null; + } + + private void setAsset(Collection assets, IAssetAdministrationShell shell) { + // Retrieve asset + try { + IReference assetRef = shell.getAssetReference(); + IAsset asset = getByReference(assetRef, assets); + ((AssetAdministrationShell) shell).setAsset((Asset) asset); + } catch (ResourceNotFoundException e) { + // Enables parsing external aasx-files without any keys in assetref + if (shell.getAssetReference().getKeys().size() > 0) { + logger.warn("Can't find asset with id " + shell.getAssetReference().getKeys().get(0).getValue() + " for AAS " + shell.getIdShort() + "; If the asset is not provided in another way, this is an error!"); + } else { + logger.warn("Can't find asset for AAS " + shell.getIdShort() + "; If the asset is not provided in another way, this is an error!"); + } + } + } + + /** + * Retrieves the Submodels belonging to an AAS + * + * @param submodels + * @param shell + * @return + */ + private Set retrieveSubmodelsForAAS(Collection submodels, + IAssetAdministrationShell shell) { + Set currentSM = new HashSet<>(); + + for (IReference submodelRef : shell.getSubmodelReferences()) { + try { + ISubmodel sm = getByReference(submodelRef, submodels); + currentSM.add(sm); + logger.debug("Found Submodel " + sm.getIdShort() + " for AAS " + shell.getIdShort()); + } catch (ResourceNotFoundException e) { + // If there's no match, the submodel is assumed to be provided by different + // means, e.g. it is already being hosted + logger.warn("Could not find Submodel " + submodelRef.getKeys().get(0).getValue() + " for AAS " + shell.getIdShort() + "; If it is not hosted elsewhere this is an error!"); + } + } + return currentSM; + } + + /** + * Retrieves an identifiable from a list of identifiable by its reference + * + * @param submodelRef + * @param identifiable + * @return + * @throws ResourceNotFoundException + */ + private T getByReference(IReference ref, Collection identifiable) + throws ResourceNotFoundException { + IKey lastKey = null; + // It may be that only one key fits to the Submodel contained in the XML + for (IKey key : ref.getKeys()) { + lastKey = key; + // There will only be a single submodel matching the identification at max + Optional match = identifiable.stream().filter(s -> s.getIdentification().getId().equals(key.getValue())).findFirst(); + if (match.isPresent()) { + return match.get(); + } + } + if (lastKey == null) { + throw new ResourceNotFoundException("Could not resolve reference without keys"); + } else { + throw new ResourceNotFoundException("Could not resolve reference with last key '" + lastKey.getValue() + "'"); + } + } +} diff --git a/src/main/java/org/eclipse/basyx/aas/bundle/AASBundleHelper.java b/src/main/java/org/eclipse/basyx/aas/bundle/AASBundleHelper.java new file mode 100644 index 00000000..c7005cff --- /dev/null +++ b/src/main/java/org/eclipse/basyx/aas/bundle/AASBundleHelper.java @@ -0,0 +1,139 @@ +/******************************************************************************* + * Copyright (C) 2021 the Eclipse BaSyx Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + ******************************************************************************/ +package org.eclipse.basyx.aas.bundle; + +import java.util.Collection; + +import org.eclipse.basyx.aas.aggregator.api.IAASAggregator; +import org.eclipse.basyx.aas.metamodel.api.IAssetAdministrationShell; +import org.eclipse.basyx.aas.metamodel.map.AssetAdministrationShell; +import org.eclipse.basyx.aas.registration.api.IAASRegistry; +import org.eclipse.basyx.submodel.metamodel.api.ISubmodel; +import org.eclipse.basyx.submodel.metamodel.map.Submodel; +import org.eclipse.basyx.submodel.restapi.SubmodelProvider; +import org.eclipse.basyx.vab.exception.provider.ProviderException; +import org.eclipse.basyx.vab.exception.provider.ResourceNotFoundException; +import org.eclipse.basyx.vab.modelprovider.api.IModelProvider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + + +/** + * This class can be used to check if all required resources are present on a server
+ * (e.g. after a restart) and upload them if necessary. + * + * @author conradi + * + */ +public class AASBundleHelper { + + private static Logger logger = LoggerFactory.getLogger(AASBundleHelper.class); + + /** + * Checks (by ID) if all AASs/SMs contained
+ * in the given AASBundles exist in the AASAggregator.
+ * Adds missing ones to the Aggregator.
+ * If a given object already exists in the Aggregator it will NOT be replaced. + * + * @param aggregator the Aggregator to be populated + * @param bundles the AASBundles + * @return true if an AAS/SM was uploaded; false otherwise + */ + public static boolean integrate(IAASAggregator aggregator, Collection bundles) { + + if(aggregator == null || bundles == null) { + throw new RuntimeException("'aggregator' and 'bundles' must not be null."); + } + + boolean objectUploaded = false; + + for(AASBundle bundle: bundles) { + IAssetAdministrationShell aas = bundle.getAAS(); + + try { + aggregator.getAAS(aas.getIdentification()); + // If no ResourceNotFoundException occurs, AAS exists on server + // -> no further action required + } catch(ResourceNotFoundException e) { + // AAS does not exist and needs to be pushed to the server + // Cast Interface to concrete class + if(aas instanceof AssetAdministrationShell) { + aggregator.createAAS((AssetAdministrationShell) aas); + objectUploaded = true; + } else { + throw new RuntimeException("aas Objects in bundles need to be instance of 'AssetAdministrationShell'"); + } + } + + IModelProvider provider = aggregator.getAASProvider(aas.getIdentification()); + for (ISubmodel sm : bundle.getSubmodels()) { + try { + provider.getValue("/aas/submodels/" + sm.getIdShort() + "/" + SubmodelProvider.SUBMODEL); + // If no ResourceNotFoundException occurs, SM exists on server + // -> no further action required + } catch (ResourceNotFoundException e) { + // AAS does not exist and needs to be pushed to the server + // Check if ISubmodel is a concrete Submodel + if (sm instanceof Submodel) { + provider.setValue("/aas/submodels/" + sm.getIdShort(), sm); + objectUploaded = true; + } else { + throw new RuntimeException("sm Objects in bundles need to be instance of 'Submodel'"); + } + } + } + } + return objectUploaded; + } + + /** + * Registers a given set of bundles with the registry + * + * @param registry + * the registry to register with + * @param bundles + * the bundles to register + * @param aasAggregatorPath + * the aggregator path, e.g. http://localhost:4000/shells + */ + public static void register(IAASRegistry registry, Collection bundles, String aasAggregatorPath) { + bundles.stream().map(b -> AASBundleDescriptorFactory.createAASDescriptor(b, aasAggregatorPath)).forEach(registry::register); + } + + /** + * Deregisters a given set of bundles from a given registry + * + * @param registry the registry to deregister from + * @param bundles the AASBundles to be deregistred + */ + public static void deregister(IAASRegistry registry, Collection bundles) { + if(registry != null && bundles != null) { + for(AASBundle bundle: bundles) { + IAssetAdministrationShell aas = bundle.getAAS(); + + try { + registry.delete(aas.getIdentification()); + } catch (ProviderException e) { + logger.info("The AAS '" + aas.getIdShort() + "' can't be deregistered. It was not found in registry."); + // Just continue if deregistration failed + } + + for(ISubmodel sm: bundle.getSubmodels()) { + try { + registry.delete(sm.getIdentification()); + } catch (ProviderException e) { + logger.info("The SM '" + sm.getIdShort() + "' can't be deregistered. It was not found in registry."); + // Just continue if deregistration failed + } + } + } + } + } +} diff --git a/src/main/java/org/eclipse/basyx/aas/factory/aasx/AASXToMetamodelConverter.java b/src/main/java/org/eclipse/basyx/aas/factory/aasx/AASXToMetamodelConverter.java new file mode 100644 index 00000000..8fa5c344 --- /dev/null +++ b/src/main/java/org/eclipse/basyx/aas/factory/aasx/AASXToMetamodelConverter.java @@ -0,0 +1,300 @@ +/******************************************************************************* + * Copyright (C) 2021 the Eclipse BaSyx Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + ******************************************************************************/ + +package org.eclipse.basyx.aas.factory.aasx; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.StringWriter; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Set; + +import javax.xml.parsers.ParserConfigurationException; + +import org.apache.commons.io.FileUtils; +import org.apache.commons.io.IOUtils; +import org.apache.poi.openxml4j.exceptions.InvalidFormatException; +import org.apache.poi.openxml4j.opc.OPCPackage; +import org.apache.poi.openxml4j.opc.PackagePart; +import org.apache.poi.openxml4j.opc.PackageRelationshipCollection; +import org.apache.poi.openxml4j.opc.PackagingURIHelper; +import org.eclipse.basyx.aas.bundle.AASBundle; +import org.eclipse.basyx.aas.bundle.AASBundleFactory; +import org.eclipse.basyx.aas.factory.xml.XMLToMetamodelConverter; +import org.eclipse.basyx.submodel.metamodel.api.ISubmodel; +import org.eclipse.basyx.submodel.metamodel.api.submodelelement.ISubmodelElement; +import org.eclipse.basyx.submodel.metamodel.api.submodelelement.ISubmodelElementCollection; +import org.eclipse.basyx.submodel.metamodel.api.submodelelement.dataelement.IFile; +import org.eclipse.basyx.vab.modelprovider.VABPathTools; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.xml.sax.SAXException; + +/** + * The AASX package converter converts a aasx package into a list of aas, a list + * of submodels a list of assets, a list of Concept descriptions + * + * The aas provides the references to the submodels and assets + * + * @author zhangzai, conradi + * + */ +public class AASXToMetamodelConverter { + + private static final String XML_TYPE = "http://www.admin-shell.io/aasx/relationships/aas-spec"; + private static final String AASX_ORIGIN = "/aasx/aasx-origin"; + + private String aasxPath; + private OPCPackage aasxRoot; + private InputStream aasxInputStream; + + private Set bundles; + + private static Logger logger = LoggerFactory.getLogger(AASXToMetamodelConverter.class); + + public AASXToMetamodelConverter(String path) { + this.aasxPath = path; + } + + public AASXToMetamodelConverter(InputStream stream) { + this.aasxInputStream = stream; + } + + @SuppressWarnings("unchecked") + public Set retrieveAASBundles() throws IOException, ParserConfigurationException, SAXException, InvalidFormatException { + + // If the XML was already parsed return cached Bundles + if (bundles != null) { + return (Set) bundles; + } + + loadAASX(); + + String xmlContent = getXMLResourceString(aasxRoot); + XMLToMetamodelConverter converter = new XMLToMetamodelConverter(xmlContent); + + bundles = new AASBundleFactory().create(converter.parseAAS(), converter.parseSubmodels(), converter.parseAssets()); + + closeOPCPackage(); + + return (Set) bundles; + } + + private void loadAASX() throws IOException, InvalidFormatException { + if (aasxInputStream == null) { + aasxInputStream = getInputStream(aasxPath); + } + + if (aasxRoot == null) { + aasxRoot = OPCPackage.open(aasxInputStream); + } + } + + private void closeOPCPackage() throws IOException { + aasxRoot.close(); + } + + /** + * Return the Content of the XML file in the aasx-package as String + * + * @param aasxPackage + * - the root package of the AASX + * @return Content of XML as String + * @throws InvalidFormatException + * @throws IOException + */ + private String getXMLResourceString(OPCPackage aasxPackage) throws InvalidFormatException, IOException { + + // Get the "/aasx/aasx-origin" Part. It is Relationship source for the + // XML-Document + PackagePart originPart = aasxPackage.getPart(PackagingURIHelper.createPartName(AASX_ORIGIN)); + + // Get the Relation to the XML Document + PackageRelationshipCollection originRelationships = originPart.getRelationshipsByType(XML_TYPE); + + // If there is more than one or no XML-Document that is an error + if (originRelationships.size() > 1) { + throw new RuntimeException("More than one 'aasx-spec' document found in .aasx"); + } else if (originRelationships.size() == 0) { + throw new RuntimeException("No 'aasx-spec' document found in .aasx"); + } + + // Get the PackagePart of the XML-Document + PackagePart xmlPart = originPart.getRelatedPart(originRelationships.getRelationship(0)); + + // Read the content from the PackagePart + InputStream stream = xmlPart.getInputStream(); + StringWriter writer = new StringWriter(); + IOUtils.copy(stream, writer, StandardCharsets.UTF_8); + return writer.toString(); + } + + /** + * Load the referenced filepaths in the submodels such as PDF, PNG files from + * the package + * + * @return a map of the folder name and folder path, the folder holds the files + * @throws IOException + * @throws SAXException + * @throws ParserConfigurationException + * @throws InvalidFormatException + * + */ + private List parseReferencedFilePathsFromAASX() throws IOException, ParserConfigurationException, SAXException, InvalidFormatException { + + Set bundles = retrieveAASBundles(); + + List submodels = new ArrayList<>(); + + // Get the Submodels from all AASBundles + for (AASBundle bundle : bundles) { + submodels.addAll(bundle.getSubmodels()); + } + + List paths = new ArrayList(); + + for (ISubmodel sm : submodels) { + paths.addAll(parseElements(sm.getSubmodelElements().values())); + } + return paths; + } + + /** + * Gets the paths from a collection of ISubmodelElement + * + * @param elements + * @return the Paths from the File elements + */ + private List parseElements(Collection elements) { + List paths = new ArrayList(); + + for (ISubmodelElement element : elements) { + if (element instanceof IFile) { + IFile file = (IFile) element; + // If the path contains a "://", we can assume, that the Path is a link to an + // other server + // e.g. http://localhost:8080/aasx/... + if (!file.getValue().contains("://")) { + paths.add(file.getValue()); + } + } else if (element instanceof ISubmodelElementCollection) { + ISubmodelElementCollection collection = (ISubmodelElementCollection) element; + paths.addAll(parseElements(collection.getSubmodelElements().values())); + } + } + return paths; + } + + /** + * Unzips all files referenced by the aasx file according to its relationships + * + * + * @throws IOException + * @throws SAXException + * @throws ParserConfigurationException + * @throws URISyntaxException + * @throws InvalidFormatException + */ + public void unzipRelatedFiles() throws IOException, ParserConfigurationException, SAXException, URISyntaxException, InvalidFormatException { + // load folder which stores the files + loadAASX(); + + List files = parseReferencedFilePathsFromAASX(); + for (String filePath : files) { + // name of the folder + unzipFile(filePath, aasxRoot); + } + + closeOPCPackage(); + } + + /** + * Create a folder to hold the unpackaged files The folder has the path + * \target\classes\docs + * + * @throws IOException + * @throws URISyntaxException + */ + protected Path getRootFolder() throws IOException, URISyntaxException { + URI uri = AASXToMetamodelConverter.class.getProtectionDomain().getCodeSource().getLocation().toURI(); + URI parent = new File(uri).getParentFile().toURI(); + return Paths.get(parent); + } + + /** + * unzip the file folders + * + * @param filePath + * - path of the file in the aasx to unzip + * @param aasxPath + * - aasx path + * @throws IOException + * @throws URISyntaxException + * @throws InvalidFormatException + */ + private void unzipFile(String filePath, OPCPackage aasxRoot) throws IOException, URISyntaxException, InvalidFormatException { + // Create destination directory + if (filePath.startsWith("/")) { + filePath = filePath.substring(1); + } + if (filePath.isEmpty()) { + logger.warn("A file with empty path can not be unzipped."); + return; + } + logger.info("Unzipping " + filePath + " to root folder:"); + String relativePath = "files/" + VABPathTools.getParentPath(filePath); + Path rootPath = getRootFolder(); + Path destDir = rootPath.resolve(relativePath); + logger.info("Unzipping to " + destDir); + Files.createDirectories(destDir); + + PackagePart part = aasxRoot.getPart(PackagingURIHelper.createPartName("/" + filePath)); + + if (part == null) { + logger.warn("File '" + filePath + "' could not be unzipped. It does not exist in .aasx."); + return; + } + + String targetPath = destDir.toString() + "/" + VABPathTools.getLastElement(filePath); + InputStream stream = part.getInputStream(); + FileUtils.copyInputStreamToFile(stream, new File(targetPath)); + } + + private InputStream getInputStream(String aasxFilePath) throws IOException { + InputStream stream = getResourceStream(aasxFilePath); + if (stream != null) { + return stream; + } else { + // Alternativ, if resource has not been found: load from a file + try { + return new FileInputStream(aasxFilePath); + } catch (FileNotFoundException e) { + logger.error("File '" + aasxFilePath + "' to be loaded was not found."); + throw e; + } + } + } + + private static InputStream getResourceStream(String relativeResourcePath) { + ClassLoader classLoader = AASXToMetamodelConverter.class.getClassLoader(); + return classLoader.getResourceAsStream(relativeResourcePath); + } +} diff --git a/src/main/java/org/eclipse/basyx/aas/factory/aasx/MetamodelToAASXConverter.java b/src/main/java/org/eclipse/basyx/aas/factory/aasx/MetamodelToAASXConverter.java new file mode 100644 index 00000000..6271ff83 --- /dev/null +++ b/src/main/java/org/eclipse/basyx/aas/factory/aasx/MetamodelToAASXConverter.java @@ -0,0 +1,309 @@ +/******************************************************************************* + * Copyright (C) 2021 the Eclipse BaSyx Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + ******************************************************************************/ +package org.eclipse.basyx.aas.factory.aasx; + +import java.io.IOException; +import java.io.OutputStream; +import java.io.StringWriter; +import java.util.ArrayList; +import java.util.Collection; +import java.util.UUID; +import java.util.stream.Collectors; + +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.transform.TransformerException; +import javax.xml.transform.stream.StreamResult; + +import org.apache.poi.openxml4j.exceptions.InvalidFormatException; +import org.apache.poi.openxml4j.opc.OPCPackage; +import org.apache.poi.openxml4j.opc.PackagePart; +import org.apache.poi.openxml4j.opc.PackagePartName; +import org.apache.poi.openxml4j.opc.PackagingURIHelper; +import org.apache.poi.openxml4j.opc.RelationshipSource; +import org.apache.poi.openxml4j.opc.TargetMode; +import org.apache.poi.openxml4j.opc.internal.MemoryPackagePart; +import org.eclipse.basyx.aas.factory.xml.MetamodelToXMLConverter; +import org.eclipse.basyx.aas.metamodel.api.IAssetAdministrationShell; +import org.eclipse.basyx.aas.metamodel.api.parts.asset.IAsset; +import org.eclipse.basyx.submodel.metamodel.api.ISubmodel; +import org.eclipse.basyx.submodel.metamodel.api.parts.IConceptDescription; +import org.eclipse.basyx.submodel.metamodel.api.submodelelement.ISubmodelElement; +import org.eclipse.basyx.submodel.metamodel.map.submodelelement.SubmodelElementCollection; +import org.eclipse.basyx.submodel.metamodel.map.submodelelement.dataelement.File; +import org.eclipse.basyx.vab.exception.provider.ResourceNotFoundException; +import org.eclipse.basyx.vab.modelprovider.VABPathTools; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + + +/** + * This class can be used to generate an .aasx file from + * Metamodel Objects and the Files referred to in the Submodels + * + * @author conradi + * + */ +public class MetamodelToAASXConverter { + + private static Logger logger = LoggerFactory.getLogger(MetamodelToAASXConverter.class); + + private static final String MIME_PLAINTXT = "text/plain"; + private static final String MIME_XML = "application/xml"; + + private static final String ORIGIN_RELTYPE = "http://www.admin-shell.io/aasx/relationships/aasx-origin"; + private static final String ORIGIN_PATH = "/aasx/aasx-origin"; + private static final String ORIGIN_CONTENT = "Intentionally empty."; + + + private static final String AASSPEC_RELTYPE = "http://www.admin-shell.io/aasx/relationships/aas-spec"; + private static final String XML_PATH = "/aasx/xml/content.xml"; + + + private static final String AASSUPPL_RELTYPE = "http://www.admin-shell.io/aasx/relationships/aas-suppl"; + + + + /** + * Generates the .aasx file and writes it to the given OutputStream + * + * @param aasList the AASs to be saved in the .aasx + * @param assetList the Assets to be saved in the .aasx + * @param conceptDescriptionList the ConceptDescriptions to be saved in the .aasx + * @param submodelList the Submodels to be saved in the .aasx + * @param files the files referred to in the Submodels + * @param os the OutputStream the resulting .aasx is written to + * @throws IOException + * @throws TransformerException + * @throws ParserConfigurationException + */ + public static void buildAASX(Collection aasList, Collection assetList, + Collection conceptDescriptionList, Collection submodelList, Collection files, OutputStream os) throws IOException, TransformerException, ParserConfigurationException { + + prepareFilePaths(submodelList, files); + + OPCPackage rootPackage = OPCPackage.create(os); + + // Create the empty aasx-origin file + PackagePart origin = createAASXPart(rootPackage, rootPackage, ORIGIN_PATH, MIME_PLAINTXT, ORIGIN_RELTYPE, ORIGIN_CONTENT.getBytes()); + + // Convert the given Metamodels to XML + String xml = convertToXML(aasList, assetList, conceptDescriptionList, submodelList); + + // Save the XML to aasx/xml/content.xml + PackagePart xmlPart = createAASXPart(rootPackage, origin, XML_PATH, MIME_XML, AASSPEC_RELTYPE, xml.getBytes()); + + storeFilesInAASX(submodelList, files, rootPackage, xmlPart); + + saveAASX(os, rootPackage); + } + + /** + * Stores the files from the Submodels in the .aasx file + * + * @param submodelList the Submodels + * @param files the content of the files + * @param rootPackage the OPCPackage + * @param xmlPart the Part the files should be related to + */ + private static void storeFilesInAASX(Collection submodelList, Collection files, + OPCPackage rootPackage, PackagePart xmlPart) { + + for(ISubmodel sm: submodelList) { + for(File file: findFileElements(sm.getSubmodelElements().values())) { + String filePath = file.getValue(); + try { + InMemoryFile content = findFileByPath(files, filePath); + logger.trace("Writing file '" + filePath + "' to .aasx."); + createAASXPart(rootPackage, xmlPart, filePath, file.getMimeType(), AASSUPPL_RELTYPE, content.getFileContent()); + } catch (ResourceNotFoundException e) { + // Log that a file is missing and continue building the .aasx + logger.warn("Could not add File '" + filePath + "'. It was not contained in given InMemoryFiles."); + } + } + } + } + + /** + * Saves the OPCPackage to the given OutputStream + * + * @param os the Stream to be saved to + * @param rootPackage the Package to be saved + * @throws IOException + */ + private static void saveAASX(OutputStream os, OPCPackage rootPackage) throws IOException { + rootPackage.flush(); + rootPackage.save(os); + } + + /** + * Generates a UUID. Every element of the + * .aasx needs a unique Id according to the specification + * + * @return UUID + */ + private static String createUniqueID() { + // only letters or underscore as start of id allowed + // https://www.w3.org/TR/1999/REC-xml-names-19990114/#ns-qualnames + // + // old AASX Package Explorer versions expect a leading R + return "Rid_" + UUID.randomUUID().toString(); + } + + /** + * Creates a Part (a file in the .aasx) of the .aasx and adds it to the Package + * + * @param root the OPCPackage + * @param relateTo the Part of the OPC the relationship of the new Part should be added to + * @param path the path inside the .aasx where the new Part should be created + * @param mimeType the mime-type of the file + * @param relType the type of the Relationship + * @param content the data the new part should contain + * @return the created PackagePart; Returned in case it is needed late as a Part to relate to + */ + private static PackagePart createAASXPart(OPCPackage root, RelationshipSource relateTo, String path, String mimeType, String relType, byte[] content) { + if(mimeType == null || mimeType.equals("")) { + throw new RuntimeException("Could not create AASX Part '" + path + "'. No MIME_TYPE specified."); + } + + PackagePartName partName = null; + MemoryPackagePart part = null; + try { + partName = PackagingURIHelper.createPartName(path); + part = new MemoryPackagePart(root, partName, mimeType); + } catch (InvalidFormatException e) { + // This occurs if the given MIME-Type is not valid according to RFC2046 + throw new RuntimeException("Could not create AASX Part '" + path + "'", e); + } + writeDataToPart(part, content); + root.registerPartAndContentType(part); + // set TargetMode to EXTERNAL to force absolute file paths + // this step is necessary for compatibility reasons with AASXPackageExplorer + relateTo.addRelationship(partName, TargetMode.EXTERNAL, relType, createUniqueID()); + return part; + } + + /** + * Writes the content of a byte[] to a Part + * + * @param part the Part to be written to + * @param content the content to be written to the part + */ + private static void writeDataToPart(PackagePart part, byte[] content) { + try(OutputStream ostream = part.getOutputStream();) { + ostream.write(content); + ostream.flush(); + } catch (Exception e) { + throw new RuntimeException("Failed to write content to AASX Part '" + part.getPartName().getName() + "'", e); + } + } + + /** + * Uses the MetamodelToXMLConverter to generate the XML + */ + private static String convertToXML(Collection aasList, Collection assetList, + Collection conceptDescriptionList, Collection submodelList) throws TransformerException, ParserConfigurationException { + + StringWriter writer = new StringWriter(); + MetamodelToXMLConverter.convertToXML(aasList, assetList, conceptDescriptionList, submodelList, new StreamResult(writer)); + + return writer.toString(); + } + + /** + * Gets the File elements from a collection of elements + * Also recursively searches in SubmodelElementCollections + * + * @param elements the Elements to be searched for File elements + * @return the found Files + */ + private static Collection findFileElements(Collection elements) { + Collection files = new ArrayList<>(); + + for(ISubmodelElement element: elements) { + if(element instanceof File) { + files.add((File) element); + } else if(element instanceof SubmodelElementCollection) { + // Recursive call to deal with SubmodelElementCollections + files.addAll(findFileElements(((SubmodelElementCollection) element).getSubmodelElements().values())); + } + } + + return files; + } + + /** + * Find files which has a valid in memory file path + * @param elements + * @param inMemoryFiles + * @return + */ + private static Collection findInMemoryFileElements(Collection elements, Collection inMemoryFiles) { + Collection files = findFileElements(elements); + return files.stream().filter(f -> + isInMemoryFile(inMemoryFiles, f.getValue())) + .collect(Collectors.toList()); + } + + /** + * Replaces the path in File Elements which has an in memory file with the result of preparePath + * + * @param submodels the Submodels + */ + private static void prepareFilePaths(Collection submodels, Collection inMemoryFiles) { + submodels.stream() + .forEach(sm -> findInMemoryFileElements(sm.getSubmodelElements().values(), inMemoryFiles).stream().forEach(f -> f.setValue(preparePath(f.getValue())))); + } + + /** + * Removes the serverpart from a path and ensures it starts with a slash + * + * @param path the path to be prepared + * @return the prepared path + */ + private static String preparePath(String path) { + String newPath = VABPathTools.getPathFromURL(path); + if(!newPath.startsWith("/")) { + newPath = "/" + newPath; + } + return newPath; + } + + /** + * Finds an InMemoryFile by its path + * + * @param files the InMemoryFiles + * @param path the path of the wanted file + * @return the InMemoryFile if it was found; else null + */ + private static InMemoryFile findFileByPath(Collection files, String path) { + for(InMemoryFile file: files) { + if(preparePath(file.getPath()).equals(path)) { + return file; + } + } + throw new ResourceNotFoundException("The wanted file '" + path + "' was not found in the given files."); + } + + /** + * Finds an InMemoryFile by its path + * + * @param files the InMemoryFiles + * @param path the path of the wanted file + * @return the InMemoryFile if it was found; else null + */ + private static boolean isInMemoryFile(Collection files, String path) { + for(InMemoryFile file: files) { + if(VABPathTools.stripSlashes(file.getPath()).equals(VABPathTools.stripSlashes(path))) { + return true; + } + } + return false; + } +} diff --git a/src/main/java/org/eclipse/basyx/aas/factory/aasx/SubmodelFileEndpointLoader.java b/src/main/java/org/eclipse/basyx/aas/factory/aasx/SubmodelFileEndpointLoader.java new file mode 100644 index 00000000..be667bc6 --- /dev/null +++ b/src/main/java/org/eclipse/basyx/aas/factory/aasx/SubmodelFileEndpointLoader.java @@ -0,0 +1,100 @@ +/******************************************************************************* + * Copyright (C) 2021 the Eclipse BaSyx Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + ******************************************************************************/ + + +package org.eclipse.basyx.aas.factory.aasx; + +import java.net.MalformedURLException; +import java.net.URL; +import java.util.Map; + +import org.eclipse.basyx.submodel.metamodel.api.ISubmodel; +import org.eclipse.basyx.submodel.metamodel.api.submodelelement.ISubmodelElement; +import org.eclipse.basyx.submodel.metamodel.api.submodelelement.ISubmodelElementCollection; +import org.eclipse.basyx.submodel.metamodel.map.submodelelement.SubmodelElementCollection; +import org.eclipse.basyx.submodel.metamodel.map.submodelelement.dataelement.File; + +/** + * A utility class for configuring file endpoints in submodels + * + * @author espen + * + */ +public class SubmodelFileEndpointLoader { + /** + * Sets all file and blob submodelElements inside of the submodel to an endpoint + * at a given host relative to its original path. + * + * @param submodel + * @param host + * e.g. localhost + * @param port + * port for the host + * @param path + * path at which the files are hosted on the host (e.g. "/files") + */ + public static void setRelativeFileEndpoints(ISubmodel submodel, String host, int port, String path) { + String fileRoot = "http://" + host + ":" + port + path; + setRelativeFileEndpoints(submodel, fileRoot); + } + + /** + * Sets all file and blob submodelElements inside of the submodel to an endpoint + * at a given host relative to its original path. + * + * @param submodel + * @param fileRoot + * the full root path for the files (e.g. + * "http://localhost:1234/myFiles") + */ + public static void setRelativeFileEndpoints(ISubmodel submodel, String fileRoot) { + Map elements = submodel.getSubmodelElements(); + setMapEndpoints(elements, fileRoot); + } + + /** + * Fixes endpoints in a Map of submodel elements (applicable for Submodels and + * SubmodelElementCollections) + * + * @param elements + * @param fileRoot + */ + private static void setMapEndpoints(Map elements, String fileRoot) { + elements.values().stream().forEach(e -> { + if (e instanceof File) { + File file = (File) e; + setFileEndpoint(file, fileRoot); + } else if (e instanceof ISubmodelElementCollection) { + SubmodelElementCollection col = (SubmodelElementCollection) e; + setMapEndpoints(col.getSubmodelElements(), fileRoot); + } + }); + } + + /** + * Modifies the file value endpoint in a single given file according to a new + * file root path + * + * @param file + * @param fileRoot + */ + private static void setFileEndpoint(File file, String fileRoot) { + String relativePath = file.getValue(); + URL url; + try { + url = new URL(file.getValue()); + relativePath = url.getPath(); + } catch (MalformedURLException e1) { + // assume that the file value is already a relative path + } + String newEndpoint = fileRoot + relativePath; + file.setValue(newEndpoint); + } +} diff --git a/src/main/java/org/eclipse/basyx/aas/factory/json/JSONAASBundleFactory.java b/src/main/java/org/eclipse/basyx/aas/factory/json/JSONAASBundleFactory.java new file mode 100644 index 00000000..79fdbfa5 --- /dev/null +++ b/src/main/java/org/eclipse/basyx/aas/factory/json/JSONAASBundleFactory.java @@ -0,0 +1,62 @@ +/******************************************************************************* + * Copyright (C) 2021 the Eclipse BaSyx Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + ******************************************************************************/ +package org.eclipse.basyx.aas.factory.json; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collection; +import java.util.Set; + +import org.eclipse.basyx.aas.bundle.AASBundle; +import org.eclipse.basyx.aas.bundle.AASBundleFactory; +import org.eclipse.basyx.aas.metamodel.map.AssetAdministrationShell; +import org.eclipse.basyx.aas.metamodel.map.parts.Asset; +import org.eclipse.basyx.submodel.metamodel.map.Submodel; + +/** + * Creates multiple {@link AASBundle} from a JSON containing several AAS and + * Submodels
+ * TODO: ConceptDescriptions + * + * @author espen + * + */ +public class JSONAASBundleFactory { + private String content; + + /** + * + * @param jsonContent + * the content of the JSON + */ + public JSONAASBundleFactory(String jsonContent) { + this.content = jsonContent; + } + + public JSONAASBundleFactory(Path jsonFile) throws IOException { + content = new String(Files.readAllBytes(jsonFile)); + } + + /** + * Creates the set of {@link AASBundle} contained in the JSON string. + * + * @return + */ + public Set create() { + JSONToMetamodelConverter converter = new JSONToMetamodelConverter(content); + + Collection shells = converter.parseAAS(); + Collection submodels = converter.parseSubmodels(); + Collection assets = converter.parseAssets(); + + return new AASBundleFactory().create(shells, submodels, assets); + } +} diff --git a/src/main/java/org/eclipse/basyx/aas/factory/xml/AASXPackageExplorerCompatibilityHandler.java b/src/main/java/org/eclipse/basyx/aas/factory/xml/AASXPackageExplorerCompatibilityHandler.java new file mode 100644 index 00000000..61d32fbb --- /dev/null +++ b/src/main/java/org/eclipse/basyx/aas/factory/xml/AASXPackageExplorerCompatibilityHandler.java @@ -0,0 +1,118 @@ +/******************************************************************************* +* Copyright (C) 2021 the Eclipse BaSyx Authors +* +* This program and the accompanying materials are made +* available under the terms of the Eclipse Public License 2.0 +* which is available at https://www.eclipse.org/legal/epl-2.0/ +* +* SPDX-License-Identifier: EPL-2.0 +******************************************************************************/ +package org.eclipse.basyx.aas.factory.xml; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.eclipse.basyx.aas.metamodel.api.parts.asset.AssetKind; +import org.eclipse.basyx.submodel.factory.xml.converters.submodelelement.SubmodelElementXMLConverter; +import org.eclipse.basyx.submodel.factory.xml.converters.submodelelement.operation.OperationXMLConverter; + +/** + * This class contains workarounds needed to be able to load .xml + * files produced by the AASXPackageExplorer in BaSyx. + * + * @author conradi + * + */ +public class AASXPackageExplorerCompatibilityHandler { + + /** + * This function makes sure the operation vars are in the correct map. + * AASXPackageExplorer uses multiple e.g. <aas:inputVariable> tags instead + * of a single <aas:inputVariable> with multiple <aas:operationVariable> tags within + * + * @param xmlObject + * @return + */ + @SuppressWarnings("unchecked") + public static Map prepareOperationVariableMap(Object xmlObject) { + if(xmlObject == null) { + return null; + } else if (isValidMap(xmlObject)) { + return (Map) xmlObject; + } else if (xmlObject instanceof List) { + return handleInvalidVariableList((List) xmlObject); + } else if (xmlObject instanceof Map) { + return handleInvalidVariableMap((Map) xmlObject); + } else { + throw createUnexpectedObjectRuntimeException(xmlObject); + } + + } + + private static Map handleInvalidVariableMap(Map map) throws RuntimeException { + if (hasValueTag(map)) { + return insertOperationVariableTag(map); + } else { + throw createUnexpectedObjectRuntimeException(map); + } + } + + private static boolean hasValueTag(Map map) { + return map.containsKey(SubmodelElementXMLConverter.VALUE); + } + + private static Map insertOperationVariableTag(Map map) { + // This Map contains directly the aas:value key and one variable + // e.g. was used instead of + // + Map correctMap = new HashMap<>(); + correctMap.put(OperationXMLConverter.OPERATION_VARIABLE, map); + return correctMap; + } + + @SuppressWarnings("unchecked") + private static boolean isValidMap(Object xmlObject) { + if (!(xmlObject instanceof Map)) { + return false; + } + + Map map = (Map) xmlObject; + return map.containsKey(OperationXMLConverter.OPERATION_VARIABLE); + } + + private static Map handleInvalidVariableList(List xmlObject) { + // If object is a List + // Multiple was used instead of + // and multiple within that + // Wrap List in Map with aas:operationVariable as key + Map correctMap = new HashMap<>(); + correctMap.put(OperationXMLConverter.OPERATION_VARIABLE, xmlObject); + return correctMap; + } + + private static RuntimeException createUnexpectedObjectRuntimeException(Object xmlObject) { + return new RuntimeException("Unexpected object: " + xmlObject); + } + + + /** + * The AASXPackageExplorer uses "Template" instead of "Type" AssetKind + * This converts "Template" to "Type" + * + * @param assetKind + * @return + */ + public static String convertAssetKind(String assetKind) { + if(isTemplateAssetKind(assetKind)) { + assetKind = AssetKind.TYPE.toString(); + } + + return assetKind; + } + + private static boolean isTemplateAssetKind(String assetKind) { + return assetKind.toLowerCase().equals("template"); + } + +} diff --git a/src/main/java/org/eclipse/basyx/aas/factory/xml/XMLAASBundleFactory.java b/src/main/java/org/eclipse/basyx/aas/factory/xml/XMLAASBundleFactory.java new file mode 100644 index 00000000..3cdf7ce8 --- /dev/null +++ b/src/main/java/org/eclipse/basyx/aas/factory/xml/XMLAASBundleFactory.java @@ -0,0 +1,67 @@ +/******************************************************************************* + * Copyright (C) 2021 the Eclipse BaSyx Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + ******************************************************************************/ +package org.eclipse.basyx.aas.factory.xml; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collection; +import java.util.Set; + +import javax.xml.parsers.ParserConfigurationException; + +import org.eclipse.basyx.aas.bundle.AASBundle; +import org.eclipse.basyx.aas.bundle.AASBundleFactory; +import org.eclipse.basyx.aas.metamodel.api.IAssetAdministrationShell; +import org.eclipse.basyx.aas.metamodel.api.parts.asset.IAsset; +import org.eclipse.basyx.submodel.metamodel.api.ISubmodel; +import org.xml.sax.SAXException; + +/** + * Creates multiple {@link AASBundle} from an XML containing several AAS and + * Submodels
+ * TODO: ConceptDescriptions + * + * @author schnicke + * + */ +public class XMLAASBundleFactory { + private String content; + + /** + * + * @param xmlContent + * the content of the XML + */ + public XMLAASBundleFactory(String xmlContent) { + this.content = xmlContent; + } + + public XMLAASBundleFactory(Path xmlFile) throws IOException { + content = new String(Files.readAllBytes(xmlFile)); + } + + /** + * Creates the set of {@link AASBundle} contained in the XML string. + * + * @return + * @throws IOException + * @throws SAXException + * @throws ParserConfigurationException + */ + public Set create() throws ParserConfigurationException, SAXException, IOException { + XMLToMetamodelConverter converter = new XMLToMetamodelConverter(content); + Collection shells = converter.parseAAS(); + Collection submodels = converter.parseSubmodels(); + Collection assets = converter.parseAssets(); + + return new AASBundleFactory().create(shells, submodels, assets); + } +} diff --git a/src/main/java/org/eclipse/basyx/aas/factory/xml/XMLToMetamodelConverter.java b/src/main/java/org/eclipse/basyx/aas/factory/xml/XMLToMetamodelConverter.java index fbd3af1b..a6621498 100644 --- a/src/main/java/org/eclipse/basyx/aas/factory/xml/XMLToMetamodelConverter.java +++ b/src/main/java/org/eclipse/basyx/aas/factory/xml/XMLToMetamodelConverter.java @@ -92,9 +92,6 @@ public List parseAssets() throws ParserConfigurationException, SAXExcept * Parses the Submodels form the XML * * @return the Submodels parsed form the XML - * @throws ParserConfigurationException - * @throws SAXException - * @throws IOException */ @SuppressWarnings("unchecked") public List parseSubmodels() { @@ -107,9 +104,6 @@ public List parseSubmodels() { * Parses the ConceptDescriptions form the XML * * @return the ConceptDescriptions parsed form the XML - * @throws ParserConfigurationException - * @throws SAXException - * @throws IOException */ @SuppressWarnings("unchecked") public List parseConceptDescriptions() { diff --git a/src/main/java/org/eclipse/basyx/aas/factory/xml/api/parts/AssetXMLConverter.java b/src/main/java/org/eclipse/basyx/aas/factory/xml/api/parts/AssetXMLConverter.java index 9ac57575..8813f4a3 100644 --- a/src/main/java/org/eclipse/basyx/aas/factory/xml/api/parts/AssetXMLConverter.java +++ b/src/main/java/org/eclipse/basyx/aas/factory/xml/api/parts/AssetXMLConverter.java @@ -14,6 +14,7 @@ import java.util.List; import java.util.Map; +import org.eclipse.basyx.aas.factory.xml.AASXPackageExplorerCompatibilityHandler; import org.eclipse.basyx.aas.metamodel.api.parts.asset.AssetKind; import org.eclipse.basyx.aas.metamodel.api.parts.asset.IAsset; import org.eclipse.basyx.aas.metamodel.map.parts.Asset; @@ -93,6 +94,9 @@ private static IReference parseAssetIdentificationModelRef(Map x private static AssetKind parseAssetKind(Map xmlObject) { String assetKindValue = XMLHelper.getString(xmlObject.get(ASSET_KIND)); if (!Strings.isNullOrEmpty(assetKindValue)) { + + assetKindValue = AASXPackageExplorerCompatibilityHandler.convertAssetKind(assetKindValue); + return AssetKind.fromString(assetKindValue); } else { throw new RuntimeException("Necessary value 'AssetKind' was not found for one of the Assets in the XML file."); diff --git a/src/main/java/org/eclipse/basyx/aas/manager/ConnectedAssetAdministrationShellManager.java b/src/main/java/org/eclipse/basyx/aas/manager/ConnectedAssetAdministrationShellManager.java index aeae53bb..c3283b93 100644 --- a/src/main/java/org/eclipse/basyx/aas/manager/ConnectedAssetAdministrationShellManager.java +++ b/src/main/java/org/eclipse/basyx/aas/manager/ConnectedAssetAdministrationShellManager.java @@ -3,14 +3,12 @@ */ package org.eclipse.basyx.aas.manager; -import java.io.UnsupportedEncodingException; -import java.net.URLEncoder; import java.util.Collection; import java.util.HashMap; import java.util.Map; +import org.eclipse.basyx.aas.aggregator.AASAggregatorAPIHelper; import org.eclipse.basyx.aas.aggregator.proxy.AASAggregatorProxy; -import org.eclipse.basyx.aas.aggregator.restapi.AASAggregatorProvider; import org.eclipse.basyx.aas.manager.api.IAssetAdministrationShellManager; import org.eclipse.basyx.aas.metamodel.api.IAssetAdministrationShell; import org.eclipse.basyx.aas.metamodel.connected.ConnectedAssetAdministrationShell; @@ -32,8 +30,8 @@ import org.eclipse.basyx.vab.protocol.http.connector.HTTPConnectorFactory; /** - * Implement a AAS manager backend that communicates via HTTP/REST
- *
+ * Implement a AAS manager backend that communicates via HTTP/REST
+ *
* * @author kuhn, schnicke * @@ -54,8 +52,8 @@ public ConnectedAssetAdministrationShellManager(IAASRegistry directory) { } /** - * @param networkDirectoryService - * @param providerProvider + * @param directory + * @param provider */ public ConnectedAssetAdministrationShellManager(IAASRegistry directory, IConnectorFactory provider) { @@ -163,19 +161,12 @@ public void deleteSubmodel(IIdentifier aasId, IIdentifier submodelId) { @Override public void createAAS(AssetAdministrationShell aas, String endpoint) { endpoint = VABPathTools.stripSlashes(endpoint); - if (!endpoint.endsWith(AASAggregatorProvider.PREFIX)) { - endpoint += "/" + AASAggregatorProvider.PREFIX; - } IModelProvider provider = connectorFactory.getConnector(endpoint); AASAggregatorProxy proxy = new AASAggregatorProxy(provider); proxy.createAAS(aas); - try { + String combinedEndpoint = VABPathTools.concatenatePaths(endpoint, AASAggregatorAPIHelper.getAASAccessPath(aas.getIdentification())); + aasDirectory.register(new AASDescriptor(aas, combinedEndpoint)); - String combinedEndpoint = VABPathTools.concatenatePaths(endpoint, URLEncoder.encode(aas.getIdentification().getId(), "UTF-8"), "aas"); - aasDirectory.register(new AASDescriptor(aas, combinedEndpoint)); - } catch (UnsupportedEncodingException e) { - throw new RuntimeException("Encoding failed. This should never happen"); - } } } diff --git a/src/main/java/org/eclipse/basyx/aas/manager/api/IAssetAdministrationShellManager.java b/src/main/java/org/eclipse/basyx/aas/manager/api/IAssetAdministrationShellManager.java index cc6420af..784debd6 100644 --- a/src/main/java/org/eclipse/basyx/aas/manager/api/IAssetAdministrationShellManager.java +++ b/src/main/java/org/eclipse/basyx/aas/manager/api/IAssetAdministrationShellManager.java @@ -41,7 +41,6 @@ public interface IAssetAdministrationShellManager { * Creates an AAS on a remote server * * @param aas - * @param aasId * @param endpoint */ void createAAS(AssetAdministrationShell aas, String endpoint); diff --git a/src/main/java/org/eclipse/basyx/aas/metamodel/api/IAssetAdministrationShell.java b/src/main/java/org/eclipse/basyx/aas/metamodel/api/IAssetAdministrationShell.java index 46710c71..7d016a2b 100644 --- a/src/main/java/org/eclipse/basyx/aas/metamodel/api/IAssetAdministrationShell.java +++ b/src/main/java/org/eclipse/basyx/aas/metamodel/api/IAssetAdministrationShell.java @@ -35,7 +35,7 @@ public interface IAssetAdministrationShell extends IElement, IIdentifiable, IHas /** * Return all registered submodels of this AAS * - * @return IdShort -> ISubmodel + * @return {@literal IdShort -> ISubmodel} */ public Map getSubmodels(); @@ -98,7 +98,7 @@ public interface IAssetAdministrationShell extends IElement, IIdentifiable, IHas /** - * Gets the views associated with the AAS.
+ * Gets the views associated with the AAS.
* If needed stakeholder specific views can be defined on the elements of the * AAS. * @@ -107,7 +107,7 @@ public interface IAssetAdministrationShell extends IElement, IIdentifiable, IHas public Collection getViews(); /** - * Gets the concept dictionaries associated with the AAS.
+ * Gets the concept dictionaries associated with the AAS.
* An AAS may have one or more concept dictionaries assigned to it. The concept * dictionaries typically contain only descriptions for elements that are also * used within the AAS (via HasSemantics). diff --git a/src/main/java/org/eclipse/basyx/aas/metamodel/api/parts/IConceptDictionary.java b/src/main/java/org/eclipse/basyx/aas/metamodel/api/parts/IConceptDictionary.java index 217d5106..944b97e5 100644 --- a/src/main/java/org/eclipse/basyx/aas/metamodel/api/parts/IConceptDictionary.java +++ b/src/main/java/org/eclipse/basyx/aas/metamodel/api/parts/IConceptDictionary.java @@ -16,10 +16,10 @@ import org.eclipse.basyx.submodel.metamodel.api.reference.IReference; /** - * A dictionary contains elements that can be reused.
- *
- * The concept dictionary contains concept descriptions.
- *
+ * A dictionary contains elements that can be reused.
+ *
+ * The concept dictionary contains concept descriptions.
+ *
* Typically a concept description dictionary of an AAS contains only concept * descriptions of elements used within submodels of the AAS. * diff --git a/src/main/java/org/eclipse/basyx/aas/metamodel/api/parts/asset/AssetKind.java b/src/main/java/org/eclipse/basyx/aas/metamodel/api/parts/asset/AssetKind.java index 31b20304..4dcece66 100644 --- a/src/main/java/org/eclipse/basyx/aas/metamodel/api/parts/asset/AssetKind.java +++ b/src/main/java/org/eclipse/basyx/aas/metamodel/api/parts/asset/AssetKind.java @@ -13,7 +13,7 @@ import org.eclipse.basyx.submodel.metamodel.enumhelper.StandardizedLiteralEnumHelper; /** - * AssetKind enum as defined by DAAS document
+ * AssetKind enum as defined by DAAS document
* Enumeration for denoting whether an element is a type or an instance. * * @author schnicke diff --git a/src/main/java/org/eclipse/basyx/aas/metamodel/map/AasEnv.java b/src/main/java/org/eclipse/basyx/aas/metamodel/map/AasEnv.java index f865d26f..bbad867a 100644 --- a/src/main/java/org/eclipse/basyx/aas/metamodel/map/AasEnv.java +++ b/src/main/java/org/eclipse/basyx/aas/metamodel/map/AasEnv.java @@ -27,20 +27,19 @@ import org.eclipse.basyx.vab.model.VABModelMap; /** - * AasEnv class + * AasEnv class * * @author gordt */ public class AasEnv extends VABModelMap implements IAasEnv { - + public static final String ASSETS = "assets"; public static final String ASSETADMINISTRATIONSHELLS = "assetAdministrationShells"; public static final String SUBMODELS = "submodels"; public static final String CONCEPTDESCRIPTIONS = "conceptDescriptions"; public static final String MODELTYPE = "AasEnv"; - public AasEnv() { // Add model type putAll(new ModelType(MODELTYPE)); @@ -57,7 +56,7 @@ public AasEnv(Collection aasList, Collection /** * Creates a AssetAdministrationShell object from a map * - * @param obj + * @param map * a AssetAdministrationShell object as raw map * @return a AssetAdministrationShell object, that behaves like a facade for the * given map @@ -69,48 +68,46 @@ public static AasEnv createAsFacade(Map map) { } AasEnv ret = new AasEnv(); - + Collection assetsTarget = new LinkedList<>(); if (map.get(ASSETS) != null && map.get(ASSETS) instanceof Collection) { Collection> objectMapCollection = (Collection>) map.get(ASSETS); - for(Map objectMap : objectMapCollection) { + for (Map objectMap : objectMapCollection) { assetsTarget.add(Asset.createAsFacade(objectMap)); } } ret.put(ASSETS, assetsTarget); - + Collection aasTarget = new LinkedList<>(); if (map.get(ASSETADMINISTRATIONSHELLS) != null && map.get(ASSETADMINISTRATIONSHELLS) instanceof Collection) { Collection> objectMapCollection = (Collection>) map.get(ASSETADMINISTRATIONSHELLS); - for(Map objectMap : objectMapCollection) { + for (Map objectMap : objectMapCollection) { aasTarget.add(AssetAdministrationShell.createAsFacade(objectMap)); } } ret.put(ASSETADMINISTRATIONSHELLS, aasTarget); - + Collection submodelsTarget = new LinkedList<>(); if (map.get(SUBMODELS) != null && map.get(SUBMODELS) instanceof Collection) { Collection> objectMapCollection = (Collection>) map.get(SUBMODELS); - for(Map objectMap : objectMapCollection) { + for (Map objectMap : objectMapCollection) { submodelsTarget.add(Submodel.createAsFacade(objectMap)); } } ret.put(SUBMODELS, submodelsTarget); - + Collection conceptDescriptionsTarget = new LinkedList<>(); if (map.get(CONCEPTDESCRIPTIONS) != null && map.get(CONCEPTDESCRIPTIONS) instanceof Collection) { Collection> objectMapCollection = (Collection>) map.get(CONCEPTDESCRIPTIONS); - for(Map objectMap : objectMapCollection) { + for (Map objectMap : objectMapCollection) { conceptDescriptionsTarget.add(ConceptDescription.createAsFacade(objectMap)); } } ret.put(CONCEPTDESCRIPTIONS, conceptDescriptionsTarget); - + return ret; } - - - + @SuppressWarnings("unchecked") @Override public Collection getAssets() { @@ -123,7 +120,7 @@ public Collection getAssets() { public void setAssets(Collection assets) { put(ASSETS, assets); } - + @SuppressWarnings("unchecked") @Override public Collection getAssetAdministrationShells() { @@ -136,7 +133,12 @@ public Collection getAssetAdministrationShells() { public void setAssetAdministrationShells(Collection assetAdministrationShells) { put(ASSETADMINISTRATIONSHELLS, assetAdministrationShells); } - + + @SuppressWarnings("unchecked") + public void addAssetAdministrationShell(IAssetAdministrationShell aas) { + ((Collection) get(ASSETADMINISTRATIONSHELLS)).add(aas); + } + @SuppressWarnings("unchecked") @Override public Collection getSubmodels() { @@ -150,6 +152,11 @@ public void setSubmodels(Collection submodels) { put(SUBMODELS, submodels); } + @SuppressWarnings("unchecked") + public void addSubmodel(ISubmodel submodel) { + ((Collection) get(SUBMODELS)).add(submodel); + } + @SuppressWarnings("unchecked") @Override public Collection getConceptDescriptions() { diff --git a/src/main/java/org/eclipse/basyx/aas/metamodel/map/AssetAdministrationShell.java b/src/main/java/org/eclipse/basyx/aas/metamodel/map/AssetAdministrationShell.java index 969592e8..1d7ed15c 100644 --- a/src/main/java/org/eclipse/basyx/aas/metamodel/map/AssetAdministrationShell.java +++ b/src/main/java/org/eclipse/basyx/aas/metamodel/map/AssetAdministrationShell.java @@ -49,7 +49,7 @@ import org.slf4j.LoggerFactory; /** - * AssetAdministrationShell class
+ * AssetAdministrationShell class
* Does not implement IAssetAdministrationShell since there are only references * stored in this map * @@ -114,7 +114,7 @@ public AssetAdministrationShell(Reference derivedFrom, Security security, Asset /** * Creates a AssetAdministrationShell object from a map * - * @param obj + * @param map * a AssetAdministrationShell object as raw map * @return a AssetAdministrationShell object, that behaves like a facade for the * given map diff --git a/src/main/java/org/eclipse/basyx/aas/metamodel/map/descriptor/AASDescriptor.java b/src/main/java/org/eclipse/basyx/aas/metamodel/map/descriptor/AASDescriptor.java index ed3f8e1b..041755ad 100644 --- a/src/main/java/org/eclipse/basyx/aas/metamodel/map/descriptor/AASDescriptor.java +++ b/src/main/java/org/eclipse/basyx/aas/metamodel/map/descriptor/AASDescriptor.java @@ -54,8 +54,7 @@ protected AASDescriptor() { * Create a new aas descriptor that retrieves the necessary information from a * passed AssetAdministrationShell * - * @param iAssetAdministrationShell - * @param IAsset - The asset which is associated to the AAS + * @param assetAdministrationShell * @param endpoint */ public AASDescriptor(IAssetAdministrationShell assetAdministrationShell, String endpoint) { diff --git a/src/main/java/org/eclipse/basyx/aas/metamodel/map/descriptor/ModelDescriptor.java b/src/main/java/org/eclipse/basyx/aas/metamodel/map/descriptor/ModelDescriptor.java index 8dc0b627..e7c780a2 100644 --- a/src/main/java/org/eclipse/basyx/aas/metamodel/map/descriptor/ModelDescriptor.java +++ b/src/main/java/org/eclipse/basyx/aas/metamodel/map/descriptor/ModelDescriptor.java @@ -13,6 +13,7 @@ import java.util.Arrays; import java.util.Collection; import java.util.HashMap; +import java.util.Iterator; import java.util.Map; import org.eclipse.basyx.aas.metamodel.map.AssetAdministrationShell; @@ -63,10 +64,8 @@ public ModelDescriptor(String idShort, IIdentifier id, String httpEndpoint) { put(Identifiable.IDENTIFICATION, identifierMap); // Set Endpoints - HashMap endpointWrapper = new HashMap<>(); - endpointWrapper.put(AssetAdministrationShell.TYPE, "http"); - endpointWrapper.put(AssetAdministrationShell.ADDRESS, httpEndpoint); - put(ENDPOINTS, Arrays.asList(endpointWrapper)); + Map endpointWrapper = convertEndpointToMap(httpEndpoint, "http"); + setEndpoints(Arrays.asList(endpointWrapper)); } /** @@ -82,6 +81,32 @@ public String getIdShort() { // Passing null in KeyElement type since it doesn't matter while only retrieving idShort return Referable.createAsFacade(this, null).getIdShort(); } + + /** + * Adds an endpoint + * @param endpoint + */ + public void addEndpoint(String endpoint) { + Collection> endpointsCollection = getEndpoints(); + + Map endpointWrapper = convertEndpointToMap(endpoint, "http"); + endpointsCollection.add(endpointWrapper); + setEndpoints(endpointsCollection); + } + + public void removeEndpoint(String endpoint) { + Collection> endpointsCollection = getEndpoints(); + + Iterator> iterator = endpointsCollection.iterator(); + while (iterator.hasNext()) { + Map endpointMap = iterator.next(); + if (endpointMap.containsKey(AssetAdministrationShell.ADDRESS) && endpointMap.get(AssetAdministrationShell.ADDRESS) != null && endpointMap.get(AssetAdministrationShell.ADDRESS).toString().equalsIgnoreCase(endpoint)) { + iterator.remove(); + break; + } + } + setEndpoints(endpointsCollection); + } /** * Return first AAS endpoint @@ -111,7 +136,13 @@ public Collection> getEndpoints() { Object endpoints = get(ENDPOINTS); // Extract String from endpoint for set or list representations of the endpoint wrappers if (endpoints instanceof Collection) { - return (Collection>) endpoints; + // Create a new return list and insert all endpoints. If the endpoints are + // created using Arrays.asList() which is immutable, this can be solved + Collection> ret = new ArrayList>(); + for (Map endpointMap: (Collection>)endpoints) { + ret.add(endpointMap); + } + return ret; } else { return new ArrayList<>(); } @@ -132,4 +163,21 @@ protected void validate(Map map) { } protected abstract String getModelType(); + + /** + * Converts an endpoint to a map wrapper + * @param endpoint + * @param type + * @return + */ + private Map convertEndpointToMap(String endpoint, String type) { + HashMap endpointWrapper = new HashMap<>(); + endpointWrapper.put(AssetAdministrationShell.TYPE, type); + endpointWrapper.put(AssetAdministrationShell.ADDRESS, endpoint); + return endpointWrapper; + } + + private void setEndpoints(Collection> endpointsCollection) { + put(ENDPOINTS, endpointsCollection); + } } diff --git a/src/main/java/org/eclipse/basyx/aas/metamodel/map/descriptor/ModelUrn.java b/src/main/java/org/eclipse/basyx/aas/metamodel/map/descriptor/ModelUrn.java index 81f04330..cd558d14 100644 --- a/src/main/java/org/eclipse/basyx/aas/metamodel/map/descriptor/ModelUrn.java +++ b/src/main/java/org/eclipse/basyx/aas/metamodel/map/descriptor/ModelUrn.java @@ -18,7 +18,7 @@ import org.slf4j.LoggerFactory; /** - * Create URNs with the format urn::::::# + * Create URNs with the format urn: {@literal :::::#} * * @author kuhn * diff --git a/src/main/java/org/eclipse/basyx/aas/metamodel/map/parts/Asset.java b/src/main/java/org/eclipse/basyx/aas/metamodel/map/parts/Asset.java index c62365b4..93f4b74d 100644 --- a/src/main/java/org/eclipse/basyx/aas/metamodel/map/parts/Asset.java +++ b/src/main/java/org/eclipse/basyx/aas/metamodel/map/parts/Asset.java @@ -31,9 +31,9 @@ import org.eclipse.basyx.vab.model.VABModelMap; /** - * Asset class as described in DAAS document
- * An Asset describes meta data of an asset that is represented by an AAS.
- * The asset may either represent an asset type or an asset instance.
+ * Asset class as described in DAAS document
+ * An Asset describes meta data of an asset that is represented by an AAS.
+ * The asset may either represent an asset type or an asset instance.
* The asset has a globally unique identifier plus � if needed � additional * domain specific (proprietary) identifiers. * @@ -90,7 +90,7 @@ public Asset(Reference submodel) { /** * Creates a Asset object from a map * - * @param obj + * @param map * a Asset object as raw map * @return a Asset object, that behaves like a facade for the given map */ diff --git a/src/main/java/org/eclipse/basyx/aas/metamodel/map/parts/ConceptDictionary.java b/src/main/java/org/eclipse/basyx/aas/metamodel/map/parts/ConceptDictionary.java index b418b997..67d54a71 100644 --- a/src/main/java/org/eclipse/basyx/aas/metamodel/map/parts/ConceptDictionary.java +++ b/src/main/java/org/eclipse/basyx/aas/metamodel/map/parts/ConceptDictionary.java @@ -64,7 +64,7 @@ public ConceptDictionary(Collection ref) { /** * Creates a ConceptDictionary object from a map * - * @param obj + * @param map * a ConceptDictionary object as raw map * @return a ConceptDictionary object, that behaves like a facade for the given * map diff --git a/src/main/java/org/eclipse/basyx/aas/metamodel/map/parts/View.java b/src/main/java/org/eclipse/basyx/aas/metamodel/map/parts/View.java index 8b80b01f..0cd8677a 100644 --- a/src/main/java/org/eclipse/basyx/aas/metamodel/map/parts/View.java +++ b/src/main/java/org/eclipse/basyx/aas/metamodel/map/parts/View.java @@ -29,7 +29,7 @@ import org.eclipse.basyx.vab.model.VABModelMap; /** - * View as defined by DAAS document.
+ * View as defined by DAAS document.
* A view is a collection of referable elements w.r.t. to a specific viewpoint * of one or more stakeholders. * @@ -76,7 +76,7 @@ public View(Set references) { /** * Creates a View object from a map * - * @param obj + * @param map * a View object as raw map * @return a View object, that behaves like a facade for the given map */ diff --git a/src/main/java/org/eclipse/basyx/aas/metamodel/map/policypoints/AccessControlPolicyPoints.java b/src/main/java/org/eclipse/basyx/aas/metamodel/map/policypoints/AccessControlPolicyPoints.java index 30e71fe4..7e62c525 100644 --- a/src/main/java/org/eclipse/basyx/aas/metamodel/map/policypoints/AccessControlPolicyPoints.java +++ b/src/main/java/org/eclipse/basyx/aas/metamodel/map/policypoints/AccessControlPolicyPoints.java @@ -34,7 +34,7 @@ public AccessControlPolicyPoints() {} /** * Creates a DataSpecificationIEC61360 object from a map * - * @param obj + * @param map * a DataSpecificationIEC61360 object as raw map * @return a DataSpecificationIEC61360 object, that behaves like a facade for * the given map diff --git a/src/main/java/org/eclipse/basyx/aas/metamodel/map/security/Security.java b/src/main/java/org/eclipse/basyx/aas/metamodel/map/security/Security.java index 32ff7f92..f4baabcf 100644 --- a/src/main/java/org/eclipse/basyx/aas/metamodel/map/security/Security.java +++ b/src/main/java/org/eclipse/basyx/aas/metamodel/map/security/Security.java @@ -37,7 +37,7 @@ public Security() {} /** * Creates a Security object from a map * - * @param obj + * @param map * a Security object as raw map * @return a Security object, that behaves like a facade for the given map */ diff --git a/src/main/java/org/eclipse/basyx/aas/observer/IObserver.java b/src/main/java/org/eclipse/basyx/aas/observer/IObserver.java new file mode 100644 index 00000000..94eae285 --- /dev/null +++ b/src/main/java/org/eclipse/basyx/aas/observer/IObserver.java @@ -0,0 +1,22 @@ +/******************************************************************************* +* Copyright (C) 2021 the Eclipse BaSyx Authors +* +* This program and the accompanying materials are made +* available under the terms of the Eclipse Public License 2.0 +* which is available at https://www.eclipse.org/legal/epl-2.0/ + +* +* SPDX-License-Identifier: EPL-2.0 +******************************************************************************/ + +package org.eclipse.basyx.aas.observer; + +/** + * Generic interface for any observer + * + * @author haque + * + */ +public interface IObserver { + +} diff --git a/src/main/java/org/eclipse/basyx/aas/observer/Observable.java b/src/main/java/org/eclipse/basyx/aas/observer/Observable.java new file mode 100644 index 00000000..8fb82a80 --- /dev/null +++ b/src/main/java/org/eclipse/basyx/aas/observer/Observable.java @@ -0,0 +1,47 @@ +/******************************************************************************* +* Copyright (C) 2021 the Eclipse BaSyx Authors +* +* This program and the accompanying materials are made +* available under the terms of the Eclipse Public License 2.0 +* which is available at https://www.eclipse.org/legal/epl-2.0/ + +* +* SPDX-License-Identifier: EPL-2.0 +******************************************************************************/ + +package org.eclipse.basyx.aas.observer; + +import java.util.ArrayList; +import java.util.Collection; + +/** + * Generic implementation of an Observable. + * This class contains all common operations an Observable is supposed to do. + * Java generics is used to specify which type of Observable is required + * @author haque + * + * @param can be Observers which extends IObserver + */ +public class Observable { + + public Collection observers = new ArrayList(); + + /** + * Adds an observer to the subscriber list + * + * @param observer the observer to be added + */ + public void addObserver(T observer) { + observers.add(observer); + } + + /** + * Removes an observer from the subscriber list + * + * @param observer the observer to be removed + * @return true if the observer was found and removed; false otherwise + */ + public boolean removeObserver(T observer) { + return observers.remove(observer); + } +} diff --git a/src/main/java/org/eclipse/basyx/aas/registration/AASRegistryAPIHelper.java b/src/main/java/org/eclipse/basyx/aas/registration/AASRegistryAPIHelper.java new file mode 100644 index 00000000..b46dea46 --- /dev/null +++ b/src/main/java/org/eclipse/basyx/aas/registration/AASRegistryAPIHelper.java @@ -0,0 +1,68 @@ +/******************************************************************************* +* Copyright (C) 2021 the Eclipse BaSyx Authors +* +* This program and the accompanying materials are made +* available under the terms of the Eclipse Public License 2.0 +* which is available at https://www.eclipse.org/legal/epl-2.0/ + +* +* SPDX-License-Identifier: EPL-2.0 +******************************************************************************/ + +package org.eclipse.basyx.aas.registration; + +import org.eclipse.basyx.aas.registration.restapi.AASRegistryModelProvider; +import org.eclipse.basyx.submodel.metamodel.api.identifier.IIdentifier; +import org.eclipse.basyx.vab.exception.provider.ProviderException; +import org.eclipse.basyx.vab.modelprovider.VABPathTools; + +/** + * API helper for AAS Registry + * @author haque + * + */ +public class AASRegistryAPIHelper { + + /** + * Retrieves base access path + * @return + */ + public static String getRegistryPath() { + return AASRegistryModelProvider.PREFIX; + } + + /** + * Retrieves an access path for an AAS + * @param aasId + * @return + */ + public static String getAASPath(IIdentifier aasId) { + return VABPathTools.concatenatePaths(getRegistryPath(), VABPathTools.encodePathElement(aasId.getId())); + } + + /** + * Retrieves an access path for all the submodels inside an AAS + * @param aasId + * @return + */ + public static String getSubmodelListOfAASPath(IIdentifier aasId) { + return VABPathTools.concatenatePaths(getRegistryPath(), buildSubmodelPath(aasId)); + } + + + /** + * Retrieves an access path for a submodel + * @param aasId + * @param submodelId + * @return + */ + public static String getSubmodelAccessPath(IIdentifier aasId, IIdentifier submodelId) { + return VABPathTools.concatenatePaths(getSubmodelListOfAASPath(aasId), VABPathTools.encodePathElement(submodelId.getId())); + } + + private static String buildSubmodelPath(IIdentifier aas) throws ProviderException { + // Encode id to handle usage of reserved symbols, e.g. / + String encodedAASId = VABPathTools.encodePathElement(aas.getId()); + return VABPathTools.concatenatePaths(encodedAASId, AASRegistryModelProvider.SUBMODELS); + } +} diff --git a/src/main/java/org/eclipse/basyx/aas/registration/observing/IAASRegistryServiceObserver.java b/src/main/java/org/eclipse/basyx/aas/registration/observing/IAASRegistryServiceObserver.java new file mode 100644 index 00000000..13f43453 --- /dev/null +++ b/src/main/java/org/eclipse/basyx/aas/registration/observing/IAASRegistryServiceObserver.java @@ -0,0 +1,50 @@ +/******************************************************************************* +* Copyright (C) 2021 the Eclipse BaSyx Authors +* +* This program and the accompanying materials are made +* available under the terms of the Eclipse Public License 2.0 +* which is available at https://www.eclipse.org/legal/epl-2.0/ + +* +* SPDX-License-Identifier: EPL-2.0 +******************************************************************************/ + +package org.eclipse.basyx.aas.registration.observing; + +import org.eclipse.basyx.aas.observer.IObserver; +import org.eclipse.basyx.submodel.metamodel.api.identifier.IIdentifier; + +/** + * Interface for an observer of {@link ObservableAASRegistryService} + * @author haque + * + */ +public interface IAASRegistryServiceObserver extends IObserver { + + /** + * Is called when an AAS is registered + * @param aasId id of the registered AAS + */ + public void aasRegistered(String aasId); + + /** + * Is called when a submodel is registered + * @param aasId id of the parent AAS + * @param smId id of the registered submodel + */ + public void submodelRegistered(IIdentifier aasId, IIdentifier smId); + + /** + * Is called when an AAS is deleted + * @param aasId id of the deleted AAS + */ + public void aasDeleted(String aasId); + + /** + * Is called when a submodel is deleted + * @param aasId id of the parent AAS + * @param smId id of the deleted Submodel + */ + public void submodelDeleted(IIdentifier aasId, IIdentifier smId); + +} diff --git a/src/main/java/org/eclipse/basyx/aas/registration/observing/ObservableAASRegistryService.java b/src/main/java/org/eclipse/basyx/aas/registration/observing/ObservableAASRegistryService.java new file mode 100644 index 00000000..2b25247f --- /dev/null +++ b/src/main/java/org/eclipse/basyx/aas/registration/observing/ObservableAASRegistryService.java @@ -0,0 +1,82 @@ +/******************************************************************************* +* Copyright (C) 2021 the Eclipse BaSyx Authors +* +* This program and the accompanying materials are made +* available under the terms of the Eclipse Public License 2.0 +* which is available at https://www.eclipse.org/legal/epl-2.0/ + +* +* SPDX-License-Identifier: EPL-2.0 +******************************************************************************/ + +package org.eclipse.basyx.aas.registration.observing; + +import java.util.List; + +import org.eclipse.basyx.aas.metamodel.map.descriptor.AASDescriptor; +import org.eclipse.basyx.aas.metamodel.map.descriptor.SubmodelDescriptor; +import org.eclipse.basyx.aas.observer.Observable; +import org.eclipse.basyx.aas.registration.api.IAASRegistry; +import org.eclipse.basyx.submodel.metamodel.api.identifier.IIdentifier; +import org.eclipse.basyx.vab.exception.provider.ProviderException; + +/** +* +* Implementation of {@link IAASRegistry} that calls back registered {@link IAASRegistryServiceObserver} +* when changes on Registry occur +* +* @author haque +* +*/ +public class ObservableAASRegistryService extends Observable implements IAASRegistry { + + private IAASRegistry aasRegistry; + + public ObservableAASRegistryService(IAASRegistry registry) { + this.aasRegistry = registry; + } + + @Override + public void register(AASDescriptor deviceAASDescriptor) throws ProviderException { + aasRegistry.register(deviceAASDescriptor); + observers.stream().forEach(o -> o.aasRegistered(deviceAASDescriptor.getIdentifier().getId())); + } + + @Override + public void register(IIdentifier aas, SubmodelDescriptor smDescriptor) throws ProviderException { + aasRegistry.register(aas, smDescriptor); + observers.stream().forEach(o -> o.submodelRegistered(aas, smDescriptor.getIdentifier())); + } + + @Override + public void delete(IIdentifier aasId) throws ProviderException { + aasRegistry.delete(aasId); + observers.stream().forEach(o -> o.aasDeleted(aasId.getId())); + } + + @Override + public void delete(IIdentifier aasId, IIdentifier smId) throws ProviderException { + observers.stream().forEach(o -> o.submodelDeleted(aasId, smId)); + } + + @Override + public AASDescriptor lookupAAS(IIdentifier aasId) throws ProviderException { + return aasRegistry.lookupAAS(aasId); + } + + @Override + public List lookupAll() throws ProviderException { + return aasRegistry.lookupAll(); + } + + @Override + public List lookupSubmodels(IIdentifier aasId) throws ProviderException { + return aasRegistry.lookupSubmodels(aasId); + } + + @Override + public SubmodelDescriptor lookupSubmodel(IIdentifier aasId, IIdentifier smId) throws ProviderException { + return aasRegistry.lookupSubmodel(aasId, smId); + } + +} diff --git a/src/main/java/org/eclipse/basyx/aas/registration/proxy/AASRegistryProxy.java b/src/main/java/org/eclipse/basyx/aas/registration/proxy/AASRegistryProxy.java index c5c5380b..830053f6 100644 --- a/src/main/java/org/eclipse/basyx/aas/registration/proxy/AASRegistryProxy.java +++ b/src/main/java/org/eclipse/basyx/aas/registration/proxy/AASRegistryProxy.java @@ -9,8 +9,6 @@ ******************************************************************************/ package org.eclipse.basyx.aas.registration.proxy; -import java.io.UnsupportedEncodingException; -import java.net.URLEncoder; import java.util.Collection; import java.util.List; import java.util.Map; @@ -19,17 +17,15 @@ import org.eclipse.basyx.aas.metamodel.map.descriptor.AASDescriptor; import org.eclipse.basyx.aas.metamodel.map.descriptor.SubmodelDescriptor; import org.eclipse.basyx.aas.registration.api.IAASRegistry; +import org.eclipse.basyx.aas.registration.AASRegistryAPIHelper; import org.eclipse.basyx.aas.registration.restapi.AASRegistryModelProvider; import org.eclipse.basyx.submodel.metamodel.api.identifier.IIdentifier; import org.eclipse.basyx.vab.coder.json.connector.JSONConnector; import org.eclipse.basyx.vab.exception.provider.ProviderException; import org.eclipse.basyx.vab.modelprovider.VABElementProxy; -import org.eclipse.basyx.vab.modelprovider.VABPathTools; import org.eclipse.basyx.vab.modelprovider.api.IModelProvider; import org.eclipse.basyx.vab.protocol.http.connector.HTTPConnector; import org.eclipse.basyx.vab.registry.proxy.VABRegistryProxy; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; @@ -40,8 +36,6 @@ * */ public class AASRegistryProxy extends VABRegistryProxy implements IAASRegistry { - - private static Logger logger = LoggerFactory.getLogger(AASRegistryProxy.class); /** * Constructor for an AAS registry proxy based on a HTTP connection @@ -77,7 +71,7 @@ public AASRegistryProxy(IModelProvider provider) throws ProviderException { } private static VABElementProxy createProxy(IModelProvider provider) { - return new VABElementProxy(AASRegistryModelProvider.PREFIX, provider); + return new VABElementProxy("", provider); } /** @@ -87,7 +81,7 @@ private static VABElementProxy createProxy(IModelProvider provider) { public void register(AASDescriptor deviceAASDescriptor) throws ProviderException { // Add a mapping from the AAS id to the serialized descriptor try { - String encodedId = VABPathTools.encodePathElement(deviceAASDescriptor.getIdentifier().getId()); + String encodedId = AASRegistryAPIHelper.getAASPath(deviceAASDescriptor.getIdentifier()); // Typically, VAB SET should not create new entries. Nevertheless, the registry // API is defined to do it. @@ -106,12 +100,7 @@ public void register(AASDescriptor deviceAASDescriptor) throws ProviderException */ @Override public void delete(IIdentifier aasIdentifier) throws ProviderException { - try { - this.removeMapping(URLEncoder.encode(aasIdentifier.getId(), "UTF-8")); - } catch (UnsupportedEncodingException e) { - logger.error("Could not encode URL. This should not happen"); - throw new RuntimeException(e); - } + this.removeMapping(AASRegistryAPIHelper.getAASPath(aasIdentifier)); } /** @@ -120,7 +109,7 @@ public void delete(IIdentifier aasIdentifier) throws ProviderException { @Override @SuppressWarnings("unchecked") public AASDescriptor lookupAAS(IIdentifier aasIdentifier) throws ProviderException { try { - Object result = provider.getValue(URLEncoder.encode(aasIdentifier.getId(), "UTF-8")); + Object result = provider.getValue(AASRegistryAPIHelper.getAASPath(aasIdentifier)); return new AASDescriptor((Map) result); } catch (Exception e) { if (e instanceof ProviderException) { @@ -135,7 +124,7 @@ public AASDescriptor lookupAAS(IIdentifier aasIdentifier) throws ProviderExcepti @Override public List lookupAll() throws ProviderException { try { - Object result = provider.getValue(""); + Object result = provider.getValue(AASRegistryAPIHelper.getRegistryPath()); Collection descriptors = (Collection) result; return descriptors.stream().map(x -> new AASDescriptor((Map) x)).collect(Collectors.toList()); } catch (Exception e) { @@ -152,7 +141,7 @@ public void register(IIdentifier aas, SubmodelDescriptor smDescriptor) throws Pr try { // Typically, VAB SET should not create new entries. Nevertheless, the registry // API is defined to do it. - provider.setValue(VABPathTools.concatenatePaths(buildSubmodelPath(aas), URLEncoder.encode(smDescriptor.getIdentifier().getId(), "UTF-8")), smDescriptor); + provider.setValue(AASRegistryAPIHelper.getSubmodelAccessPath(aas, smDescriptor.getIdentifier()), smDescriptor); } catch (Exception e) { if (e instanceof ProviderException) { throw (ProviderException) e; @@ -165,7 +154,7 @@ public void register(IIdentifier aas, SubmodelDescriptor smDescriptor) throws Pr @Override public void delete(IIdentifier aasId, IIdentifier smId) throws ProviderException { try { - provider.deleteValue(VABPathTools.concatenatePaths(buildSubmodelPath(aasId), URLEncoder.encode(smId.getId(), "UTF-8"))); + provider.deleteValue(AASRegistryAPIHelper.getSubmodelAccessPath(aasId, smId)); } catch (Exception e) { if (e instanceof ProviderException) { throw (ProviderException) e; @@ -175,17 +164,12 @@ public void delete(IIdentifier aasId, IIdentifier smId) throws ProviderException } } - private String buildSubmodelPath(IIdentifier aas) throws ProviderException { - // Encode id to handle usage of reserved symbols, e.g. / - String encodedAASId = VABPathTools.encodePathElement(aas.getId()); - return VABPathTools.concatenatePaths(encodedAASId, AASRegistryModelProvider.SUBMODELS); - } @SuppressWarnings("unchecked") @Override public List lookupSubmodels(IIdentifier aasId) throws ProviderException { try { - Object result = provider.getValue(VABPathTools.concatenatePaths(buildSubmodelPath(aasId))); + Object result = provider.getValue(AASRegistryAPIHelper.getSubmodelListOfAASPath(aasId)); Collection descriptors = (Collection) result; return descriptors.stream().map(x -> new SubmodelDescriptor((Map) x)).collect(Collectors.toList()); } catch (Exception e) { @@ -201,7 +185,7 @@ public List lookupSubmodels(IIdentifier aasId) throws Provid @Override public SubmodelDescriptor lookupSubmodel(IIdentifier aasId, IIdentifier smId) throws ProviderException { try { - Object result = provider.getValue(VABPathTools.concatenatePaths(buildSubmodelPath(aasId), URLEncoder.encode(smId.getId(), "UTF-8"))); + Object result = provider.getValue(AASRegistryAPIHelper.getSubmodelAccessPath(aasId, smId)); return new SubmodelDescriptor((Map) result); } catch (Exception e) { if (e instanceof ProviderException) { diff --git a/src/main/java/org/eclipse/basyx/aas/registration/restapi/AASRegistryModelProvider.java b/src/main/java/org/eclipse/basyx/aas/registration/restapi/AASRegistryModelProvider.java index be780dc0..285944bb 100644 --- a/src/main/java/org/eclipse/basyx/aas/registration/restapi/AASRegistryModelProvider.java +++ b/src/main/java/org/eclipse/basyx/aas/registration/restapi/AASRegistryModelProvider.java @@ -140,7 +140,7 @@ private Map checkModelType(String expectedModelType, Object valu } /** - * Makes sure, that given Object is an AASDescriptor by checking its ModelType
+ * Makes sure, that given Object is an AASDescriptor by checking its ModelType
* Creates a new AASDescriptor with the content of the given Map * * @param value the AAS Map object @@ -154,7 +154,7 @@ private AASDescriptor createAASDescriptorFromMap(Object value) throws MalformedR } /** - * Makes sure, that given Object is an SubmodelDescriptor by checking its ModelType
+ * Makes sure, that given Object is an SubmodelDescriptor by checking its ModelType
* Creates a new SubmodelDescriptor with the content of the given Map * * @param value the AAS Map object diff --git a/src/main/java/org/eclipse/basyx/aas/restapi/AASAPIHelper.java b/src/main/java/org/eclipse/basyx/aas/restapi/AASAPIHelper.java new file mode 100644 index 00000000..685fe9da --- /dev/null +++ b/src/main/java/org/eclipse/basyx/aas/restapi/AASAPIHelper.java @@ -0,0 +1,38 @@ +/******************************************************************************* +* Copyright (C) 2021 the Eclipse BaSyx Authors +* +* This program and the accompanying materials are made +* available under the terms of the Eclipse Public License 2.0 +* which is available at https://www.eclipse.org/legal/epl-2.0/ + +* +* SPDX-License-Identifier: EPL-2.0 +******************************************************************************/ + +package org.eclipse.basyx.aas.restapi; + +import org.eclipse.basyx.aas.metamodel.map.AssetAdministrationShell; + +/** + * API helper for AAS API + * @author haque + * + */ +public class AASAPIHelper { + + /** + * Retrieves base access path for AAS API + * @return + */ + public static String getAASPath() { + return ""; + } + + /** + * Retrieves access path for submodels inside AAS API + * @return + */ + public static String getSubmodelsPath() { + return AssetAdministrationShell.SUBMODELS; + } +} diff --git a/src/main/java/org/eclipse/basyx/aas/restapi/MultiSubmodelProvider.java b/src/main/java/org/eclipse/basyx/aas/restapi/MultiSubmodelProvider.java index 19f5d9c3..f874979c 100644 --- a/src/main/java/org/eclipse/basyx/aas/restapi/MultiSubmodelProvider.java +++ b/src/main/java/org/eclipse/basyx/aas/restapi/MultiSubmodelProvider.java @@ -38,56 +38,42 @@ import org.eclipse.basyx.vab.protocol.http.connector.HTTPConnectorFactory; /** - * Provider class that implements the AssetAdministrationShellServices
+ * Provider class that implements the AssetAdministrationShellServices
* This provider supports operations on multiple sub models that are selected by - * path
- *
- * Supported API:
- * - getModelPropertyValue
- * /aas Returns the Asset Administration Shell
- * /aas/submodels Retrieves all Submodels from the current Asset Administration - * Shell
- * /aas/submodels/{subModelId} Retrieves a specific Submodel from a specific - * Asset Administration Shell
- * /aas/submodels/{subModelId}/properties Retrieves all Properties from the - * current Submodel
- * /aas/submodels/{subModelId}/operations Retrieves all Operations from the - * current Submodel
- * /aas/submodels/{subModelId}/events Retrieves all Events from the current - * Submodel
- * /aas/submodels/{subModelId}/properties/{propertyId} Retrieves a specific - * property from the AAS's Submodel
- * /aas/submodels/{subModelId}/operations/{operationId} Retrieves a specific - * Operation from the AAS's Submodel
- * /aas/submodels/{subModelId}/events/{eventId} Retrieves a specific event from - * the AAS's submodel - *

- * - createValue
- * /aas/submodels Adds a new Submodel to an existing Asset Administration Shell - *

- * /aas/submodels/{subModelId}/properties Adds a new property to the AAS's - * submodel
- * /aas/submodels/{subModelId}/operations Adds a new operation to the AAS's - * submodel
- * /aas/submodels/{subModelId}/events Adds a new event to the AAS's submodel - *

- * - invokeOperation
- * /aas/submodels/{subModelId}/operations/{operationId} Invokes a specific - * operation from the AAS' submodel with a list of input parameters - *

- * - deleteValue
- * /aas/submodels/{subModelId} Deletes a specific Submodel from a specific Asset - * Administration Shell
- * /aas/submodels/{subModelId}/properties/{propertyId} Deletes a specific - * Property from the AAS's Submodel
- * /aas/submodels/{subModelId}/operations/{operationId} Deletes a specific - * Operation from the AAS's Submodel
- * /aas/submodels/{subModelId}/events/{eventId} Deletes a specific event from - * the AAS's submodel - *

- * - setModelPropertyValue
- * /aas/submodels/{subModelId}/properties/{propertyId} Sets the value of the - * AAS's Submodel's Property + * path
+ *
+ * Supported API:
+ * - getValue
+ * /aas Returns the Asset Administration Shell
+ * /aas/submodels Retrieves all Submodels from the current Asset + * Administration Shell
+ * /aas/submodels/{subModelIdShort}/submodel Retrieves a specific + * Submodel from a specific Asset Administration Shell
+ * /aas/submodels/{subModelIdShort}/submodel/submodelElements Retrieves + * all SubmodelElements from the current Submodel
+ * /aas/submodels/{subModelIdShort}/submodel/submodelElements/{submodelElementIdShort} + * Retrieves a specific SubmodelElement from the AAS's Submodel
+ * /aas/submodels/{subModelIdShort}/submodel/submodelElements/{submodelElementIdShort}/value + * Retrieves the value of a specific SubmodelElement from the AAS's Submodel
+ *
+ * - setValue
+ * /aas/submodels/{subModelIdShort} Adds a new Submodel to an existing + * Asset Administration Shell
+ * /aas/submodels/{subModelIdShort}/submodel/submodelElements/{submodelElementIdShort} + * Adds a new SubmodelElement to the AAS's submodel
+ * /aas/submodels/{subModelIdShort}/submodel/submodelElements/{submodelElementIdShort} + * Sets the value of a specific SubmodelElement from the AAS's Submodel
+ *
+ * - invokeOperation
+ * /aas/submodels/{subModelIdShort}/submodel/submodelElements/{submodelElementIdShort} + * Invokes a specific operation from the AAS' submodel with a list of input + * parameters
+ *
+ * - deleteValue
+ * /aas/submodels/{subModelId} Deletes a specific Submodel from a + * specific Asset Administration Shell
+ * /aas/submodels/{subModelIdShort}/submodel/submodelElements/{submodelElementIdShort} + * Deletes a specific submodelElement from the AAS's Submodel
* * * @author kuhn, pschorn @@ -209,8 +195,6 @@ public MultiSubmodelProvider(AASModelProvider contentProvider, IAASRegistry regi /** * Set an AAS for this provider * - * @param elementId - * Element ID * @param modelContentProvider * Model content provider */ diff --git a/src/main/java/org/eclipse/basyx/aas/restapi/vab/VABAASAPI.java b/src/main/java/org/eclipse/basyx/aas/restapi/vab/VABAASAPI.java index d6e0437f..57a1a343 100644 --- a/src/main/java/org/eclipse/basyx/aas/restapi/vab/VABAASAPI.java +++ b/src/main/java/org/eclipse/basyx/aas/restapi/vab/VABAASAPI.java @@ -17,6 +17,7 @@ import org.eclipse.basyx.aas.metamodel.api.IAssetAdministrationShell; import org.eclipse.basyx.aas.metamodel.map.AssetAdministrationShell; import org.eclipse.basyx.aas.restapi.api.IAASAPI; +import org.eclipse.basyx.aas.restapi.AASAPIHelper; import org.eclipse.basyx.submodel.metamodel.api.reference.IKey; import org.eclipse.basyx.submodel.metamodel.api.reference.IReference; import org.eclipse.basyx.submodel.metamodel.map.reference.Reference; @@ -37,7 +38,7 @@ public class VABAASAPI implements IAASAPI { /** * Creates a VABAASAPI that wraps an IModelProvider * - * @param modelProvider + * @param provider * providing the AAS */ public VABAASAPI(IModelProvider provider) { @@ -49,19 +50,19 @@ public VABAASAPI(IModelProvider provider) { @Override public IAssetAdministrationShell getAAS() { // For access on the container property root, return the whole model - Map map = (Map) provider.getValue(""); + Map map = (Map) provider.getValue(AASAPIHelper.getAASPath()); return AssetAdministrationShell.createAsFacade(map); } @Override public void addSubmodel(IReference submodel) { - provider.createValue(AssetAdministrationShell.SUBMODELS, submodel); + provider.createValue(AASAPIHelper.getSubmodelsPath(), submodel); } @SuppressWarnings("unchecked") @Override public void removeSubmodel(String id) { - Collection> smReferences = (Collection>) provider.getValue(AssetAdministrationShell.SUBMODELS); + Collection> smReferences = (Collection>) provider.getValue(AASAPIHelper.getSubmodelsPath()); // Reference to submodel could be either by idShort (=> local) or directly via // its identifier for (Iterator> iterator = smReferences.iterator(); iterator.hasNext();) { @@ -72,7 +73,7 @@ public void removeSubmodel(String id) { String idValue = lastKey.getValue(); // remove this reference, if the last key points to the submodel if (idValue.equals(id)) { - provider.deleteValue(AssetAdministrationShell.SUBMODELS, ref); + provider.deleteValue(AASAPIHelper.getSubmodelsPath(), ref); break; } } diff --git a/src/main/java/org/eclipse/basyx/extensions/aas/aggregator/aasxupload/AASAggregatorAASXUpload.java b/src/main/java/org/eclipse/basyx/extensions/aas/aggregator/aasxupload/AASAggregatorAASXUpload.java new file mode 100644 index 00000000..b69a3b29 --- /dev/null +++ b/src/main/java/org/eclipse/basyx/extensions/aas/aggregator/aasxupload/AASAggregatorAASXUpload.java @@ -0,0 +1,86 @@ +/******************************************************************************* +* Copyright (C) 2021 the Eclipse BaSyx Authors +* +* This program and the accompanying materials are made +* available under the terms of the Eclipse Public License 2.0 +* which is available at https://www.eclipse.org/legal/epl-2.0/ + +* +* SPDX-License-Identifier: EPL-2.0 +******************************************************************************/ + +package org.eclipse.basyx.extensions.aas.aggregator.aasxupload; + +import java.io.InputStream; +import java.util.Collection; +import java.util.Set; + +import org.eclipse.basyx.aas.aggregator.api.IAASAggregator; +import org.eclipse.basyx.aas.bundle.AASBundle; +import org.eclipse.basyx.aas.bundle.AASBundleHelper; +import org.eclipse.basyx.aas.factory.aasx.AASXToMetamodelConverter; +import org.eclipse.basyx.aas.metamodel.api.IAssetAdministrationShell; +import org.eclipse.basyx.aas.metamodel.map.AssetAdministrationShell; +import org.eclipse.basyx.extensions.aas.aggregator.aasxupload.api.IAASAggregatorAASXUpload; +import org.eclipse.basyx.submodel.metamodel.api.identifier.IIdentifier; +import org.eclipse.basyx.vab.exception.provider.MalformedRequestException; +import org.eclipse.basyx.vab.exception.provider.ResourceNotFoundException; +import org.eclipse.basyx.vab.modelprovider.api.IModelProvider; + +/** + * An implementation of the IAASAggregatorAASXUpload interface using maps internally + * with the support of AASX upload via {@link InputStream} + * + * @author haque + * + */ +public class AASAggregatorAASXUpload implements IAASAggregatorAASXUpload { + private IAASAggregator aggregator; + /** + * Constructs default AAS Aggregator with AASX upload + */ + public AASAggregatorAASXUpload(IAASAggregator aggregator) { + this.aggregator = aggregator; + } + + @Override + public void uploadAASX(InputStream aasxStream) { + try { + AASXToMetamodelConverter converter = new AASXToMetamodelConverter(aasxStream); + Set bundles = converter.retrieveAASBundles(); + AASBundleHelper.integrate(this, bundles); + } catch (Exception e) { + throw new MalformedRequestException("invalid request to aasx path without valid aasx input stream"); + } + } + + @Override + public Collection getAASList() { + return aggregator.getAASList(); + } + + @Override + public IAssetAdministrationShell getAAS(IIdentifier aasId) throws ResourceNotFoundException { + return aggregator.getAAS(aasId); + } + + @Override + public IModelProvider getAASProvider(IIdentifier aasId) throws ResourceNotFoundException { + return aggregator.getAASProvider(aasId); + } + + @Override + public void createAAS(AssetAdministrationShell aas) { + aggregator.createAAS(aas); + } + + @Override + public void updateAAS(AssetAdministrationShell aas) throws ResourceNotFoundException { + aggregator.updateAAS(aas); + } + + @Override + public void deleteAAS(IIdentifier aasId) { + aggregator.deleteAAS(aasId); + } +} diff --git a/src/main/java/org/eclipse/basyx/extensions/aas/aggregator/aasxupload/api/IAASAggregatorAASXUpload.java b/src/main/java/org/eclipse/basyx/extensions/aas/aggregator/aasxupload/api/IAASAggregatorAASXUpload.java new file mode 100644 index 00000000..3a07c977 --- /dev/null +++ b/src/main/java/org/eclipse/basyx/extensions/aas/aggregator/aasxupload/api/IAASAggregatorAASXUpload.java @@ -0,0 +1,32 @@ +/******************************************************************************* +* Copyright (C) 2021 the Eclipse BaSyx Authors +* +* This program and the accompanying materials are made +* available under the terms of the Eclipse Public License 2.0 +* which is available at https://www.eclipse.org/legal/epl-2.0/ + +* +* SPDX-License-Identifier: EPL-2.0 +******************************************************************************/ + +package org.eclipse.basyx.extensions.aas.aggregator.aasxupload.api; + +import java.io.InputStream; + +import org.eclipse.basyx.aas.aggregator.api.IAASAggregator; + +/** + * Interface for the Asset Administration Shell Aggregator API
+ * with AASX upload support + * + * @author haque + * + */ +public interface IAASAggregatorAASXUpload extends IAASAggregator { + /** + * Uploads an AASX in the AAS Aggregator + * + * @param aasxStream stream of the given AASX + */ + public void uploadAASX(InputStream aasxStream); +} diff --git a/src/main/java/org/eclipse/basyx/extensions/aas/aggregator/aasxupload/proxy/AASAggregatorAASXUploadProxy.java b/src/main/java/org/eclipse/basyx/extensions/aas/aggregator/aasxupload/proxy/AASAggregatorAASXUploadProxy.java new file mode 100644 index 00000000..c0529609 --- /dev/null +++ b/src/main/java/org/eclipse/basyx/extensions/aas/aggregator/aasxupload/proxy/AASAggregatorAASXUploadProxy.java @@ -0,0 +1,45 @@ +/******************************************************************************* +* Copyright (C) 2021 the Eclipse BaSyx Authors +* +* This program and the accompanying materials are made +* available under the terms of the Eclipse Public License 2.0 +* which is available at https://www.eclipse.org/legal/epl-2.0/ + +* +* SPDX-License-Identifier: EPL-2.0 +******************************************************************************/ + +package org.eclipse.basyx.extensions.aas.aggregator.aasxupload.proxy; + +import java.io.InputStream; + +import org.eclipse.basyx.aas.aggregator.proxy.AASAggregatorProxy; +import org.eclipse.basyx.extensions.aas.aggregator.aasxupload.api.IAASAggregatorAASXUpload; +import org.eclipse.basyx.extensions.aas.aggregator.aasxupload.restapi.AASAggregatorAASXUploadProvider; +import org.eclipse.basyx.vab.exception.provider.MalformedRequestException; +import org.eclipse.basyx.vab.modelprovider.VABPathTools; +import org.eclipse.basyx.vab.protocol.http.helper.HTTPUploadHelper; + +/** + * Proxy AASAggregator with the support of uploading AASX + * @author haque + * + */ +public class AASAggregatorAASXUploadProxy extends AASAggregatorProxy implements IAASAggregatorAASXUpload { + private String aasAggregatorUrl; + + public AASAggregatorAASXUploadProxy(String url) { + super(url); + this.aasAggregatorUrl = url; + } + + @Override + public void uploadAASX(InputStream aasxStream) { + String uploadUrl = VABPathTools.append(aasAggregatorUrl, AASAggregatorAASXUploadProvider.AASX_PATH); + try { + HTTPUploadHelper.uploadHTTPPost(aasxStream, uploadUrl); + } catch (Exception e) { + throw new MalformedRequestException("invalid request to aasx path without valid aasx input stream"); + } + } +} diff --git a/src/main/java/org/eclipse/basyx/extensions/aas/aggregator/aasxupload/restapi/AASAggregatorAASXUploadProvider.java b/src/main/java/org/eclipse/basyx/extensions/aas/aggregator/aasxupload/restapi/AASAggregatorAASXUploadProvider.java new file mode 100644 index 00000000..31229737 --- /dev/null +++ b/src/main/java/org/eclipse/basyx/extensions/aas/aggregator/aasxupload/restapi/AASAggregatorAASXUploadProvider.java @@ -0,0 +1,63 @@ +/******************************************************************************* +* Copyright (C) 2021 the Eclipse BaSyx Authors +* +* This program and the accompanying materials are made +* available under the terms of the Eclipse Public License 2.0 +* which is available at https://www.eclipse.org/legal/epl-2.0/ + +* +* SPDX-License-Identifier: EPL-2.0 +******************************************************************************/ + +package org.eclipse.basyx.extensions.aas.aggregator.aasxupload.restapi; + +import java.io.InputStream; + +import org.eclipse.basyx.aas.aggregator.api.IAASAggregator; +import org.eclipse.basyx.aas.aggregator.restapi.AASAggregatorProvider; +import org.eclipse.basyx.extensions.aas.aggregator.aasxupload.api.IAASAggregatorAASXUpload; +import org.eclipse.basyx.vab.exception.provider.ProviderException; +import org.eclipse.basyx.vab.modelprovider.VABPathTools; + +/** + * Provider class with support to upload an AASX file in the + * underlying {@link IAASAggregator} + * @author haque + * + */ +public class AASAggregatorAASXUploadProvider extends AASAggregatorProvider { + private IAASAggregatorAASXUpload uploadAggregator; + public static final String AASX_PATH = "aasx"; + + public AASAggregatorAASXUploadProvider(IAASAggregatorAASXUpload aggregator) { + super(aggregator); + this.uploadAggregator = aggregator; + } + + @Override + public void createValue(String path, Object newEntity) throws ProviderException { + path = stripPrefix(path); + String[] splitted = VABPathTools.splitPath(path); + + if (isAASXAccessPath(splitted)) { + try { + this.uploadAggregator.uploadAASX((InputStream) newEntity); + } catch (Exception e) { + throw new ProviderException("AASX upload failed"); + } + + } else { + super.createValue(path, newEntity); + } + } + + /** + * Checks if the path array is a valid AASX path + * @param splitted + * @return true/false + */ + private boolean isAASXAccessPath(String[] splitted) { + return splitted.length == 1 && splitted[0].equals(AASX_PATH); + } + +} diff --git a/src/main/java/org/eclipse/basyx/extensions/aas/aggregator/mqtt/MqttAASAggregatorObserver.java b/src/main/java/org/eclipse/basyx/extensions/aas/aggregator/mqtt/MqttAASAggregatorObserver.java new file mode 100644 index 00000000..637b794f --- /dev/null +++ b/src/main/java/org/eclipse/basyx/extensions/aas/aggregator/mqtt/MqttAASAggregatorObserver.java @@ -0,0 +1,87 @@ +/******************************************************************************* + * Copyright (C) 2021 the Eclipse BaSyx Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + ******************************************************************************/ +package org.eclipse.basyx.extensions.aas.aggregator.mqtt; + +import org.eclipse.basyx.aas.aggregator.api.IAASAggregator; +import org.eclipse.basyx.aas.aggregator.observing.IAASAggregatorObserver; +import org.eclipse.basyx.extensions.shared.mqtt.MqttEventService; +import org.eclipse.paho.client.mqttv3.MqttClient; +import org.eclipse.paho.client.mqttv3.MqttException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Observer for the AASAggregator that triggers MQTT events for + * different operations on the aggregator. + * + * @author haque + * + */ +public class MqttAASAggregatorObserver extends MqttEventService implements IAASAggregatorObserver { + private static Logger logger = LoggerFactory.getLogger(MqttAASAggregatorObserver.class); + + // List of topics + public static final String TOPIC_CREATEAAS = "BaSyxAggregator_createdAAS"; + public static final String TOPIC_DELETEAAS = "BaSyxAggregator_deletedAAS"; + public static final String TOPIC_UPDATEAAS = "BaSyxAggregator_updatedAAS"; + + /** + * Constructor for adding this MQTT extension as an AAS Aggregator Observer + * + * @param serverEndpoint endpoint of mqtt broker + * @param clientId unique client identifier + * @throws MqttException + */ + public MqttAASAggregatorObserver(String serverEndpoint, String clientId) throws MqttException { + super(serverEndpoint, clientId); + logger.info("Create new MQTT AAS Aggregator Observer for endpoint " + serverEndpoint); + } + + /** + * Constructor for adding this MQTT extension as an AAS Aggregator Observer + * + * @param serverEndpoint endpoint of mqtt broker + * @param clientId unique client identifier + * @param user username for authentication with broker + * @param pw password for authentication with broker + * @throws MqttException + */ + public MqttAASAggregatorObserver(IAASAggregator observedAASAggregator, String serverEndpoint, String clientId, String user, char[] pw) + throws MqttException { + super(serverEndpoint, clientId, user, pw); + logger.info("Create new MQTT AAS Aggregator Observer for endpoint " + serverEndpoint); + } + + /** + * Constructor for adding this MQTT extension as an AAS Aggregator Observer + * + * @param client already configured client + * @throws MqttException + */ + public MqttAASAggregatorObserver(IAASAggregator observedAASAggregator, MqttClient client) throws MqttException { + super(client); + logger.info("Create new MQTT AAS Aggregator Observer for endpoint " + client.getServerURI()); + } + + @Override + public void aasCreated(String aasId) { + sendMqttMessage(TOPIC_CREATEAAS, aasId); + } + + @Override + public void aasUpdated(String aasId) { + sendMqttMessage(TOPIC_UPDATEAAS, aasId); + } + + @Override + public void aasDeleted(String aasId) { + sendMqttMessage(TOPIC_DELETEAAS, aasId); + } +} diff --git a/src/main/java/org/eclipse/basyx/extensions/aas/directory/tagged/api/IAASTaggedDirectory.java b/src/main/java/org/eclipse/basyx/extensions/aas/directory/tagged/api/IAASTaggedDirectory.java index 795f6a9a..c8fc43b3 100644 --- a/src/main/java/org/eclipse/basyx/extensions/aas/directory/tagged/api/IAASTaggedDirectory.java +++ b/src/main/java/org/eclipse/basyx/extensions/aas/directory/tagged/api/IAASTaggedDirectory.java @@ -34,7 +34,7 @@ public interface IAASTaggedDirectory extends IAASRegistry { /** * Looks up all AAS that are tagged with all tags * - * @param tag + * @param tags * @return */ public Set lookupTags(Set tags); diff --git a/src/main/java/org/eclipse/basyx/extensions/aas/directory/tagged/api/TaggedAASDescriptor.java b/src/main/java/org/eclipse/basyx/extensions/aas/directory/tagged/api/TaggedAASDescriptor.java index beae4f2c..a42a8d32 100644 --- a/src/main/java/org/eclipse/basyx/extensions/aas/directory/tagged/api/TaggedAASDescriptor.java +++ b/src/main/java/org/eclipse/basyx/extensions/aas/directory/tagged/api/TaggedAASDescriptor.java @@ -33,7 +33,7 @@ public class TaggedAASDescriptor extends AASDescriptor { * Create a new aas descriptor that retrieves the necessary information from a * passend AssetAdministrationShell * - * @param iAssetAdministrationShell + * @param assetAdministrationShell * @param endpoint */ public TaggedAASDescriptor(IAssetAdministrationShell assetAdministrationShell, String endpoint) { diff --git a/src/main/java/org/eclipse/basyx/extensions/aas/directory/tagged/map/MapTaggedDirectory.java b/src/main/java/org/eclipse/basyx/extensions/aas/directory/tagged/map/MapTaggedDirectory.java index 403cfbaf..1d07b62f 100644 --- a/src/main/java/org/eclipse/basyx/extensions/aas/directory/tagged/map/MapTaggedDirectory.java +++ b/src/main/java/org/eclipse/basyx/extensions/aas/directory/tagged/map/MapTaggedDirectory.java @@ -23,7 +23,7 @@ import org.eclipse.basyx.submodel.metamodel.api.identifier.IIdentifier; /** - * Map implementation of a tagged directory. It extends {@link MapRegistry} by + * Map implementation of a tagged directory. It extends {@link AASRegistry} by * additionally managing a map of tags * * @author schnicke @@ -54,7 +54,7 @@ public void register(TaggedAASDescriptor descriptor) { @Override public Set lookupTag(String tag) { if (tagMap.containsKey(tag)) { - return tagMap.get(tag); + return new HashSet<>(tagMap.get(tag)); } else { return new HashSet<>(); } diff --git a/src/main/java/org/eclipse/basyx/extensions/aas/registration/mqtt/MqttAASRegistryServiceObserver.java b/src/main/java/org/eclipse/basyx/extensions/aas/registration/mqtt/MqttAASRegistryServiceObserver.java new file mode 100644 index 00000000..4586afbc --- /dev/null +++ b/src/main/java/org/eclipse/basyx/extensions/aas/registration/mqtt/MqttAASRegistryServiceObserver.java @@ -0,0 +1,113 @@ +/******************************************************************************* + * Copyright (C) 2021 the Eclipse BaSyx Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + ******************************************************************************/ +package org.eclipse.basyx.extensions.aas.registration.mqtt; + +import org.eclipse.basyx.aas.registration.observing.IAASRegistryServiceObserver; +import org.eclipse.basyx.extensions.shared.mqtt.MqttEventService; +import org.eclipse.basyx.submodel.metamodel.api.identifier.IIdentifier; +import org.eclipse.paho.client.mqttv3.MqttClient; +import org.eclipse.paho.client.mqttv3.MqttClientPersistence; +import org.eclipse.paho.client.mqttv3.MqttException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Implementation variant for the AASRegistryServiceObserver that triggers MQTT events for + * different operations on the registry. + * + * @author haque + * + */ +public class MqttAASRegistryServiceObserver extends MqttEventService implements IAASRegistryServiceObserver { + private static Logger logger = LoggerFactory.getLogger(MqttAASRegistryServiceObserver.class); + + // List of topics + public static final String TOPIC_REGISTERAAS = "BaSyxRegistry_registeredAAS"; + public static final String TOPIC_REGISTERSUBMODEL = "BaSyxRegistry_registeredSubmodel"; + public static final String TOPIC_DELETEAAS = "BaSyxRegistry_deletedAAS"; + public static final String TOPIC_DELETESUBMODEL = "BaSyxRegistry_deletedSubmodel"; + + /** + * Constructor for adding this MQTT extension as an AAS Registry Observer + * + * @param serverEndpoint endpoint of mqtt broker + * @param clientId unique client identifier + * @throws MqttException + */ + public MqttAASRegistryServiceObserver(String serverEndpoint, String clientId) throws MqttException { + super(serverEndpoint, clientId); + logger.info("Create new MQTT AAS Registry Service Observer for endpoint " + serverEndpoint); + } + + /** + * Constructor for adding this MQTT extension as an AAS Registry Observer with a + * custom mqtt client persistence + * + * @param serverEndpoint endpoint of mqtt broker + * @param clientId unique client identifier + * @param clientId unique client identifier + * @param mqttPersistence custom mqtt persistence strategy + * @throws MqttException + */ + public MqttAASRegistryServiceObserver(String serverEndpoint, String clientId, MqttClientPersistence mqttPersistence) throws MqttException { + super(serverEndpoint, clientId, mqttPersistence); + logger.info("Create new MQTT AAS Registry Service Observer for endpoint " + serverEndpoint); + } + + /** + * Constructor for adding this MQTT extension as an AAS Registry Observer + * + * @param serverEndpoint endpoint of mqtt broker + * @param clientId unique client identifier + * @param user username for authentication with broker + * @param pw password for authentication with broker + * @throws MqttException + */ + public MqttAASRegistryServiceObserver(String serverEndpoint, String clientId, String user, char[] pw) + throws MqttException { + super(serverEndpoint, clientId, user, pw); + logger.info("Create new MQTT AAS Registry Service Observer for endpoint " + serverEndpoint); + } + + /** + * Constructor for adding this MQTT extension as an AAS Registry Observer + * + * @param client already configured client + * @throws MqttException + */ + public MqttAASRegistryServiceObserver(MqttClient client) throws MqttException { + super(client); + logger.info("Create new MQTT AAS Registry Service Observer for endpoint " + client.getServerURI()); + } + + @Override + public void aasRegistered(String aasId) { + sendMqttMessage(TOPIC_REGISTERAAS, aasId); + } + + @Override + public void submodelRegistered(IIdentifier aasId, IIdentifier smId) { + sendMqttMessage(TOPIC_REGISTERSUBMODEL, concatAasSmId(aasId, smId)); + } + + @Override + public void aasDeleted(String aasId) { + sendMqttMessage(TOPIC_DELETEAAS, aasId); + } + + @Override + public void submodelDeleted(IIdentifier aasId, IIdentifier smId) { + sendMqttMessage(TOPIC_DELETESUBMODEL, concatAasSmId(aasId, smId)); + } + + public static String concatAasSmId(IIdentifier aasId, IIdentifier smId) { + return "(" + aasId.getId() + "," + smId.getId() + ")"; + } +} diff --git a/src/main/java/org/eclipse/basyx/extensions/shared/mqtt/MqttEventService.java b/src/main/java/org/eclipse/basyx/extensions/shared/mqtt/MqttEventService.java index f71ef77e..18606715 100644 --- a/src/main/java/org/eclipse/basyx/extensions/shared/mqtt/MqttEventService.java +++ b/src/main/java/org/eclipse/basyx/extensions/shared/mqtt/MqttEventService.java @@ -10,6 +10,7 @@ package org.eclipse.basyx.extensions.shared.mqtt; import org.eclipse.paho.client.mqttv3.MqttClient; +import org.eclipse.paho.client.mqttv3.MqttClientPersistence; import org.eclipse.paho.client.mqttv3.MqttConnectOptions; import org.eclipse.paho.client.mqttv3.MqttException; import org.eclipse.paho.client.mqttv3.MqttMessage; @@ -41,12 +42,33 @@ public class MqttEventService { * @throws MqttException */ public MqttEventService(String serverEndpoint, String clientId) throws MqttException { - this.mqttClient = new MqttClient(serverEndpoint, clientId, new MqttDefaultFilePersistence()); - mqttClient.connect(); + this(serverEndpoint, clientId, new MqttDefaultFilePersistence()); } + /** + * Constructor for creating an MqttClient (with no authentication and a custom + * persistence strategy) + */ + public MqttEventService(String serverEndpoint, String clientId, MqttClientPersistence mqttPersistence) throws MqttException { + this.mqttClient = new MqttClient(serverEndpoint, clientId, mqttPersistence); + mqttClient.connect(); + } + + /** + * Constructor for creating an MqttClient with authentication and a custom + * persistence strategy + */ + public MqttEventService(String serverEndpoint, String clientId, String user, char[] pw, MqttClientPersistence mqttPersistence) throws MqttException { + this.mqttClient = new MqttClient(serverEndpoint, clientId, mqttPersistence); + MqttConnectOptions options = new MqttConnectOptions(); + options.setUserName(user); + options.setPassword(pw); + mqttClient.connect(options); + } + /** * Constructor for creating an MqttClient with authentication + * * @param serverEndpoint * @param clientId * @param user @@ -55,11 +77,7 @@ public MqttEventService(String serverEndpoint, String clientId) throws MqttExcep */ public MqttEventService(String serverEndpoint, String clientId, String user, char[] pw) throws MqttException { - this.mqttClient = new MqttClient(serverEndpoint, clientId, new MqttDefaultFilePersistence()); - MqttConnectOptions options = new MqttConnectOptions(); - options.setUserName(user); - options.setPassword(pw); - mqttClient.connect(options); + this(serverEndpoint, clientId, user, pw, new MqttDefaultFilePersistence()); } /** @@ -87,8 +105,6 @@ public void setQoS(int qos) { /** * Gets the QoS for MQTT messages - * - * @param qos */ public int getQoS() { return this.qos; diff --git a/src/main/java/org/eclipse/basyx/extensions/submodel/mqtt/MqttSubmodelAPI.java b/src/main/java/org/eclipse/basyx/extensions/submodel/mqtt/MqttSubmodelAPI.java index fdf33e94..a4b69cf2 100644 --- a/src/main/java/org/eclipse/basyx/extensions/submodel/mqtt/MqttSubmodelAPI.java +++ b/src/main/java/org/eclipse/basyx/extensions/submodel/mqtt/MqttSubmodelAPI.java @@ -10,33 +10,29 @@ package org.eclipse.basyx.extensions.submodel.mqtt; import java.util.Collection; -import java.util.HashSet; -import java.util.List; import java.util.Set; -import org.eclipse.basyx.extensions.shared.mqtt.MqttEventService; import org.eclipse.basyx.submodel.metamodel.api.ISubmodel; -import org.eclipse.basyx.submodel.metamodel.api.reference.IKey; -import org.eclipse.basyx.submodel.metamodel.api.reference.IReference; import org.eclipse.basyx.submodel.metamodel.api.submodelelement.ISubmodelElement; import org.eclipse.basyx.submodel.metamodel.api.submodelelement.operation.IOperation; import org.eclipse.basyx.submodel.restapi.api.ISubmodelAPI; +import org.eclipse.basyx.submodel.restapi.observing.ObservableSubmodelAPI; import org.eclipse.basyx.vab.modelprovider.VABPathTools; import org.eclipse.paho.client.mqttv3.MqttClient; +import org.eclipse.paho.client.mqttv3.MqttClientPersistence; import org.eclipse.paho.client.mqttv3.MqttException; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import org.eclipse.paho.client.mqttv3.persist.MqttDefaultFilePersistence; /** * Implementation variant for the SubmodelAPI that triggers MQTT events for * different CRUD operations on the submodel. Has to be based on a backend * implementation of the ISubmodelAPI to forward its method calls. * + * * @author espen * */ -public class MqttSubmodelAPI extends MqttEventService implements ISubmodelAPI { - private static Logger logger = LoggerFactory.getLogger(MqttSubmodelAPI.class); +public class MqttSubmodelAPI implements ISubmodelAPI { // List of topics public static final String TOPIC_CREATESUBMODEL = "BaSyxSubmodel_createdSubmodel"; @@ -45,11 +41,10 @@ public class MqttSubmodelAPI extends MqttEventService implements ISubmodelAPI { public static final String TOPIC_UPDATEELEMENT = "BaSyxSubmodel_updatedSubmodelElement"; // The underlying SubmodelAPI - protected ISubmodelAPI observedAPI; + protected ObservableSubmodelAPI observedAPI; + + private MqttSubmodelAPIObserver observer; - // Submodel Element whitelist for filtering - protected boolean useWhitelist = false; - protected Set whitelist = new HashSet<>(); /** * Constructor for adding this MQTT extension on top of another SubmodelAPI @@ -58,10 +53,16 @@ public class MqttSubmodelAPI extends MqttEventService implements ISubmodelAPI { * @throws MqttException */ public MqttSubmodelAPI(ISubmodelAPI observedAPI, String serverEndpoint, String clientId) throws MqttException { - super(serverEndpoint, clientId); - logger.info("Create new MQTT submodel for endpoint " + serverEndpoint); - this.observedAPI = observedAPI; - sendMqttMessage(TOPIC_CREATESUBMODEL, observedAPI.getSubmodel().getIdentification().getId()); + this(observedAPI, serverEndpoint, clientId, new MqttDefaultFilePersistence()); + } + + /** + * Constructor for adding this MQTT extension on top of another SubmodelAPI with + * a custom persistence strategy + */ + public MqttSubmodelAPI(ISubmodelAPI observedAPI, String brokerEndpoint, String clientId, MqttClientPersistence persistence) throws MqttException { + this.observedAPI = new ObservableSubmodelAPI(observedAPI); + observer = new MqttSubmodelAPIObserver(this.observedAPI, brokerEndpoint, clientId, persistence); } /** @@ -72,10 +73,15 @@ public MqttSubmodelAPI(ISubmodelAPI observedAPI, String serverEndpoint, String c */ public MqttSubmodelAPI(ISubmodelAPI observedAPI, String serverEndpoint, String clientId, String user, char[] pw) throws MqttException { - super(serverEndpoint, clientId, user, pw); - logger.info("Create new MQTT submodel for endpoint " + serverEndpoint); - this.observedAPI = observedAPI; - sendMqttMessage(TOPIC_CREATESUBMODEL, observedAPI.getSubmodel().getIdentification().getId()); + this(observedAPI, serverEndpoint, clientId, user, pw, new MqttDefaultFilePersistence()); + } + + /** + * Constructor for adding this MQTT extension on top of another SubmodelAPI. + */ + public MqttSubmodelAPI(ISubmodelAPI observedAPI, String serverEndpoint, String clientId, String user, char[] pw, MqttClientPersistence persistence) throws MqttException { + this.observedAPI = new ObservableSubmodelAPI(observedAPI); + observer = new MqttSubmodelAPIObserver(this.observedAPI, serverEndpoint, clientId, user, pw, persistence); } /** @@ -86,48 +92,40 @@ public MqttSubmodelAPI(ISubmodelAPI observedAPI, String serverEndpoint, String c * @throws MqttException */ public MqttSubmodelAPI(ISubmodelAPI observedAPI, MqttClient client) throws MqttException { - super(client); - this.observedAPI = observedAPI; - sendMqttMessage(TOPIC_CREATESUBMODEL, observedAPI.getSubmodel().getIdentification().getId()); + this.observedAPI = new ObservableSubmodelAPI(observedAPI); + observer = new MqttSubmodelAPIObserver(this.observedAPI, client); } /** * Adds a submodel element to the filter whitelist. Can also be a path for nested submodel elements. * - * @param element + * @param shortId */ public void observeSubmodelElement(String shortId) { - whitelist.add(VABPathTools.stripSlashes(shortId)); + observer.observeSubmodelElement(VABPathTools.stripSlashes(shortId)); } /** * Sets a new filter whitelist. * - * @param element + * @param shortIds */ public void setWhitelist(Set shortIds) { - this.whitelist.clear(); - for (String entry : shortIds) { - this.whitelist.add(VABPathTools.stripSlashes(entry)); - } + observer.setWhitelist(shortIds); } /** * Disables the submodel element filter whitelist - * - * @param element */ public void disableWhitelist() { - useWhitelist = false; + observer.disableWhitelist(); } /** * Enables the submodel element filter whitelist - * - * @param element */ public void enableWhitelist() { - useWhitelist = true; + observer.enableWhitelist(); } @Override @@ -138,17 +136,11 @@ public ISubmodel getSubmodel() { @Override public void addSubmodelElement(ISubmodelElement elem) { observedAPI.addSubmodelElement(elem); - if (filter(elem.getIdShort())) { - sendMqttMessage(TOPIC_ADDELEMENT, getCombinedMessage(getAASId(), getSubmodelId(), elem.getIdShort())); - } } @Override public void addSubmodelElement(String idShortPath, ISubmodelElement elem) { observedAPI.addSubmodelElement(idShortPath, elem); - if (filter(idShortPath)) { - sendMqttMessage(TOPIC_ADDELEMENT, getCombinedMessage(getAASId(), getSubmodelId(), idShortPath)); - } } @Override @@ -159,9 +151,6 @@ public ISubmodelElement getSubmodelElement(String idShortPath) { @Override public void deleteSubmodelElement(String idShortPath) { observedAPI.deleteSubmodelElement(idShortPath); - if (filter(idShortPath)) { - sendMqttMessage(TOPIC_DELETEELEMENT, getCombinedMessage(getAASId(), getSubmodelId(), idShortPath)); - } } @Override @@ -177,9 +166,6 @@ public Collection getSubmodelElements() { @Override public void updateSubmodelElement(String idShortPath, Object newValue) { observedAPI.updateSubmodelElement(idShortPath, newValue); - if (filter(idShortPath)) { - sendMqttMessage(TOPIC_UPDATEELEMENT, getCombinedMessage(getAASId(), getSubmodelId(), idShortPath)); - } } @Override @@ -206,26 +192,4 @@ public static String getCombinedMessage(String aasId, String submodelId, String elementPart = VABPathTools.stripSlashes(elementPart); return "(" + aasId + "," + submodelId + "," + elementPart + ")"; } - - private boolean filter(String idShort) { - idShort = VABPathTools.stripSlashes(idShort); - return !useWhitelist || whitelist.contains(idShort); - } - - private String getSubmodelId() { - ISubmodel submodel = getSubmodel(); - return submodel.getIdentification().getId(); - } - - private String getAASId() { - ISubmodel submodel = getSubmodel(); - IReference parentReference = submodel.getParent(); - if (parentReference != null) { - List keys = parentReference.getKeys(); - if (keys != null && keys.size() > 0) { - return keys.get(0).getValue(); - } - } - return null; - } } diff --git a/src/main/java/org/eclipse/basyx/extensions/submodel/mqtt/MqttSubmodelAPIObserver.java b/src/main/java/org/eclipse/basyx/extensions/submodel/mqtt/MqttSubmodelAPIObserver.java new file mode 100644 index 00000000..5f93447a --- /dev/null +++ b/src/main/java/org/eclipse/basyx/extensions/submodel/mqtt/MqttSubmodelAPIObserver.java @@ -0,0 +1,197 @@ +/******************************************************************************* +* Copyright (C) 2021 the Eclipse BaSyx Authors +* +* This program and the accompanying materials are made +* available under the terms of the Eclipse Public License 2.0 +* which is available at https://www.eclipse.org/legal/epl-2.0/ +* +* SPDX-License-Identifier: EPL-2.0 +******************************************************************************/ +package org.eclipse.basyx.extensions.submodel.mqtt; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import org.eclipse.basyx.extensions.shared.mqtt.MqttEventService; +import org.eclipse.basyx.submodel.metamodel.api.ISubmodel; +import org.eclipse.basyx.submodel.metamodel.api.reference.IKey; +import org.eclipse.basyx.submodel.metamodel.api.reference.IReference; +import org.eclipse.basyx.submodel.restapi.observing.ISubmodelAPIObserver; +import org.eclipse.basyx.submodel.restapi.observing.ObservableSubmodelAPI; +import org.eclipse.basyx.vab.modelprovider.VABPathTools; +import org.eclipse.paho.client.mqttv3.MqttClient; +import org.eclipse.paho.client.mqttv3.MqttClientPersistence; +import org.eclipse.paho.client.mqttv3.MqttException; +import org.eclipse.paho.client.mqttv3.persist.MqttDefaultFilePersistence; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Implementation of {@link ISubmodelAPIObserver} + * Triggers MQTT events for different CRUD operations on the submodel. + * + * @author conradi + * + */ +public class MqttSubmodelAPIObserver extends MqttEventService implements ISubmodelAPIObserver { + private static Logger logger = LoggerFactory.getLogger(MqttSubmodelAPIObserver.class); + + // List of topics + public static final String TOPIC_CREATESUBMODEL = "BaSyxSubmodel_createdSubmodel"; + public static final String TOPIC_ADDELEMENT = "BaSyxSubmodel_addedSubmodelElement"; + public static final String TOPIC_DELETEELEMENT = "BaSyxSubmodel_removedSubmodelElement"; + public static final String TOPIC_UPDATEELEMENT = "BaSyxSubmodel_updatedSubmodelElement"; + + // The underlying SubmodelAPI + protected ObservableSubmodelAPI observedAPI; + + // Submodel Element whitelist for filtering + protected boolean useWhitelist = false; + protected Set whitelist = new HashSet<>(); + + /** + * Constructor for adding this MQTT extension on top of another SubmodelAPI + * + * @param observedAPI The underlying submodelAPI + * @throws MqttException + */ + public MqttSubmodelAPIObserver(ObservableSubmodelAPI observedAPI, String serverEndpoint, String clientId) throws MqttException { + this(observedAPI, serverEndpoint, clientId, new MqttDefaultFilePersistence()); + } + + /** + * Constructor for adding this MQTT extension on top of another SubmodelAPI with + * a custom persistence strategy + */ + public MqttSubmodelAPIObserver(ObservableSubmodelAPI observedAPI, String brokerEndpoint, String clientId, MqttClientPersistence persistence) throws MqttException { + super(brokerEndpoint, clientId, persistence); + logger.info("Create new MQTT submodel for endpoint " + brokerEndpoint); + this.observedAPI = observedAPI; + observedAPI.addObserver(this); + sendMqttMessage(TOPIC_CREATESUBMODEL, observedAPI.getSubmodel().getIdentification().getId()); + } + + /** + * Constructor for adding this MQTT extension on top of another SubmodelAPI + * + * @param observedAPI The underlying submodelAPI + * @throws MqttException + */ + public MqttSubmodelAPIObserver(ObservableSubmodelAPI observedAPI, String serverEndpoint, String clientId, String user, char[] pw) + throws MqttException { + this(observedAPI, serverEndpoint, clientId, user, pw, new MqttDefaultFilePersistence()); + } + + /** + * Constructor for adding this MQTT extension on top of another SubmodelAPI with + * credentials and persistency strategy + */ + public MqttSubmodelAPIObserver(ObservableSubmodelAPI observedAPI, String serverEndpoint, String clientId, String user, char[] pw, MqttClientPersistence persistence) throws MqttException { + super(serverEndpoint, clientId, user, pw); + logger.info("Create new MQTT submodel for endpoint " + serverEndpoint); + this.observedAPI = observedAPI; + observedAPI.addObserver(this); + sendMqttMessage(TOPIC_CREATESUBMODEL, observedAPI.getSubmodel().getIdentification().getId()); + } + + /** + * Constructor for adding this MQTT extension on top of another SubmodelAPI. + * + * @param observedAPI The underlying submodelAPI + * @param client An already connected mqtt client + * @throws MqttException + */ + public MqttSubmodelAPIObserver(ObservableSubmodelAPI observedAPI, MqttClient client) throws MqttException { + super(client); + this.observedAPI = observedAPI; + observedAPI.addObserver(this); + sendMqttMessage(TOPIC_CREATESUBMODEL, observedAPI.getSubmodel().getIdentification().getId()); + } + + /** + * Adds a submodel element to the filter whitelist. Can also be a path for nested submodel elements. + * + * @param shortId + */ + public void observeSubmodelElement(String shortId) { + whitelist.add(VABPathTools.stripSlashes(shortId)); + } + + /** + * Sets a new filter whitelist. + * + * @param shortIds + */ + public void setWhitelist(Set shortIds) { + this.whitelist.clear(); + for (String entry : shortIds) { + this.whitelist.add(VABPathTools.stripSlashes(entry)); + } + } + + /** + * Disables the submodel element filter whitelist + * + */ + public void disableWhitelist() { + useWhitelist = false; + } + + /** + * Enables the submodel element filter whitelist + * + */ + public void enableWhitelist() { + useWhitelist = true; + } + + @Override + public void elementAdded(String idShortPath, Object newValue) { + if (filter(idShortPath)) { + sendMqttMessage(TOPIC_ADDELEMENT, getCombinedMessage(getAASId(), getSubmodelId(), idShortPath)); + } + } + + @Override + public void elementDeleted(String idShortPath) { + if (filter(idShortPath)) { + sendMqttMessage(TOPIC_DELETEELEMENT, getCombinedMessage(getAASId(), getSubmodelId(), idShortPath)); + } + } + + @Override + public void elementUpdated(String idShortPath, Object newValue) { + if (filter(idShortPath)) { + sendMqttMessage(TOPIC_UPDATEELEMENT, getCombinedMessage(getAASId(), getSubmodelId(), idShortPath)); + } + } + + public static String getCombinedMessage(String aasId, String submodelId, String elementPart) { + elementPart = VABPathTools.stripSlashes(elementPart); + return "(" + aasId + "," + submodelId + "," + elementPart + ")"; + } + + private boolean filter(String idShort) { + idShort = VABPathTools.stripSlashes(idShort); + return !useWhitelist || whitelist.contains(idShort); + } + + private String getSubmodelId() { + ISubmodel submodel = observedAPI.getSubmodel(); + return submodel.getIdentification().getId(); + } + + private String getAASId() { + ISubmodel submodel = observedAPI.getSubmodel(); + IReference parentReference = submodel.getParent(); + if (parentReference != null) { + List keys = parentReference.getKeys(); + if (keys != null && keys.size() > 0) { + return keys.get(0).getValue(); + } + } + return null; + } + +} diff --git a/src/main/java/org/eclipse/basyx/submodel/factory/xml/api/dataspecification/DataSpecificationIEC61360XMLConverter.java b/src/main/java/org/eclipse/basyx/submodel/factory/xml/api/dataspecification/DataSpecificationIEC61360XMLConverter.java index b7e80c18..645e591b 100644 --- a/src/main/java/org/eclipse/basyx/submodel/factory/xml/api/dataspecification/DataSpecificationIEC61360XMLConverter.java +++ b/src/main/java/org/eclipse/basyx/submodel/factory/xml/api/dataspecification/DataSpecificationIEC61360XMLConverter.java @@ -63,7 +63,7 @@ public class DataSpecificationIEC61360XMLConverter { /** * Parses the DataSpecificationIEC61360 object from XML * - * @param xmlDataSpecificationContentObject the XML map containing the <aas:dataSpecificationIEC61360> tag + * @param contentObj the XML map containing the <aas:dataSpecificationIEC61360> tag * @return the parsed DataSpecificationIEC61360 object */ @SuppressWarnings("unchecked") diff --git a/src/main/java/org/eclipse/basyx/submodel/factory/xml/converters/qualifier/HasDataSpecificationXMLConverter.java b/src/main/java/org/eclipse/basyx/submodel/factory/xml/converters/qualifier/HasDataSpecificationXMLConverter.java index dbf2d78d..d0798261 100644 --- a/src/main/java/org/eclipse/basyx/submodel/factory/xml/converters/qualifier/HasDataSpecificationXMLConverter.java +++ b/src/main/java/org/eclipse/basyx/submodel/factory/xml/converters/qualifier/HasDataSpecificationXMLConverter.java @@ -47,7 +47,7 @@ public class HasDataSpecificationXMLConverter { * Populates a given IHasDataSpecification object with the data form the given XML * * @param xmlObject the XML map containing the <aas:embeddedDataSpecification> tag - * @param hasDataSpecificationMap the IHasDataSpecification object to be populated -treated as Map here- + * @param hasDataSpecification the IHasDataSpecification object to be populated -treated as Map here- */ public static void populateHasDataSpecification(Map xmlObject, HasDataSpecification hasDataSpecification) { diff --git a/src/main/java/org/eclipse/basyx/submodel/factory/xml/converters/submodelelement/operation/OperationXMLConverter.java b/src/main/java/org/eclipse/basyx/submodel/factory/xml/converters/submodelelement/operation/OperationXMLConverter.java index e982a38a..4a7efc90 100644 --- a/src/main/java/org/eclipse/basyx/submodel/factory/xml/converters/submodelelement/operation/OperationXMLConverter.java +++ b/src/main/java/org/eclipse/basyx/submodel/factory/xml/converters/submodelelement/operation/OperationXMLConverter.java @@ -14,6 +14,7 @@ import java.util.List; import java.util.Map; +import org.eclipse.basyx.aas.factory.xml.AASXPackageExplorerCompatibilityHandler; import org.eclipse.basyx.submodel.factory.xml.XMLHelper; import org.eclipse.basyx.submodel.factory.xml.converters.submodelelement.SubmodelElementXMLConverter; import org.eclipse.basyx.submodel.metamodel.api.submodelelement.ISubmodelElement; @@ -47,23 +48,25 @@ public class OperationXMLConverter extends SubmodelElementXMLConverter { * @param xmlObject the Map with the content of XML tag <aas:operation> * @return the parsed Operation */ - @SuppressWarnings("unchecked") public static Operation parseOperation(Map xmlObject) { List inList = new ArrayList<>(); List outList = new ArrayList<>(); List inoutList = new ArrayList<>(); - Map inObj = (Map) xmlObject.get(INPUT_VARIABLE); + Map inObj = AASXPackageExplorerCompatibilityHandler + .prepareOperationVariableMap(xmlObject.get(INPUT_VARIABLE)); if (inObj != null) { inList = getOperationVariables(inObj); } - Map outObj = (Map) xmlObject.get(OUTPUT_VARIABLE); + Map outObj = AASXPackageExplorerCompatibilityHandler + .prepareOperationVariableMap(xmlObject.get(OUTPUT_VARIABLE)); if (outObj != null) { outList = getOperationVariables(outObj); } - Map inoutObj = (Map) xmlObject.get(INOUTPUT_VARIABLE); + Map inoutObj = AASXPackageExplorerCompatibilityHandler + .prepareOperationVariableMap(xmlObject.get(INOUTPUT_VARIABLE)); if (inoutObj != null) { inoutList = getOperationVariables(inoutObj); } @@ -163,13 +166,6 @@ private static List getOperationVariables(Map List variableList = new ArrayList<>(); Object operationVarObj = varObj.get(OPERATION_VARIABLE); - //TODO: Remove after non-existing aas:operationVariable problem fixed in AAS Package Explorer - if (operationVarObj == null) { - if (varObj.get(VALUE) != null) { - operationVarObj = varObj; - } - } - List> xmlOpVars = XMLHelper.getList(operationVarObj); for(Map map : xmlOpVars) { diff --git a/src/main/java/org/eclipse/basyx/submodel/factory/xml/converters/submodelelement/relationship/AnnotatedRelationshipElementXMLConverter.java b/src/main/java/org/eclipse/basyx/submodel/factory/xml/converters/submodelelement/relationship/AnnotatedRelationshipElementXMLConverter.java index 28432687..74cbb53d 100644 --- a/src/main/java/org/eclipse/basyx/submodel/factory/xml/converters/submodelelement/relationship/AnnotatedRelationshipElementXMLConverter.java +++ b/src/main/java/org/eclipse/basyx/submodel/factory/xml/converters/submodelelement/relationship/AnnotatedRelationshipElementXMLConverter.java @@ -72,7 +72,7 @@ public static AnnotatedRelationshipElement parseAnnotatedRelationshipElement(Map * Builds the <aas:annotatedRelationshipElement> XML tag for a AnnotatedRelationshipElement * * @param document the XML document - * @param relElem the IAnnotatedRelationshipElement to build the XML for + * @param annotatedElement the IAnnotatedRelationshipElement to build the XML for * @return the <aas:annotatedRelationshipElement> XML tag for the given AnnotatedRelationshipElement */ public static Element buildAnnotatedRelationshipElement(Document document, IAnnotatedRelationshipElement annotatedElement) { diff --git a/src/main/java/org/eclipse/basyx/submodel/metamodel/api/ISubmodel.java b/src/main/java/org/eclipse/basyx/submodel/metamodel/api/ISubmodel.java index e0346906..ba54b6b2 100644 --- a/src/main/java/org/eclipse/basyx/submodel/metamodel/api/ISubmodel.java +++ b/src/main/java/org/eclipse/basyx/submodel/metamodel/api/ISubmodel.java @@ -19,8 +19,8 @@ /** * A submodel defines a specific aspect of the asset represented by the - * AAS.
- *
+ * AAS.
+ *
* A submodel is used to structure the digital representation and technical * functionality of an Administration Shell into distinguishable parts. Each * submodel refers to a well-defined domain or subject matter. Submodels can @@ -33,7 +33,7 @@ public interface ISubmodel extends IElement, IHasSemantics, IIdentifiable, IQualifiable, IHasDataSpecification, IHasKind, IElementContainer { /** - * Gets a Map containing the values of all submodelElements + * Gets a {@literal Map} containing the values of all submodelElements * * @return a Map with the values of all submodelElements */ diff --git a/src/main/java/org/eclipse/basyx/submodel/metamodel/api/identifier/IdentifierType.java b/src/main/java/org/eclipse/basyx/submodel/metamodel/api/identifier/IdentifierType.java index 4082a5d5..2775687d 100644 --- a/src/main/java/org/eclipse/basyx/submodel/metamodel/api/identifier/IdentifierType.java +++ b/src/main/java/org/eclipse/basyx/submodel/metamodel/api/identifier/IdentifierType.java @@ -13,9 +13,9 @@ import org.eclipse.basyx.submodel.metamodel.enumhelper.StandardizedLiteralEnumHelper; /** - * Enumeration of different types of Identifiers for global identification
- * Since in Java there is no enum inheritance, it is implemented as class
- *
+ * Enumeration of different types of Identifiers for global identification
+ * Since in Java there is no enum inheritance, it is implemented as class
+ *
* * @author schnicke * diff --git a/src/main/java/org/eclipse/basyx/submodel/metamodel/api/parts/IConceptDescription.java b/src/main/java/org/eclipse/basyx/submodel/metamodel/api/parts/IConceptDescription.java index ab2e2f8e..e75cd689 100644 --- a/src/main/java/org/eclipse/basyx/submodel/metamodel/api/parts/IConceptDescription.java +++ b/src/main/java/org/eclipse/basyx/submodel/metamodel/api/parts/IConceptDescription.java @@ -17,8 +17,8 @@ /** * The semantics of a property or other elements that may have a semantic - * description is defined by a concept description.
- *
+ * description is defined by a concept description.
+ *
* The description of the concept should follow a standardized schema (realized * as data specification template). * diff --git a/src/main/java/org/eclipse/basyx/submodel/metamodel/api/qualifier/IHasSemantics.java b/src/main/java/org/eclipse/basyx/submodel/metamodel/api/qualifier/IHasSemantics.java index daed9c17..08cc1cc7 100644 --- a/src/main/java/org/eclipse/basyx/submodel/metamodel/api/qualifier/IHasSemantics.java +++ b/src/main/java/org/eclipse/basyx/submodel/metamodel/api/qualifier/IHasSemantics.java @@ -21,8 +21,8 @@ public interface IHasSemantics { /** * Gets the identifier of the semantic definition of the element. It is called - * semantic id of the element.
- *
+ * semantic id of the element.
+ *
* The semantic id may either reference an external global id or it may * reference a referable model element of kind=Template that defines the * semantics of the element. diff --git a/src/main/java/org/eclipse/basyx/submodel/metamodel/api/qualifier/IReferable.java b/src/main/java/org/eclipse/basyx/submodel/metamodel/api/qualifier/IReferable.java index 1c589911..2343ffab 100644 --- a/src/main/java/org/eclipse/basyx/submodel/metamodel/api/qualifier/IReferable.java +++ b/src/main/java/org/eclipse/basyx/submodel/metamodel/api/qualifier/IReferable.java @@ -37,8 +37,8 @@ public interface IReferable { public String getCategory(); /** - * Gets the description or comments on the element.
- *
+ * Gets the description or comments on the element.
+ *
* The description can be provided in several languages. * * @return diff --git a/src/main/java/org/eclipse/basyx/submodel/metamodel/api/qualifier/haskind/ModelingKind.java b/src/main/java/org/eclipse/basyx/submodel/metamodel/api/qualifier/haskind/ModelingKind.java index b88a6920..f42d6521 100644 --- a/src/main/java/org/eclipse/basyx/submodel/metamodel/api/qualifier/haskind/ModelingKind.java +++ b/src/main/java/org/eclipse/basyx/submodel/metamodel/api/qualifier/haskind/ModelingKind.java @@ -13,7 +13,7 @@ import org.eclipse.basyx.submodel.metamodel.enumhelper.StandardizedLiteralEnumHelper; /** - * ModelingKind enum as defined by DAAS document
+ * ModelingKind enum as defined by DAAS document
* Enumeration for denoting whether an element is a template or an instance. * * @author schnicke diff --git a/src/main/java/org/eclipse/basyx/submodel/metamodel/api/reference/IKey.java b/src/main/java/org/eclipse/basyx/submodel/metamodel/api/reference/IKey.java index 33eec093..5c6f27c7 100644 --- a/src/main/java/org/eclipse/basyx/submodel/metamodel/api/reference/IKey.java +++ b/src/main/java/org/eclipse/basyx/submodel/metamodel/api/reference/IKey.java @@ -44,7 +44,7 @@ public interface IKey { public String getValue(); /** - * Gets the type of the key value.
+ * Gets the type of the key value.
* In case of idType = idShort local shall be true. In case type=GlobalReference * idType shall not be IdShort. * diff --git a/src/main/java/org/eclipse/basyx/submodel/metamodel/api/reference/IReference.java b/src/main/java/org/eclipse/basyx/submodel/metamodel/api/reference/IReference.java index 936250e1..852a2e39 100644 --- a/src/main/java/org/eclipse/basyx/submodel/metamodel/api/reference/IReference.java +++ b/src/main/java/org/eclipse/basyx/submodel/metamodel/api/reference/IReference.java @@ -13,8 +13,8 @@ /** * Reference to either a model element of the same or another AAs or to an - * external entity.
- *
+ * external entity.
+ *
* A reference is an ordered list of keys, each key referencing an element. The * complete list of keys may for example be concatenated to a path that then * gives unique access to an element or entity. diff --git a/src/main/java/org/eclipse/basyx/submodel/metamodel/api/reference/enums/KeyElements.java b/src/main/java/org/eclipse/basyx/submodel/metamodel/api/reference/enums/KeyElements.java index 15b54014..d8c6347d 100644 --- a/src/main/java/org/eclipse/basyx/submodel/metamodel/api/reference/enums/KeyElements.java +++ b/src/main/java/org/eclipse/basyx/submodel/metamodel/api/reference/enums/KeyElements.java @@ -14,8 +14,8 @@ /** * KeyElements, ReferableElements, IdentifiableElements as defined in DAAS - * document
- *
+ * document
+ *
* Since there's no enum inheritance in Java, all enums are merged into a single * class * diff --git a/src/main/java/org/eclipse/basyx/submodel/metamodel/api/reference/enums/KeyType.java b/src/main/java/org/eclipse/basyx/submodel/metamodel/api/reference/enums/KeyType.java index 9e969a5d..b8f517ec 100644 --- a/src/main/java/org/eclipse/basyx/submodel/metamodel/api/reference/enums/KeyType.java +++ b/src/main/java/org/eclipse/basyx/submodel/metamodel/api/reference/enums/KeyType.java @@ -14,8 +14,8 @@ import org.eclipse.basyx.submodel.metamodel.enumhelper.StandardizedLiteralEnumHelper; /** - * KeyType, LocalKeyType, IdentifierType as defined in DAAS document
- *
+ * KeyType, LocalKeyType, IdentifierType as defined in DAAS document
+ *
* Since there's no enum inheritance in Java, all enums are merged into a single * class * diff --git a/src/main/java/org/eclipse/basyx/submodel/metamodel/api/submodelelement/ICapability.java b/src/main/java/org/eclipse/basyx/submodel/metamodel/api/submodelelement/ICapability.java index ccac1acc..69f8e35f 100644 --- a/src/main/java/org/eclipse/basyx/submodel/metamodel/api/submodelelement/ICapability.java +++ b/src/main/java/org/eclipse/basyx/submodel/metamodel/api/submodelelement/ICapability.java @@ -12,7 +12,7 @@ /** * A capability is the implementation-independent description of the potential * of an asset to achieve a certain effect in the physical or virtual world. - *
+ *
* To be extended according to upcoming releases of Plattform I4.0. * * @author schnicke diff --git a/src/main/java/org/eclipse/basyx/submodel/metamodel/api/submodelelement/ISubmodelElementCollection.java b/src/main/java/org/eclipse/basyx/submodel/metamodel/api/submodelelement/ISubmodelElementCollection.java index 321afdf2..1d400cf0 100644 --- a/src/main/java/org/eclipse/basyx/submodel/metamodel/api/submodelelement/ISubmodelElementCollection.java +++ b/src/main/java/org/eclipse/basyx/submodel/metamodel/api/submodelelement/ISubmodelElementCollection.java @@ -60,4 +60,11 @@ public interface ISubmodelElementCollection extends ISubmodelElement, IElementCo */ @Override public Map getOperations(); + + /** + * Gets a {@literal Map} containing the values of all submodelElements + * + * @return a Map with the values of all submodelElements + */ + public Map getValues(); } diff --git a/src/main/java/org/eclipse/basyx/submodel/metamodel/api/submodelelement/dataelement/IBlob.java b/src/main/java/org/eclipse/basyx/submodel/metamodel/api/submodelelement/dataelement/IBlob.java index 051676c2..62824a5c 100644 --- a/src/main/java/org/eclipse/basyx/submodel/metamodel/api/submodelelement/dataelement/IBlob.java +++ b/src/main/java/org/eclipse/basyx/submodel/metamodel/api/submodelelement/dataelement/IBlob.java @@ -29,7 +29,7 @@ public interface IBlob extends IDataElement { /** * Sets a Base64 encoded value of the BLOB instance of a blob data element. * - * @param bytes + * @param value */ public void setValue(String value); @@ -43,7 +43,7 @@ public interface IBlob extends IDataElement { /** * Sets the value of the Blob instance of a blob data element. * - * @param bytes + * @param value */ public void setByteArrayValue(byte[] value); @@ -63,9 +63,9 @@ public interface IBlob extends IDataElement { /** * Gets the mime type of the content of the BLOB. The mime type states which - * file extension the file has.
+ * file extension the file has.
* Valid values are e.g. “application/json”, “application/xls”, ”image/jpg”. - *
+ *
* The allowed values are defined as in RFC2046. * * @return diff --git a/src/main/java/org/eclipse/basyx/submodel/metamodel/api/submodelelement/dataelement/IRange.java b/src/main/java/org/eclipse/basyx/submodel/metamodel/api/submodelelement/dataelement/IRange.java index c7d5bdbe..889ade78 100644 --- a/src/main/java/org/eclipse/basyx/submodel/metamodel/api/submodelelement/dataelement/IRange.java +++ b/src/main/java/org/eclipse/basyx/submodel/metamodel/api/submodelelement/dataelement/IRange.java @@ -27,8 +27,8 @@ public interface IRange extends IDataElement { ValueType getValueType(); /** - * Returns the minimum value of the range.
- *
+ * Returns the minimum value of the range.
+ *
* If the min value is missing then the value is assumed to be negative * infinite. * @@ -37,8 +37,8 @@ public interface IRange extends IDataElement { Object getMin(); /** - * The maximum value of the range.
- *
+ * The maximum value of the range.
+ *
* If the max value is missing then the value is assumed to be positive * infinite. */ diff --git a/src/main/java/org/eclipse/basyx/submodel/metamodel/api/submodelelement/event/IBasicEvent.java b/src/main/java/org/eclipse/basyx/submodel/metamodel/api/submodelelement/event/IBasicEvent.java index e35022cd..51cd2eea 100644 --- a/src/main/java/org/eclipse/basyx/submodel/metamodel/api/submodelelement/event/IBasicEvent.java +++ b/src/main/java/org/eclipse/basyx/submodel/metamodel/api/submodelelement/event/IBasicEvent.java @@ -12,7 +12,7 @@ import org.eclipse.basyx.submodel.metamodel.api.reference.IReference; /** - * A basic event.
+ * A basic event.
* To be extended according to upcoming releases of Plattform I4.0. * * @author schnicke diff --git a/src/main/java/org/eclipse/basyx/submodel/metamodel/api/submodelelement/event/IEvent.java b/src/main/java/org/eclipse/basyx/submodel/metamodel/api/submodelelement/event/IEvent.java index 9ec86c61..7871fa12 100644 --- a/src/main/java/org/eclipse/basyx/submodel/metamodel/api/submodelelement/event/IEvent.java +++ b/src/main/java/org/eclipse/basyx/submodel/metamodel/api/submodelelement/event/IEvent.java @@ -12,7 +12,7 @@ import org.eclipse.basyx.submodel.metamodel.api.submodelelement.ISubmodelElement; /** - * An event.
+ * An event.
* To be extended according to upcoming releases of Plattform I4.0. * * @author schnicke diff --git a/src/main/java/org/eclipse/basyx/submodel/metamodel/api/submodelelement/operation/IOperation.java b/src/main/java/org/eclipse/basyx/submodel/metamodel/api/submodelelement/operation/IOperation.java index 42d850e9..9ab1b67d 100644 --- a/src/main/java/org/eclipse/basyx/submodel/metamodel/api/submodelelement/operation/IOperation.java +++ b/src/main/java/org/eclipse/basyx/submodel/metamodel/api/submodelelement/operation/IOperation.java @@ -47,25 +47,35 @@ public interface IOperation extends IElement, ISubmodelElement { /** * Invoke operation with given parameter * - * + * @deprecated Please use either {@link #invoke(SubmodelElement...)} for passing + * SubmodelElements or {@link #invokeSimple(Object...)} for directly + * passing values. * @param params - * Operation parameter + * Operation parameter * @return If multiple values are returned, Object is here a list of Objects - * @throws Exception */ + @Deprecated public Object invoke(Object... params); /** * Invoke operation with parameters wrapped as SubmodelElements * * - * @param params + * @param elems * Operation parameters * @return List of results - * @throws Exception */ public SubmodelElement[] invoke(SubmodelElement... elems); + /** + * Invoke operation with raw parameters, i.e. not wrapped as SubmodelElements + * + * @param params + * Raw operation parameters + * @return Raw result + */ + public Object invokeSimple(Object... params); + /** * Invoke operation with given parameter asynchronously * diff --git a/src/main/java/org/eclipse/basyx/submodel/metamodel/connected/submodelelement/ConnectedSubmodelElementCollection.java b/src/main/java/org/eclipse/basyx/submodel/metamodel/connected/submodelelement/ConnectedSubmodelElementCollection.java index 7a5a23d3..343bacc7 100644 --- a/src/main/java/org/eclipse/basyx/submodel/metamodel/connected/submodelelement/ConnectedSubmodelElementCollection.java +++ b/src/main/java/org/eclipse/basyx/submodel/metamodel/connected/submodelelement/ConnectedSubmodelElementCollection.java @@ -118,4 +118,9 @@ public void addSubmodelElement(ISubmodelElement element) { public SubmodelElementCollection getLocalCopy() { return SubmodelElementCollection.createAsFacade(getElem()).getLocalCopy(); } + + @Override + public Map getValues() { + return SubmodelElementCollection.createAsFacade(getElemLive()).getValues(); + } } diff --git a/src/main/java/org/eclipse/basyx/submodel/metamodel/connected/submodelelement/ConnectedSubmodelElementFactory.java b/src/main/java/org/eclipse/basyx/submodel/metamodel/connected/submodelelement/ConnectedSubmodelElementFactory.java index 31b7520d..2deccf4f 100644 --- a/src/main/java/org/eclipse/basyx/submodel/metamodel/connected/submodelelement/ConnectedSubmodelElementFactory.java +++ b/src/main/java/org/eclipse/basyx/submodel/metamodel/connected/submodelelement/ConnectedSubmodelElementFactory.java @@ -107,8 +107,9 @@ public static Collection getElementCollection(VABElementProxy * Creates a connected ISubmodelElement by idShort, proxy and map content * * @param rootProxy proxy for the root element - * @param collectionPath path in the proxy for accessing all elements * @param elementPath path in the proxy for accessing single elements by short ids + * @param idShort + * @param mapContent * @return The connected variant of the requested submodel element */ public static ISubmodelElement getConnectedSubmodelElement(VABElementProxy rootProxy, diff --git a/src/main/java/org/eclipse/basyx/submodel/metamodel/connected/submodelelement/operation/ConnectedOperation.java b/src/main/java/org/eclipse/basyx/submodel/metamodel/connected/submodelelement/operation/ConnectedOperation.java index 4c2e6b3c..13a1fa2f 100644 --- a/src/main/java/org/eclipse/basyx/submodel/metamodel/connected/submodelelement/operation/ConnectedOperation.java +++ b/src/main/java/org/eclipse/basyx/submodel/metamodel/connected/submodelelement/operation/ConnectedOperation.java @@ -12,7 +12,6 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; -import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.UUID; @@ -23,14 +22,13 @@ import org.eclipse.basyx.submodel.metamodel.api.submodelelement.operation.IOperation; import org.eclipse.basyx.submodel.metamodel.api.submodelelement.operation.IOperationVariable; import org.eclipse.basyx.submodel.metamodel.connected.submodelelement.ConnectedSubmodelElement; -import org.eclipse.basyx.submodel.metamodel.map.qualifier.Referable; import org.eclipse.basyx.submodel.metamodel.map.submodelelement.SubmodelElement; -import org.eclipse.basyx.submodel.metamodel.map.submodelelement.dataelement.property.Property; import org.eclipse.basyx.submodel.metamodel.map.submodelelement.operation.Operation; +import org.eclipse.basyx.submodel.metamodel.map.submodelelement.operation.OperationCheckHelper; +import org.eclipse.basyx.submodel.metamodel.map.submodelelement.operation.OperationHelper; import org.eclipse.basyx.submodel.metamodel.map.submodelelement.operation.OperationVariable; import org.eclipse.basyx.submodel.restapi.operation.InvocationRequest; import org.eclipse.basyx.submodel.restapi.operation.InvocationResponse; -import org.eclipse.basyx.vab.exception.provider.WrongNumberOfParametersException; import org.eclipse.basyx.vab.modelprovider.VABElementProxy; /** @@ -67,16 +65,9 @@ public Collection getInOutputVariables() { */ @Override public Object invoke(Object... params) { - // Wrap simple params - SubmodelElement[] wrapper = createElementWrapper(params); - - // Invoke with submodel elements - SubmodelElement[] result = invoke(wrapper); - - // Unwrap result wrapper - return unwrapResult(result); + return invokeSimple(params); } - + @SuppressWarnings("unchecked") @Override public SubmodelElement[] invoke(SubmodelElement... elems) { @@ -89,8 +80,7 @@ public SubmodelElement[] invoke(SubmodelElement... elems) { // Extract the output elements Collection outputArguments = response.getOutputArguments(); - List elements = outputArguments.stream().map(IOperationVariable::getValue) - .collect(Collectors.toList()); + List elements = outputArguments.stream().map(IOperationVariable::getValue).collect(Collectors.toList()); // Cast them to an array SubmodelElement[] result = new SubmodelElement[elements.size()]; @@ -100,8 +90,7 @@ public SubmodelElement[] invoke(SubmodelElement... elems) { private InvocationRequest createInvocationRequest(int timeout, SubmodelElement... elems) { // Wrap parameters in operation variables - Collection inputArguments = Arrays.asList(elems).stream().map(OperationVariable::new) - .collect(Collectors.toList()); + Collection inputArguments = Arrays.asList(elems).stream().map(OperationVariable::new).collect(Collectors.toList()); // Generate random request id String requestId = UUID.randomUUID().toString(); @@ -109,38 +98,15 @@ private InvocationRequest createInvocationRequest(int timeout, SubmodelElement.. return new InvocationRequest(requestId, new ArrayList<>(), inputArguments, timeout); } - private SubmodelElement[] createElementWrapper(Object... params) { - Collection inputVariables = getInputVariables(); - if (inputVariables.size() != params.length) { - throw new WrongNumberOfParametersException(getIdShort(), inputVariables, params); - } - - // Copy parameter values into SubmodelElements according to InputVariables - SubmodelElement[] ret = new SubmodelElement[params.length]; - Iterator iterator = inputVariables.iterator(); - int i = 0; - while (iterator.hasNext()) { - IOperationVariable matchedInput = iterator.next(); - ISubmodelElement inputElement = matchedInput.getValue(); - SubmodelElement copy = inputElement.getLocalCopy(); - copy.setValue(params[i]); - ret[i] = copy; - i++; - } - - return ret; - } - @Override public ConnectedAsyncInvocation invokeAsync(Object... params) { - SubmodelElement[] smElements = createElementWrapper(params); - InvocationRequest request = createInvocationRequest(DEFAULT_ASYNC_TIMEOUT, smElements); - return new ConnectedAsyncInvocation(getProxy(), getIdShort(), request); + return invokeAsyncWithTimeout(DEFAULT_ASYNC_TIMEOUT, params); } - + @Override public ConnectedAsyncInvocation invokeAsyncWithTimeout(int timeout, Object... params) { - SubmodelElement[] smElements = createElementWrapper(params); + OperationCheckHelper.checkValidParameterLength(params.length, getIdShort(), getInputVariables()); + SubmodelElement[] smElements = OperationHelper.wrapParameters(getInputVariables(), params); InvocationRequest request = createInvocationRequest(timeout, smElements); return new ConnectedAsyncInvocation(getProxy(), getIdShort(), request); } @@ -149,7 +115,7 @@ public ConnectedAsyncInvocation invokeAsyncWithTimeout(int timeout, Object... pa protected KeyElements getKeyElement() { return KeyElements.OPERATION; } - + @Override public Object getValue() { throw new UnsupportedOperationException("An Operation has no value"); @@ -159,32 +125,18 @@ public Object getValue() { public void setValue(Object value) { throw new UnsupportedOperationException("An Operation has no value"); } - - @SuppressWarnings("unchecked") - private Object unwrapResult(Object result) { - if (result instanceof Collection) { - Collection coll = (Collection) result; - if (coll.isEmpty()) { - return result; - } - Object resultWrapper = coll.iterator().next(); - if (resultWrapper instanceof Map) { - Map map = (Map) resultWrapper; - if (map.get(Referable.IDSHORT).equals("Response") && map.get(Property.VALUE) != null) { - return map.get(Property.VALUE); - } - } - } else if (result instanceof SubmodelElement[]) { - SubmodelElement[] arr = (SubmodelElement[]) result; - if (arr.length > 0 && arr[0] instanceof Map) { - return arr[0].getValue(); - } - } - return result; - } @Override public Operation getLocalCopy() { return Operation.createAsFacade(getElem()).getLocalCopy(); } + + @Override + public Object invokeSimple(Object... params) { + OperationCheckHelper.checkValidParameterLength(params.length, getIdShort(), getInputVariables()); + OperationCheckHelper.checkSubmodelElementExpectedTypes(params, getInputVariables()); + SubmodelElement[] wrapper = OperationHelper.wrapParameters(getInputVariables(), params); + SubmodelElement[] result = invoke(wrapper); + return OperationHelper.unwrapResult(result); + } } diff --git a/src/main/java/org/eclipse/basyx/submodel/metamodel/facade/ElementContainerValuesHelper.java b/src/main/java/org/eclipse/basyx/submodel/metamodel/facade/ElementContainerValuesHelper.java new file mode 100644 index 00000000..cfbc5c95 --- /dev/null +++ b/src/main/java/org/eclipse/basyx/submodel/metamodel/facade/ElementContainerValuesHelper.java @@ -0,0 +1,67 @@ +/******************************************************************************* + * Copyright (C) 2021 the Eclipse BaSyx Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + ******************************************************************************/ +package org.eclipse.basyx.submodel.metamodel.facade; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; + +import org.eclipse.basyx.submodel.metamodel.api.IElementContainer; +import org.eclipse.basyx.submodel.metamodel.api.submodelelement.ISubmodelElement; +import org.eclipse.basyx.submodel.metamodel.map.qualifier.LangStrings; + +/** + * Helper class for getting the /values Map from a Element Container. + * + * @author conradi, haque + * + */ +public class ElementContainerValuesHelper { + + /** + * Gets the Values from a {@link IElementContainer} + * + * @param container the {@link IElementContainer} to get the values from. + * @return A Map mapping idShort to the value of the SubmodelElement + */ + @SuppressWarnings("unchecked") + public static Map getSubmodelValue(IElementContainer container) { + Map elements = container.getSubmodelElements(); + + return (Map) handleValue(elements.values()); + } + + + @SuppressWarnings("unchecked") + private static Object handleValue(Object value) { + // Check if it is a collection but not a LangStrings (is internally also a Collection) + if (value instanceof Collection && !(value instanceof LangStrings)) { + return handleValueCollection((Collection) value); + } else { + // The value is not a collection -> return it as is + return value; + } + } + + + private static Map handleValueCollection(Collection collection) { + Map ret = new HashMap<>(); + for(ISubmodelElement element: collection) { + try { + ret.put(element.getIdShort(), handleValue(element.getValue())); + } catch (UnsupportedOperationException e) { + // this Element has no value (e.g. an Operation) + // -> just ignore it + } + } + return ret; + } + +} diff --git a/src/main/java/org/eclipse/basyx/submodel/metamodel/facade/SubmodelElementMapCollectionConverter.java b/src/main/java/org/eclipse/basyx/submodel/metamodel/facade/SubmodelElementMapCollectionConverter.java index 56181c11..2c08d932 100644 --- a/src/main/java/org/eclipse/basyx/submodel/metamodel/facade/SubmodelElementMapCollectionConverter.java +++ b/src/main/java/org/eclipse/basyx/submodel/metamodel/facade/SubmodelElementMapCollectionConverter.java @@ -11,10 +11,10 @@ import java.util.Collection; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; - import org.eclipse.basyx.submodel.metamodel.api.submodelelement.ISubmodelElement; import org.eclipse.basyx.submodel.metamodel.facade.submodelelement.SubmodelElementFacadeFactory; import org.eclipse.basyx.submodel.metamodel.map.Submodel; @@ -37,7 +37,7 @@ public class SubmodelElementMapCollectionConverter { /** * Builds a Submodel from a given Map.
- * Converts the Submodel.SUBMODELELEMENT entry of a Map to a Map.
+ * Converts the Submodel.SUBMODELELEMENT entry of a Map to a {@literal Map}.
* Creates Facades for all smElements. * * @param submodel a Map representing the Submodel to be converted. @@ -86,7 +86,7 @@ public static Map smToMap(Submodel submodel) { /** * Builds a SubmodelElementCollection from a given Map.
- * Converts the Property.VALUE entry of a Map to a Map.
+ * Converts the Property.VALUE entry of a Map to a {@literal Map}.
* Creates Facades for all smElements. * * @param smECollection a Map representing the SubmodelElementCollection to be converted. @@ -130,14 +130,14 @@ public static Map smElementToMap(Map smElement) /** - * Converts a given smElement Collection/Map to a Map. + * Converts a given smElement Collection/Map to a {@literal Map}. * * @param smElements the smElements to be converted - * @return a Map + * @return a {@literal Map} */ @SuppressWarnings("unchecked") public static Map convertCollectionToIDMap(Object smElements) { - Map smElementsMap = new HashMap<>(); + Map smElementsMap = new LinkedHashMap<>(); if(smElements == null) { // if null was given, return an empty Map @@ -168,10 +168,10 @@ public static Map convertCollectionToIDMap(Object smElements) { /** - * Converts a given Map to a smElement Collection. + * Converts a given {@literal Map} to a smElement Collection. * - * @param smElements the smElements to be converted - * @return Collection + * @param map the map to be converted + * @return {@literal Collection} */ @SuppressWarnings("unchecked") public static Collection> convertIDMapToCollection(Object map) { diff --git a/src/main/java/org/eclipse/basyx/submodel/metamodel/facade/SubmodelFacadeCustomSemantics.java b/src/main/java/org/eclipse/basyx/submodel/metamodel/facade/SubmodelFacadeCustomSemantics.java index 5f8053a5..21410126 100644 --- a/src/main/java/org/eclipse/basyx/submodel/metamodel/facade/SubmodelFacadeCustomSemantics.java +++ b/src/main/java/org/eclipse/basyx/submodel/metamodel/facade/SubmodelFacadeCustomSemantics.java @@ -48,7 +48,7 @@ public SubmodelFacadeCustomSemantics() { /** * Sub model constructor for sub models that conform to a globally defined - * semantics with Custom semantics
+ * semantics with Custom semantics
* * Create an instance sub model with all meta properties empty / set to default * values @@ -93,7 +93,7 @@ public SubmodelFacadeCustomSemantics(String semantics, IdentifierType idType, St /** * Sub model constructor for sub models that conform to a globally defined - * semantics with Custom semantics
+ * semantics with Custom semantics
* * Create an instance sub model with all meta properties empty / set to default * values diff --git a/src/main/java/org/eclipse/basyx/submodel/metamodel/facade/SubmodelFacadeIRDISemantics.java b/src/main/java/org/eclipse/basyx/submodel/metamodel/facade/SubmodelFacadeIRDISemantics.java index e4579a17..017eb70b 100644 --- a/src/main/java/org/eclipse/basyx/submodel/metamodel/facade/SubmodelFacadeIRDISemantics.java +++ b/src/main/java/org/eclipse/basyx/submodel/metamodel/facade/SubmodelFacadeIRDISemantics.java @@ -92,7 +92,7 @@ public SubmodelFacadeIRDISemantics(String semantics, IdentifierType idType, Stri /** * Sub model constructor for sub models that conform to a globally defined - * semantics with IRDI (International Registration Data Identifier)
+ * semantics with IRDI (International Registration Data Identifier)
* Create an instance sub model with all meta properties empty / set to default * values * diff --git a/src/main/java/org/eclipse/basyx/submodel/metamodel/facade/SubmodelValuesHelper.java b/src/main/java/org/eclipse/basyx/submodel/metamodel/facade/SubmodelValuesHelper.java index 8bf2b524..58fde0ac 100644 --- a/src/main/java/org/eclipse/basyx/submodel/metamodel/facade/SubmodelValuesHelper.java +++ b/src/main/java/org/eclipse/basyx/submodel/metamodel/facade/SubmodelValuesHelper.java @@ -1,67 +1,26 @@ /******************************************************************************* - * Copyright (C) 2021 the Eclipse BaSyx Authors - * - * This program and the accompanying materials are made - * available under the terms of the Eclipse Public License 2.0 - * which is available at https://www.eclipse.org/legal/epl-2.0/ - * - * SPDX-License-Identifier: EPL-2.0 - ******************************************************************************/ -package org.eclipse.basyx.submodel.metamodel.facade; +* Copyright (C) 2021 the Eclipse BaSyx Authors +* +* This program and the accompanying materials are made +* available under the terms of the Eclipse Public License 2.0 +* which is available at https://www.eclipse.org/legal/epl-2.0/ -import java.util.Collection; -import java.util.HashMap; -import java.util.Map; +* +* SPDX-License-Identifier: EPL-2.0 +******************************************************************************/ -import org.eclipse.basyx.submodel.metamodel.api.submodelelement.ISubmodelElement; -import org.eclipse.basyx.submodel.metamodel.map.Submodel; -import org.eclipse.basyx.submodel.metamodel.map.qualifier.LangStrings; +package org.eclipse.basyx.submodel.metamodel.facade; /** * Helperclass for getting the /values Map from a Submodel. * + * This class is deprecated and replaces by {@link ElementContainerValuesHelper} + * Please use {@link ElementContainerValuesHelper} instead of this + * * @author conradi * */ -public class SubmodelValuesHelper { - - /** - * Gets the Values from a Submodel - * - * @param sm the Submodel to get the values from. - * @return A Map mapping idShort to the value of the SubmodelElement - */ - @SuppressWarnings("unchecked") - public static Map getSubmodelValue(Submodel sm) { - Map elements = sm.getSubmodelElements(); - - return (Map) handleValue(elements.values()); - } - - - @SuppressWarnings("unchecked") - private static Object handleValue(Object value) { - // Check if it is a collection but not a LangStrings (is internally also a Collection) - if (value instanceof Collection && !(value instanceof LangStrings)) { - return handleValueCollection((Collection) value); - } else { - // The value is not a collection -> return it as is - return value; - } - } - - - private static Map handleValueCollection(Collection collection) { - Map ret = new HashMap<>(); - for(ISubmodelElement element: collection) { - try { - ret.put(element.getIdShort(), handleValue(element.getValue())); - } catch (UnsupportedOperationException e) { - // this Element has no value (e.g. an Operation) - // -> just ignore it - } - } - return ret; - } +@Deprecated +public class SubmodelValuesHelper extends ElementContainerValuesHelper { } diff --git a/src/main/java/org/eclipse/basyx/submodel/metamodel/facade/submodelelement/SubmodelElementFacadeFactory.java b/src/main/java/org/eclipse/basyx/submodel/metamodel/facade/submodelelement/SubmodelElementFacadeFactory.java index b4dc4895..5abbf257 100644 --- a/src/main/java/org/eclipse/basyx/submodel/metamodel/facade/submodelelement/SubmodelElementFacadeFactory.java +++ b/src/main/java/org/eclipse/basyx/submodel/metamodel/facade/submodelelement/SubmodelElementFacadeFactory.java @@ -37,7 +37,7 @@ public class SubmodelElementFacadeFactory { /** * Takes a Map and creates the corresponding SubmodelElement as facade * - * @param smElement a Map containing the information of a SubmodelElement + * @param submodelElement a Map containing the information of a SubmodelElement * @return the actual of the given SubmodelElement map created as facade */ public static ISubmodelElement createSubmodelElement(Map submodelElement) { diff --git a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/Submodel.java b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/Submodel.java index d3d23747..ce10f789 100644 --- a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/Submodel.java +++ b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/Submodel.java @@ -32,7 +32,7 @@ import org.eclipse.basyx.submodel.metamodel.api.submodelelement.dataelement.IProperty; import org.eclipse.basyx.submodel.metamodel.api.submodelelement.operation.IOperation; import org.eclipse.basyx.submodel.metamodel.facade.SubmodelElementMapCollectionConverter; -import org.eclipse.basyx.submodel.metamodel.facade.SubmodelValuesHelper; +import org.eclipse.basyx.submodel.metamodel.facade.ElementContainerValuesHelper; import org.eclipse.basyx.submodel.metamodel.map.helper.ElementContainerHelper; import org.eclipse.basyx.submodel.metamodel.map.modeltype.ModelType; import org.eclipse.basyx.submodel.metamodel.map.qualifier.AdministrativeInformation; @@ -50,8 +50,8 @@ /** * A submodel defines a specific aspect of the asset represented by the AAS. - *
- *
+ *
+ *
* A submodel is used to structure the digital representation and technical * functionality of an Administration Shell into distinguishable parts. Each * submodel refers to a well-defined domain or subject matter. Submodels can @@ -316,7 +316,7 @@ public Map getSubmodelElements() { @Override public Map getValues() { - return SubmodelValuesHelper.getSubmodelValue(this); + return ElementContainerValuesHelper.getSubmodelValue(this); } @Override diff --git a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/dataspecification/DataSpecificationContent.java b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/dataspecification/DataSpecificationContent.java index d1a43b44..9c8faac5 100644 --- a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/dataspecification/DataSpecificationContent.java +++ b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/dataspecification/DataSpecificationContent.java @@ -18,7 +18,7 @@ public class DataSpecificationContent extends VABModelMap implements IDa /** * Creates a DataSpecificationContent object from a map * - * @param obj + * @param map * a DataSpecificationContent object as raw map * @return a DataSpecificationContent object, that behaves like a facade for the given map */ diff --git a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/dataspecification/DataSpecificationIEC61360Content.java b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/dataspecification/DataSpecificationIEC61360Content.java index d39e8774..154d3dc6 100644 --- a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/dataspecification/DataSpecificationIEC61360Content.java +++ b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/dataspecification/DataSpecificationIEC61360Content.java @@ -74,7 +74,7 @@ public DataSpecificationIEC61360Content(LangStrings preferredName, LangStrings s /** * Creates a DataSpecificationIEC61360 object from a map * - * @param obj + * @param map * a DataSpecificationIEC61360 object as raw map * @return a DataSpecificationIEC61360 object, that behaves like a facade for * the given map diff --git a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/dataspecification/EmbeddedDataSpecification.java b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/dataspecification/EmbeddedDataSpecification.java index 2a0e4a48..aad99f5d 100644 --- a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/dataspecification/EmbeddedDataSpecification.java +++ b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/dataspecification/EmbeddedDataSpecification.java @@ -25,7 +25,7 @@ public class EmbeddedDataSpecification extends VABModelMap implements IE /** * Creates a EmbeddedDataSpecification object from a map * - * @param obj + * @param map * a EmbeddedDataSpecification object as raw map * @return a EmbeddedDataSpecification object, that behaves like a facade for the given map */ diff --git a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/dataspecification/ValueReferencePair.java b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/dataspecification/ValueReferencePair.java index d73393e0..d3668441 100644 --- a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/dataspecification/ValueReferencePair.java +++ b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/dataspecification/ValueReferencePair.java @@ -41,9 +41,8 @@ public ValueReferencePair() {} * Constructs a reference based on an {@link IIdentifiable} and additional * information (see {@link Key#Key(KeyElements, boolean, String, KeyType)}). * - * @param identifiable - * @param keyElement - * @param local + * @param value + * @param valueId */ public ValueReferencePair(String value, IReference valueId) { setValue(value); @@ -53,7 +52,7 @@ public ValueReferencePair(String value, IReference valueId) { /** * Creates a ValueReferencePair object from a map * - * @param obj + * @param map * a ValueReferencePair object as raw map * @return a ValueReferencePair object, that behaves like a facade for the given map */ diff --git a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/identifier/Identifier.java b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/identifier/Identifier.java index 7eddd66b..a1b77a36 100644 --- a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/identifier/Identifier.java +++ b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/identifier/Identifier.java @@ -39,7 +39,7 @@ public Identifier() { /** * Creates a Identifier object from a map * - * @param obj + * @param map * a Identifier object as raw map * @return a Identifier object, that behaves like a facade for the given map */ diff --git a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/modeltype/ModelType.java b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/modeltype/ModelType.java index d6cf8ed2..40c22e60 100644 --- a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/modeltype/ModelType.java +++ b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/modeltype/ModelType.java @@ -16,7 +16,7 @@ /** * Describes the type of the used model and is used to add a model type to - * existing AAS meta model entries.
+ * existing AAS meta model entries.
* Needed for C# compatibility * * @author schnicke @@ -38,7 +38,7 @@ private ModelType() { /** * Creates a DataSpecificationIEC61360 object from a map * - * @param obj + * @param map * a DataSpecificationIEC61360 object as raw map * @return a DataSpecificationIEC61360 object, that behaves like a facade for * the given map diff --git a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/parts/ConceptDescription.java b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/parts/ConceptDescription.java index a2475e0b..9953142e 100644 --- a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/parts/ConceptDescription.java +++ b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/parts/ConceptDescription.java @@ -67,7 +67,7 @@ public ConceptDescription(String idShort, IIdentifier identification) { /** * Creates a DataSpecificationIEC61360 object from a map * - * @param obj + * @param map * a DataSpecificationIEC61360 object as raw map * @return a DataSpecificationIEC61360 object, that behaves like a facade for * the given map diff --git a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/qualifier/AdministrativeInformation.java b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/qualifier/AdministrativeInformation.java index e654e3e0..bc571dfc 100644 --- a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/qualifier/AdministrativeInformation.java +++ b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/qualifier/AdministrativeInformation.java @@ -52,7 +52,7 @@ public AdministrativeInformation(String version, String revision) { /** * Creates a AdministrativeInformation object from a map * - * @param obj + * @param map * a AdministrativeInformation object as raw map * @return a AdministrativeInformation object, that behaves like a facade for * the given map diff --git a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/qualifier/HasDataSpecification.java b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/qualifier/HasDataSpecification.java index aaa624af..6a851789 100644 --- a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/qualifier/HasDataSpecification.java +++ b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/qualifier/HasDataSpecification.java @@ -50,7 +50,7 @@ public HasDataSpecification(Collection embedded, Col /** * Creates a DataSpecificationIEC61360 object from a map * - * @param obj + * @param map * a DataSpecificationIEC61360 object as raw map * @return a DataSpecificationIEC61360 object, that behaves like a facade for * the given map diff --git a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/qualifier/HasSemantics.java b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/qualifier/HasSemantics.java index e835cb60..dfe20177 100644 --- a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/qualifier/HasSemantics.java +++ b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/qualifier/HasSemantics.java @@ -40,7 +40,7 @@ public HasSemantics(IReference ref) { /** * Creates a HasSemantics object from a map * - * @param obj + * @param map * a HasSemantics object as raw map * @return a HasSemantics object, that behaves like a facade for the given map */ diff --git a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/qualifier/Identifiable.java b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/qualifier/Identifiable.java index e0b8e431..902d2f28 100644 --- a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/qualifier/Identifiable.java +++ b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/qualifier/Identifiable.java @@ -67,7 +67,7 @@ public Identifiable(String version, String revision, String idShort, String cate /** * Creates a Identifiable object from a map * - * @param obj + * @param map * a Identifiable object as raw map * @return a Identifiable object, that behaves like a facade for the given map */ diff --git a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/qualifier/LangString.java b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/qualifier/LangString.java index f1f84be0..2098549e 100644 --- a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/qualifier/LangString.java +++ b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/qualifier/LangString.java @@ -39,7 +39,7 @@ public LangString(String language, String description) { /** * Creates a LangString object from a map * - * @param obj a LangString object as raw map + * @param map a LangString object as raw map * @return a LangString object, that behaves like a facade for * the given map */ diff --git a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/qualifier/LangStrings.java b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/qualifier/LangStrings.java index c3782d18..b6975794 100644 --- a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/qualifier/LangStrings.java +++ b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/qualifier/LangStrings.java @@ -15,7 +15,7 @@ import java.util.Set; /** - * This Class is a List, which holds LangString Objects
+ * This Class is a List, which holds LangString Objects
* It is used to hold a text in multiple languages * * @author conradi, haque @@ -58,7 +58,7 @@ public LangStrings(Collection langStrings) { /** * Creates a LangStrings object from a collection of map * - * @param obj a LangStrings object as raw collection of map + * @param maps a LangStrings object as raw collection of map * @return a LangStrings object, that behaves like a facade for * the given map */ @@ -75,6 +75,42 @@ public static LangStrings createAsFacade(Collection> maps) { return ret; } + /** + * QoL method which creates a LangStrings with the specified strings. The strings must + * be given in pairs, such that the first string is the language code and the second is the text. + * + *

+ * Examples: + * + *

+	 * {@code
+	 * // Creates a LangStrings with two languages: 
+	 * LangStrings ls1 = LangStrings.fromStringPairs("en", "Manual", "de", "Betriebsanleitung");
+	 * 
+	 * // Throws an exception:
+	 * LangStrings ls2 = LangStrings.fromStringPairs("en");
+	 * }
+	 * 
+ * + * @param strings A even-numbered set of strings where every pair of two strings describes one + * LangString. + * + * @return A new instance of LangStrings. + * + * @throws IllegalArgumentException if strings contains an odd number of elements. + */ + public static LangStrings fromStringPairs(String... strings) { + if ((strings.length % 2) == 1) { + throw new IllegalArgumentException("strings must have an even number of items."); + } + + LangStrings result = new LangStrings(); + for (int i = 0; i < strings.length; i = i + 2) { + result.add(new LangString(strings[i], strings[i + 1])); + } + return result; + } + @SuppressWarnings("unchecked") public static boolean isLangStrings(Object value) { if(!(value instanceof Collection)) { @@ -89,7 +125,7 @@ public static boolean isLangStrings(Object value) { /** * * @param language - * @return The String for the specified language or
+ * @return The String for the specified language or
* an empty String if no matching LangString is found */ public String get(String language) { diff --git a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/qualifier/Referable.java b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/qualifier/Referable.java index 329ad4e4..3f387c31 100644 --- a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/qualifier/Referable.java +++ b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/qualifier/Referable.java @@ -86,7 +86,7 @@ public Referable(String idShort, String category, LangStrings description) { /** * Creates a Referable object from a map * - * @param obj + * @param map * a Referable object as raw map * @return a Referable object, that behaves like a facade for the given map */ diff --git a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/qualifier/haskind/HasKind.java b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/qualifier/haskind/HasKind.java index a1c535fc..6377bc2f 100644 --- a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/qualifier/haskind/HasKind.java +++ b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/qualifier/haskind/HasKind.java @@ -31,8 +31,7 @@ public HasKind() {} /** * Constructor that takes - * {@link org.eclipse.basyx.submodel.metamodel.map.qualifier.haskind.Kind - * Kind}(either Kind.Instance or Kind.Type) + * {@link ModelingKind} (either Kind.Instance or Kind.Type) */ public HasKind(ModelingKind kind) { // Kind of the element: either type or instance. @@ -43,7 +42,7 @@ public HasKind(ModelingKind kind) { /** * Creates a HasKind object from a map * - * @param obj + * @param map * a HasKind object as raw map * @return a HasKind object, that behaves like a facade for the given map */ diff --git a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/qualifier/qualifiable/Formula.java b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/qualifier/qualifiable/Formula.java index 9865766d..5c794aa6 100644 --- a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/qualifier/qualifiable/Formula.java +++ b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/qualifier/qualifiable/Formula.java @@ -54,7 +54,7 @@ public Formula(Collection dependsOn) { /** * Creates a Formula object from a map * - * @param obj + * @param map * a Formula object as raw map * @return a Formula object, that behaves like a facade for the given map */ diff --git a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/qualifier/qualifiable/Qualifiable.java b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/qualifier/qualifiable/Qualifiable.java index d3b1c7f6..248ef622 100644 --- a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/qualifier/qualifiable/Qualifiable.java +++ b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/qualifier/qualifiable/Qualifiable.java @@ -58,7 +58,7 @@ public Qualifiable(Collection qualifiers) { /** * Creates a Qualifiable object from a map * - * @param obj + * @param map * a Qualifiable object as raw map * @return a Qualifiable object, that behaves like a facade for the given map */ diff --git a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/qualifier/qualifiable/Qualifier.java b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/qualifier/qualifiable/Qualifier.java index 41720c79..1aaf8ca4 100644 --- a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/qualifier/qualifiable/Qualifier.java +++ b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/qualifier/qualifiable/Qualifier.java @@ -67,7 +67,7 @@ public Qualifier(String type, String value, String valueType, Reference valueId) /** * Creates a Qualifier object from a map * - * @param obj + * @param map * a Qualifier object as raw map * @return a Qualifier object, that behaves like a facade for the given map */ diff --git a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/reference/Key.java b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/reference/Key.java index 9424e616..392d1de4 100644 --- a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/reference/Key.java +++ b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/reference/Key.java @@ -19,8 +19,8 @@ import org.eclipse.basyx.vab.model.VABModelMap; /** - * Key as defined in DAAS document
- *
+ * Key as defined in DAAS document
+ *
* A key is a reference to an element by its id. * * @author schnicke @@ -53,12 +53,11 @@ public Key(KeyElements type, boolean local, String value, KeyType idType) { } /** - * Helper constructor to translate IdentifierType to KeyType.
+ * Helper constructor to translate IdentifierType to KeyType.
* In the meta model KeyType inheritcs from IdentifiertType, however Java does * not support enum inheritance * * @param type - * @param local * @param value * @param idType */ @@ -76,7 +75,7 @@ private Key() { /** * Creates a Key object from a map * - * @param obj + * @param map * a Key object as raw map * @return a Key object, that behaves like a facade for the given map */ diff --git a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/reference/Reference.java b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/reference/Reference.java index 23502c0a..203c5eb2 100644 --- a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/reference/Reference.java +++ b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/reference/Reference.java @@ -25,8 +25,8 @@ import org.eclipse.basyx.vab.model.VABModelMap; /** - * Reference as described by DAAS document
- *
+ * Reference as described by DAAS document
+ *
* Reference to either a model element of the same or another AAS or to an * external entity. A reference is an ordered list of keys, each key referencing * an element. The complete list of keys may for example be concatenated to a @@ -51,7 +51,6 @@ public Reference() { * * @param identifiable * @param keyElement - * @param local */ public Reference(IIdentifiable identifiable, KeyElements keyElement, boolean local) { this(identifiable.getIdentification(), keyElement, local); @@ -61,7 +60,7 @@ public Reference(IIdentifiable identifiable, KeyElements keyElement, boolean loc * Constructs a reference based on an {@link IIdentifier} and additional * information (see {@link Key#Key(KeyElements, boolean, String, KeyType)}). * - * @param identifiable + * @param identifier * @param keyElement * @param local */ @@ -71,7 +70,7 @@ public Reference(IIdentifier identifier, KeyElements keyElement, boolean local) /** * - * @param key Unique reference in its name space. + * @param keys Unique reference in its name space. */ public Reference(List keys) { setKeys(keys); @@ -89,7 +88,7 @@ public Reference(Key key) { /** * Creates a Reference object from a map * - * @param obj + * @param map * a Reference object as raw map * @return a Reference object, that behaves like a facade for the given map */ @@ -130,7 +129,7 @@ public static boolean isValid(Map map) { * Creates a Reference object from a map * without checking mandatory attributes present * - * @param obj + * @param map * a Reference object as raw map * @return a Reference object, that behaves like a facade for the given map */ diff --git a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/reference/ReferenceHelper.java b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/reference/ReferenceHelper.java index de82c047..13614f9d 100644 --- a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/reference/ReferenceHelper.java +++ b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/reference/ReferenceHelper.java @@ -24,9 +24,9 @@ public class ReferenceHelper { /** * Helper method used e.g. by facades to transform a set of maps to a set of - * IReference -> Assumes the given object is a Collection> + * {@literal IReference -> Assumes the given object is a Collection>} * - * @param set + * @param obj * @return */ public static Collection transform(Object obj) { diff --git a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/submodelelement/SubmodelElementCollection.java b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/submodelelement/SubmodelElementCollection.java index df8a35bb..0be99704 100644 --- a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/submodelelement/SubmodelElementCollection.java +++ b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/submodelelement/SubmodelElementCollection.java @@ -23,6 +23,7 @@ import org.eclipse.basyx.submodel.metamodel.api.submodelelement.ISubmodelElementCollection; import org.eclipse.basyx.submodel.metamodel.api.submodelelement.dataelement.IProperty; import org.eclipse.basyx.submodel.metamodel.api.submodelelement.operation.IOperation; +import org.eclipse.basyx.submodel.metamodel.facade.ElementContainerValuesHelper; import org.eclipse.basyx.submodel.metamodel.facade.SubmodelElementMapCollectionConverter; import org.eclipse.basyx.submodel.metamodel.map.helper.ElementContainerHelper; import org.eclipse.basyx.submodel.metamodel.map.modeltype.ModelType; @@ -33,7 +34,7 @@ import org.eclipse.basyx.submodel.metamodel.map.submodelelement.operation.Operation; /** - * SubmodelElementCollection as defined by DAAS document
+ * SubmodelElementCollection as defined by DAAS document
* A submodel element collection is a set or list of submodel elements * * @author schnicke @@ -285,4 +286,9 @@ public SubmodelElementCollection getLocalCopy() { copy.setValue(clonedValue); return copy; } + + @Override + public Map getValues() { + return ElementContainerValuesHelper.getSubmodelValue(this); + } } diff --git a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/submodelelement/dataelement/Blob.java b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/submodelelement/dataelement/Blob.java index 2926c821..c775a44d 100644 --- a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/submodelelement/dataelement/Blob.java +++ b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/submodelelement/dataelement/Blob.java @@ -20,7 +20,7 @@ import org.eclipse.basyx.submodel.metamodel.map.submodelelement.dataelement.property.Property; /** - * A blob element as defined in DAAS document
+ * A blob element as defined in DAAS document
* * @author pschorn, schnicke * diff --git a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/submodelelement/dataelement/File.java b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/submodelelement/dataelement/File.java index 8ebde4f5..7b0503bd 100644 --- a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/submodelelement/dataelement/File.java +++ b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/submodelelement/dataelement/File.java @@ -18,7 +18,7 @@ import org.eclipse.basyx.submodel.metamodel.map.submodelelement.dataelement.property.Property; /** - * A blob property as defined in DAAS document
+ * A blob property as defined in DAAS document
* * @author pschorn, schnicke * @@ -45,7 +45,7 @@ public File(String mimeType) { } /** - * Creates a file data element. It has to have a mimeType
+ * Creates a file data element. It has to have a mimeType
* An absolute path is used in the case that the file exists independently of * the AAS. A relative path, relative to the package root should be used if the * file is part of the serialized package of the AAS. diff --git a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/submodelelement/dataelement/ReferenceElement.java b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/submodelelement/dataelement/ReferenceElement.java index 52e2c526..65e342b4 100644 --- a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/submodelelement/dataelement/ReferenceElement.java +++ b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/submodelelement/dataelement/ReferenceElement.java @@ -20,7 +20,7 @@ import org.eclipse.basyx.submodel.metamodel.map.submodelelement.dataelement.property.Property; /** - * A ReferenceElement as defined in DAAS document
+ * A ReferenceElement as defined in DAAS document
* A reference element is a data element that defines a reference to another * element within the same or another AAS or a reference to an external object * or entity. diff --git a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/submodelelement/dataelement/property/valuetype/ValueType.java b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/submodelelement/dataelement/property/valuetype/ValueType.java index 6377fd4d..b5dea922 100644 --- a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/submodelelement/dataelement/property/valuetype/ValueType.java +++ b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/submodelelement/dataelement/property/valuetype/ValueType.java @@ -13,8 +13,8 @@ import org.eclipse.basyx.submodel.metamodel.enumhelper.StandardizedLiteralEnumHelper; /** - * Helper enum to handle anySimpleTypeDef as defined in DAAS document
- * Represents the type of a data entry
+ * Helper enum to handle anySimpleTypeDef as defined in DAAS document
+ * Represents the type of a data entry
* TODO: Extend this to support rest of types (cf. p. 58) * * @author schnicke diff --git a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/submodelelement/dataelement/property/valuetype/ValueTypeHelper.java b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/submodelelement/dataelement/property/valuetype/ValueTypeHelper.java index 4da0bd47..b1f073ef 100644 --- a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/submodelelement/dataelement/property/valuetype/ValueTypeHelper.java +++ b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/submodelelement/dataelement/property/valuetype/ValueTypeHelper.java @@ -24,8 +24,8 @@ /** * Provides utility functions for * {@link org.eclipse.basyx.submodel.metamodel.map.submodelelement.dataelement.property.valuetype.ValueType - * PropertyValueTypeDef}
- * * Creating a PropertyValueTypeDef from name
+ * PropertyValueTypeDef}
+ * * Creating a PropertyValueTypeDef from name
* * Creating a PropertyValueTypeDef for an object * * @author schnicke diff --git a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/submodelelement/operation/AsyncInvocation.java b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/submodelelement/operation/AsyncInvocation.java index 193818c5..ca459f55 100644 --- a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/submodelelement/operation/AsyncInvocation.java +++ b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/submodelelement/operation/AsyncInvocation.java @@ -14,7 +14,6 @@ import java.util.concurrent.TimeUnit; import java.util.function.Function; -import org.apache.poi.ss.formula.functions.T; import org.eclipse.basyx.submodel.metamodel.api.submodelelement.operation.IAsyncInvocation; /** @@ -29,7 +28,10 @@ public class AsyncInvocation implements IAsyncInvocation { private String operationId; private CompletableFuture future; + + // This variable is used to write the result of the function into private Object result; + private RuntimeException exception; @SuppressWarnings("unchecked") @@ -37,6 +39,12 @@ public AsyncInvocation(Operation operation, int timeout, Object... parameters) { operationId = operation.getIdShort(); Function invokable = (Function) operation.get(Operation.INVOKABLE); + + // This future executes the given function and a timeout future. + // It finishes when either the timeout is over or the function finishes. + + // The result of the function is written into the "result" variable + // as it can not be returned through the Future due to the timeout check future = CompletableFuture.supplyAsync( // Run Operation asynchronously () -> invokable.apply(parameters)) @@ -60,8 +68,8 @@ public AsyncInvocation(Operation operation, int timeout, Object... parameters) { /** * Function for scheduling a timeout function with completable futures */ - private CompletableFuture setTimeout(int timeout) { - CompletableFuture timeoutFuture = new CompletableFuture<>(); + private CompletableFuture setTimeout(int timeout) { + CompletableFuture timeoutFuture = new CompletableFuture<>(); delayer.schedule( () -> timeoutFuture.completeExceptionally( new OperationExecutionTimeoutException("Operation " + operationId + " timed out")), @@ -72,11 +80,13 @@ private CompletableFuture setTimeout(int timeout) { @Override public Object getResult() { try { + // The future itself always returns null () + // Actual return value is written into result variable inside future future.get(); } catch (Exception e) { // Some RuntimeException occured when finishing the future throw new OperationExecutionErrorException( - "Exception while executing Operation Operation '" + operationId + "'", e.getCause()); + "Exception while executing Operation '" + operationId + "'", e.getCause()); } if (exception instanceof OperationExecutionTimeoutException || exception instanceof OperationExecutionErrorException) { diff --git a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/submodelelement/operation/Operation.java b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/submodelelement/operation/Operation.java index 1568c825..75e08812 100644 --- a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/submodelelement/operation/Operation.java +++ b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/submodelelement/operation/Operation.java @@ -12,7 +12,9 @@ import java.util.ArrayList; import java.util.Collection; import java.util.Map; +import java.util.function.Consumer; import java.util.function.Function; +import java.util.function.Supplier; import org.eclipse.basyx.aas.metamodel.exception.MetamodelConstructionException; import org.eclipse.basyx.submodel.metamodel.api.reference.IReference; @@ -25,10 +27,10 @@ import org.eclipse.basyx.submodel.metamodel.map.qualifier.LangStrings; import org.eclipse.basyx.submodel.metamodel.map.qualifier.Referable; import org.eclipse.basyx.submodel.metamodel.map.submodelelement.SubmodelElement; -import org.eclipse.basyx.vab.exception.provider.WrongNumberOfParametersException; +import org.eclipse.basyx.vab.modelprovider.lambda.VABLambdaProvider; /** - * Operation as defined in DAAS document
+ * Operation as defined in DAAS document
* An operation is a submodel element with input and output variables. * * @author schnicke @@ -44,43 +46,40 @@ public class Operation extends SubmodelElement implements IOperation { public static final String INVOKABLE = "invokable"; public static final String MODELTYPE = "Operation"; - + + // Extension of DAAS specification for function storage public static final String INVOKE = "invoke"; + public static final String IS_WRAPPED_INVOKABLE = "isWrappedInvokable"; - /** - * Constructor - */ public Operation() { - // Add model type - putAll(new ModelType(MODELTYPE)); - - // Input variables - put(IN, new ArrayList()); - - // Output variables - put(OUT, new ArrayList()); - - // Variables, that are input and output - put(INOUT, new ArrayList()); + this(new ArrayList<>(), new ArrayList<>(), new ArrayList<>()); } - + /** * Constructor accepting only mandatory attribute + * * @param idShort */ public Operation(String idShort) { - super(idShort); - // Add model type - putAll(new ModelType(MODELTYPE)); - - // Input variables - setInputVariables(new ArrayList()); - - // Output variables - setOutputVariables(new ArrayList()); + this(); + setIdShort(idShort); + } - // Variables, that are input and output - setInOutputVariables(new ArrayList()); + /** + * @param in + * Input parameter of the operation. + * @param out + * Output parameter of the operation. + * @param inout + * Inoutput parameter of the operation. + * + */ + public Operation(Collection in, Collection out, Collection inout) { + super(); + putAll(new ModelType(MODELTYPE)); + setInputVariables(in); + setOutputVariables(out); + setInOutputVariables(inout); } /** @@ -92,33 +91,18 @@ public Operation(String idShort) { * @param inout * Inoutput parameter of the operation. * @param function - * the concrete function + * the concrete function that can directly handle unwrapped values * */ - public Operation(Collection in, Collection out, - Collection inout, Function function) { - // Add model type - putAll(new ModelType(MODELTYPE)); - - // Input variables - put(IN, in); - - // Output variables - put(OUT, out); - - // Output variables - put(INOUT, inout); - - // Extension of DAAS specification for function storage - put(INVOKABLE, function); + public Operation(Collection in, Collection out, Collection inout, Function function) { + this(in, out, inout); + setInvokable(function); } /** * Create Operations w/o endpoint * - * @param operation * @param function - * @return */ public Operation(Function function) { this(); @@ -136,19 +120,19 @@ public static Operation createAsFacade(Map obj) { if (obj == null) { return null; } - + if (!isValid(obj)) { throw new MetamodelConstructionException(Operation.class, obj); } - + Operation ret = new Operation(); ret.setMap(obj); return ret; } - + /** - * Check whether all mandatory elements for the metamodel - * exist in a map + * Checks whether all mandatory elements for the metamodel exist in a map + * * @return true/false */ public static boolean isValid(Map obj) { @@ -160,16 +144,16 @@ public static boolean isValid(Map obj) { */ @SuppressWarnings("unchecked") public static boolean isOperation(Object value) { - if(!(value instanceof Map)) { + if (!(value instanceof Map)) { return false; } - + Map map = (Map) value; - + String modelType = ModelType.createAsFacade(map).getName(); - // Either model type is set or the element type specific attributes are contained - return MODELTYPE.equals(modelType) - || (modelType == null && (map.containsKey(IN) && map.containsKey(OUT) && map.containsKey(INOUT))); + // Either model type is set or the element type specific attributes are + // contained + return MODELTYPE.equals(modelType) || (modelType == null && (map.containsKey(IN) && map.containsKey(OUT) && map.containsKey(INOUT))); } @Override @@ -187,33 +171,35 @@ public Collection getInOutputVariables() { return transformToOperationVariables(get(Operation.INOUT)); } - @SuppressWarnings("unchecked") - private Collection transformToOperationVariables(Object obj) { - if (obj instanceof Collection) { - Collection> map = (Collection>) obj; - Collection ret = new ArrayList<>(); - for (Map m : map) { - ret.add(OperationVariable.createAsFacade(m)); - } - return ret; - } else { - return new ArrayList<>(); - } + @Override + public Object invoke(Object... params) { + return invokeSimple(params); } - @SuppressWarnings("unchecked") @Override - public Object invoke(Object... params) { - if (params.length != getInputVariables().size()) { - throw new WrongNumberOfParametersException(getIdShort(), getInputVariables(), params); + public Object invokeSimple(Object... simpleParams) { + OperationCheckHelper.checkValidParameterLength(simpleParams.length, getIdShort(), getInputVariables()); + OperationCheckHelper.checkSubmodelElementExpectedTypes(simpleParams, getInputVariables()); + if (isWrappedInvokable()) { + return invokeWrappedInvokableWithSimpleParameters(simpleParams); + } else { + return directlyInvokeSimpleInvokable(simpleParams); } - return ((Function) get(INVOKABLE)).apply(params); + } + + private Object invokeWrappedInvokableWithSimpleParameters(Object... simpleParams) { + Map wrappedParamMap = OperationHelper.wrapSimpleInputParametersInMap(simpleParams, getInputVariables()); + OperationCheckHelper.checkSubmodelElementAsParameter(wrappedParamMap, getInputVariables()); + SubmodelElement[] wrappedResult = directlyInvokeWrappedInvokable(wrappedParamMap); + return OperationHelper.unwrapResult(wrappedResult); } @Override public SubmodelElement[] invoke(SubmodelElement... elems) { - throw new UnsupportedOperationException( - "SubmodelElement matching logic is only supported for connected Operations"); + OperationCheckHelper.checkValidParameterLength(elems.length, getIdShort(), getInputVariables()); + OperationCheckHelper.checkSubmodelElementAsParameter(elems, getInputVariables()); + Map seMap = OperationHelper.convertSubmodelElementArrayToMap(elems); + return invokeWrappedUnchecked(seMap); } @Override @@ -238,8 +224,58 @@ public void setInOutputVariables(Collection inOut) { put(Operation.INOUT, inOut); } + /** + * Sets an invokable that handles submodel elements. + * + * @param endpoint + */ + public void setWrappedInvokable(Function, SubmodelElement[]> endpoint) { + Function wrappedInvokable = prepareWrappedFunctionForVAB(endpoint); + setInvokable(wrappedInvokable); + put(Operation.IS_WRAPPED_INVOKABLE, true); + } + + /** + * Sets an invokable that handles a consumer. + * + * @param consumer + */ + public void setWrappedInvokable(Consumer> consumer) { + Consumer wrappedInvokable = prepareWrappedFunctionForVAB(consumer); + setInvokable(wrappedInvokable); + put(Operation.IS_WRAPPED_INVOKABLE, true); + } + + /** + * Sets an invokable that handles a supplier. + * + * @param supplier + */ + public void setWrappedInvokable(Supplier supplier) { + Supplier wrappedInvokable = prepareWrappedFunctionForVAB(supplier); + setInvokable(wrappedInvokable); + put(Operation.IS_WRAPPED_INVOKABLE, true); + } + + /** + * Sets an invokable that handles direct values. + * + * @param endpoint + */ public void setInvokable(Function endpoint) { - put(Operation.INVOKABLE, endpoint); + setSimpleInvokable(endpoint); + } + + public void setInvokable(Runnable runnable) { + setSimpleInvokable(runnable); + } + + public void setInvokable(Supplier supplier) { + setSimpleInvokable(supplier); + } + + public void setInvokable(Consumer consumer) { + setSimpleInvokable(consumer); } @Override @@ -266,7 +302,7 @@ public String getCategory() { public LangStrings getDescription() { return Referable.createAsFacade(this, getKeyElement()).getDescription(); } - + @Override protected KeyElements getKeyElement() { return KeyElements.OPERATION; @@ -304,4 +340,65 @@ public Operation getLocalCopy() { copy.setInOutputVariables(inoutVarCopy); return copy; } + + private void setSimpleInvokable(Object invokable) { + put(Operation.INVOKABLE, invokable); + put(Operation.IS_WRAPPED_INVOKABLE, false); + } + + @SuppressWarnings("unchecked") + private Collection transformToOperationVariables(Object obj) { + if (obj instanceof Collection) { + Collection> mapCollection = (Collection>) obj; + return transformToOperationVariable(mapCollection); + } else { + return new ArrayList<>(); + } + } + + private Collection transformToOperationVariable(Collection> mapCollection) { + Collection ret = new ArrayList<>(); + for (Map m : mapCollection) { + OperationVariable opVariable = OperationVariable.createAsFacade(m); + ret.add(opVariable); + } + return ret; + } + + private Object directlyInvokeSimpleInvokable(Object[] simpleParams) { + return new VABLambdaProvider(this).invokeOperation(INVOKABLE, simpleParams); + } + + private SubmodelElement[] invokeWrappedUnchecked(Map wrappedParamMap) { + if (isWrappedInvokable()) { + return directlyInvokeWrappedInvokable(wrappedParamMap); + } else { + Object[] unwrappedParams = OperationHelper.unwrapInputParameters(wrappedParamMap, getInputVariables()); + Object unwrappedResult = directlyInvokeSimpleInvokable(unwrappedParams); + return OperationHelper.wrapResult(unwrappedResult, getOutputVariables()); + } + } + + private SubmodelElement[] directlyInvokeWrappedInvokable(Map wrappedParamMap) { + return (SubmodelElement[]) new VABLambdaProvider(this).invokeOperation(INVOKABLE, wrappedParamMap); + } + + private boolean isWrappedInvokable() { + Object isWrappedInvokable = get(IS_WRAPPED_INVOKABLE); + return isWrappedInvokable != null && ((boolean) isWrappedInvokable); + } + + @SuppressWarnings("unchecked") + private Function prepareWrappedFunctionForVAB(Function, SubmodelElement[]> wrappedFunction) { + return elemArray -> wrappedFunction.apply((Map) elemArray[0]); + } + + @SuppressWarnings("unchecked") + private Consumer prepareWrappedFunctionForVAB(Consumer> consumer) { + return elemArray -> consumer.accept((Map) elemArray[0]); + } + + private Supplier prepareWrappedFunctionForVAB(Supplier supplier) { + return () -> supplier.get(); + } } diff --git a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/submodelelement/operation/OperationCheckHelper.java b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/submodelelement/operation/OperationCheckHelper.java new file mode 100644 index 00000000..ade06b59 --- /dev/null +++ b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/submodelelement/operation/OperationCheckHelper.java @@ -0,0 +1,137 @@ +/******************************************************************************* + * Copyright (C) 2021 the Eclipse BaSyx Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + ******************************************************************************/ + +package org.eclipse.basyx.submodel.metamodel.map.submodelelement.operation; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; + +import org.eclipse.basyx.submodel.metamodel.api.submodelelement.operation.IOperationVariable; +import org.eclipse.basyx.submodel.metamodel.map.submodelelement.SubmodelElement; +import org.eclipse.basyx.vab.exception.provider.MalformedRequestException; +import org.eclipse.basyx.vab.exception.provider.WrongNumberOfParametersException; + +/** + * Checks submodel inputs of {@link Operation} to be the expected submodels. + * + * @author fischer, espen + */ +public class OperationCheckHelper { + + private OperationCheckHelper() { + } + + /** + * Checks parameter signature for given complex parameters for an operation. + * + * @param givenParameters + * @param expectedVariables + */ + public static void checkSubmodelElementAsParameter(SubmodelElement[] givenParameters, Collection expectedVariables) { + Map paramsByIdShortMap = createParameterMap(givenParameters); + + compareGivenWithExpectedVariables(paramsByIdShortMap, expectedVariables); + } + + /** + * Checks parameter signature for given complex parameters for an operation. + * + * @param givenParameterMap + * @param expectedVariables + */ + public static void checkSubmodelElementAsParameter(Map givenParameterMap, Collection expectedVariables) { + compareGivenWithExpectedVariables(givenParameterMap, expectedVariables); + } + + /** + * Checks if given parameters correspond with the actual length. + * + * @param actualParameterLength + * of the given parameters + * @param idShort + * of the operation + * @param inputVariables + */ + public static void checkValidParameterLength(int actualParameterLength, String idShort, Collection inputVariables) { + if (actualParameterLength != inputVariables.size()) { + throw new WrongNumberOfParametersException(idShort, inputVariables, actualParameterLength); + } + } + + /** + * Checks if the expected variables allow setValue to the given parameters. + * + * @param givenParameters + * @param expectedVariables + */ + public static void checkSubmodelElementExpectedTypes(Object[] givenParameters, Collection expectedVariables) { + IOperationVariable[] expectedVarArray = expectedVariables.toArray(new IOperationVariable[expectedVariables.size()]); + + for (int i = 0; i < expectedVarArray.length; i++) { + checkSubmodelElementExpectedType(expectedVarArray[i], givenParameters[i]); + } + + } + + private static void checkSubmodelElementExpectedType(IOperationVariable iOperationVariable, Object value) { + SubmodelElement submodelElement = iOperationVariable.getValue().getLocalCopy(); + + try { + submodelElement.setValue(value); + } catch (Exception e) { + throw new MalformedRequestException(e); + } + } + + private static void compareGivenWithExpectedVariables(Map paramsByIdShortMap, Collection expectedVariables) { + for (IOperationVariable expectedVariable : expectedVariables) { + compareExpectedVariableToGivenVariableMap(expectedVariable, paramsByIdShortMap); + } + } + + private static void compareExpectedVariableToGivenVariableMap(IOperationVariable expectedVariable, Map paramsByIdShortMap) { + SubmodelElement expectedParam = (SubmodelElement) expectedVariable.getValue(); + SubmodelElement givenParam = paramsByIdShortMap.get(expectedParam.getIdShort()); + + compareSubmodelElements(expectedParam, givenParam); + } + + private static void compareSubmodelElements(SubmodelElement expectedParam, SubmodelElement givenParam) { + checkIfSubmodelElementExists(expectedParam, givenParam); + checkModelType(expectedParam, givenParam); + } + + private static Map createParameterMap(SubmodelElement[] params) { + Map parameterMap = new HashMap<>(); + for (SubmodelElement param : params) { + String parameterKey = getKeyForParameter(param); + parameterMap.put(parameterKey, param); + } + return parameterMap; + } + + private static String getKeyForParameter(SubmodelElement param) { + IOperationVariable paramOperationVariable = new OperationVariable(param); + return paramOperationVariable.getValue().getIdShort(); + } + + private static void checkIfSubmodelElementExists(SubmodelElement expectedParam, SubmodelElement givenParam) { + if (givenParam == null) { + throw new MalformedRequestException("Expected parameter " + expectedParam.getIdShort() + " missing in request"); + } + } + + private static void checkModelType(SubmodelElement expectedParam, SubmodelElement givenParam) { + if (!expectedParam.getModelType().equals(givenParam.getModelType())) { + throw new MalformedRequestException("Given modelType " + givenParam.getModelType() + " differs from expected modelType " + expectedParam.getModelType()); + } + } +} diff --git a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/submodelelement/operation/OperationHelper.java b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/submodelelement/operation/OperationHelper.java index cee7219f..4f152e3b 100644 --- a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/submodelelement/operation/OperationHelper.java +++ b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/submodelelement/operation/OperationHelper.java @@ -9,7 +9,15 @@ ******************************************************************************/ package org.eclipse.basyx.submodel.metamodel.map.submodelelement.operation; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.stream.StreamSupport; + import org.eclipse.basyx.submodel.metamodel.api.qualifier.haskind.ModelingKind; +import org.eclipse.basyx.submodel.metamodel.api.submodelelement.ISubmodelElement; +import org.eclipse.basyx.submodel.metamodel.api.submodelelement.operation.IOperationVariable; +import org.eclipse.basyx.submodel.metamodel.map.submodelelement.SubmodelElement; import org.eclipse.basyx.submodel.metamodel.map.submodelelement.dataelement.property.Property; import org.eclipse.basyx.submodel.metamodel.map.submodelelement.dataelement.property.valuetype.ValueType; @@ -20,4 +28,61 @@ public static Property createPropertyTemplate(ValueType type) { prop.setModelingKind(ModelingKind.TEMPLATE); return prop; } + + public static SubmodelElement[] wrapParameters(Collection inputVariables, Object... unwrappedParameters) { + IOperationVariable[] inputVarArray = inputVariables.toArray(new IOperationVariable[inputVariables.size()]); + SubmodelElement[] wrappedParameters = new SubmodelElement[inputVariables.size()]; + for (int i = 0; i < wrappedParameters.length; i++) { + wrappedParameters[i] = wrapSingleParameter(inputVarArray[i], unwrappedParameters[i]); + } + return wrappedParameters; + } + + public static Map wrapSimpleInputParametersInMap(Object[] simpleParams, Collection inputVariables) { + SubmodelElement[] wrappedParameterArray = OperationHelper.wrapParameters(inputVariables, simpleParams); + return convertSubmodelElementArrayToMap(wrappedParameterArray); + } + + public static SubmodelElement[] wrapResult(Object unwrappedResult, Collection outputVariables) { + if (outputVariables.isEmpty()) { + return new SubmodelElement[] {}; + } + IOperationVariable resultTemplate = outputVariables.iterator().next(); + SubmodelElement wrappedResult = OperationHelper.wrapSingleParameter(resultTemplate, unwrappedResult); + return new SubmodelElement[] { wrappedResult }; + } + + public static SubmodelElement wrapSingleParameter(IOperationVariable template, Object simpleValue) { + ISubmodelElement submodelElementTemplate = template.getValue(); + SubmodelElement submodelElementCopy = submodelElementTemplate.getLocalCopy(); + submodelElementCopy.setValue(simpleValue); + return submodelElementCopy; + } + + public static Object[] unwrapInputParameters(Map wrappedParamMap, Collection inputVariables) { + return StreamSupport.stream(inputVariables.spliterator(), false).map(inputVar -> unwrapInputParameter(wrappedParamMap, inputVar)).toArray(); + } + + public static Object unwrapInputParameter(Map wrappedParamMap, IOperationVariable parameter) { + ISubmodelElement parameterElement = parameter.getValue(); + String parameterName = parameterElement.getIdShort(); + SubmodelElement passedParameterElement = wrappedParamMap.get(parameterName); + return passedParameterElement.getValue(); + } + + public static Object unwrapResult(SubmodelElement[] result) { + if (result != null && result.length > 0) { + return result[0].getValue(); + } else { + return null; + } + } + + public static Map convertSubmodelElementArrayToMap(SubmodelElement[] elems) { + Map seMap = new HashMap<>(); + for (SubmodelElement se : elems) { + seMap.put(se.getIdShort(), se); + } + return seMap; + } } diff --git a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/submodelelement/relationship/AnnotatedRelationshipElement.java b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/submodelelement/relationship/AnnotatedRelationshipElement.java index 8e4fb1f0..c0edffc9 100644 --- a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/submodelelement/relationship/AnnotatedRelationshipElement.java +++ b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/submodelelement/relationship/AnnotatedRelationshipElement.java @@ -21,7 +21,7 @@ import org.eclipse.basyx.submodel.metamodel.map.modeltype.ModelType; /** - * AnnotatedRelationshipElement as defined in DAAS document
+ * AnnotatedRelationshipElement as defined in DAAS document
* An annotated relationship element is a relationship element that can be annotated with additional data elements. * * @author schnicke, conradi diff --git a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/submodelelement/relationship/RelationshipElement.java b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/submodelelement/relationship/RelationshipElement.java index b288e544..af0b31d6 100644 --- a/src/main/java/org/eclipse/basyx/submodel/metamodel/map/submodelelement/relationship/RelationshipElement.java +++ b/src/main/java/org/eclipse/basyx/submodel/metamodel/map/submodelelement/relationship/RelationshipElement.java @@ -20,7 +20,7 @@ import org.eclipse.basyx.submodel.metamodel.map.submodelelement.SubmodelElement; /** - * RelationshipElement as defined in DAAS document
+ * RelationshipElement as defined in DAAS document
* A relationship element is used to define a relationship between two referable * elements. * diff --git a/src/main/java/org/eclipse/basyx/submodel/restapi/MultiSubmodelElementProvider.java b/src/main/java/org/eclipse/basyx/submodel/restapi/MultiSubmodelElementProvider.java index 9b017280..f39cf0ee 100644 --- a/src/main/java/org/eclipse/basyx/submodel/restapi/MultiSubmodelElementProvider.java +++ b/src/main/java/org/eclipse/basyx/submodel/restapi/MultiSubmodelElementProvider.java @@ -45,7 +45,7 @@ public MultiSubmodelElementProvider(IModelProvider provider) { } /** - * The elements are stored in a map => convert them to a list + * The elements are stored in a map {@literal =>} convert them to a list */ @SuppressWarnings("unchecked") protected Collection> getElementsList() { diff --git a/src/main/java/org/eclipse/basyx/submodel/restapi/OperationProvider.java b/src/main/java/org/eclipse/basyx/submodel/restapi/OperationProvider.java index 70ddcd26..f69366a3 100644 --- a/src/main/java/org/eclipse/basyx/submodel/restapi/OperationProvider.java +++ b/src/main/java/org/eclipse/basyx/submodel/restapi/OperationProvider.java @@ -10,9 +10,13 @@ package org.eclipse.basyx.submodel.restapi; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; import java.util.Map; import java.util.UUID; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; import org.eclipse.basyx.submodel.metamodel.api.submodelelement.operation.IOperationVariable; import org.eclipse.basyx.submodel.metamodel.map.submodelelement.SubmodelElement; @@ -20,19 +24,19 @@ import org.eclipse.basyx.submodel.metamodel.map.submodelelement.operation.OperationVariable; import org.eclipse.basyx.submodel.restapi.operation.AsyncOperationHandler; import org.eclipse.basyx.submodel.restapi.operation.CallbackResponse; +import org.eclipse.basyx.submodel.restapi.operation.DelegatedInvocationHelper; import org.eclipse.basyx.submodel.restapi.operation.ExecutionState; import org.eclipse.basyx.submodel.restapi.operation.InvocationRequest; import org.eclipse.basyx.submodel.restapi.operation.InvocationResponse; import org.eclipse.basyx.vab.exception.provider.MalformedRequestException; import org.eclipse.basyx.vab.exception.provider.ProviderException; -import org.eclipse.basyx.vab.modelprovider.VABElementProxy; import org.eclipse.basyx.vab.modelprovider.VABPathTools; import org.eclipse.basyx.vab.modelprovider.api.IModelProvider; /** * Handles operations according to AAS meta model. * - * @author schnicke + * @author schnicke, espen, fischer * */ public class OperationProvider implements IModelProvider { @@ -52,15 +56,19 @@ public Object getValue(String path) throws ProviderException { String[] splitted = VABPathTools.splitPath(path); if (path.isEmpty()) { return modelProvider.getValue(""); - } else if (splitted[0].equals(INVOCATION_LIST) && splitted.length == 2) { + } else if (isInvocationListQuery(splitted)) { String requestId = splitted[1]; return AsyncOperationHandler.retrieveResult(requestId, operationId); - + } else { throw new MalformedRequestException("Get of an Operation supports only empty or /invocationList/{requestId} paths"); } } + private boolean isInvocationListQuery(String[] splitted) { + return splitted[0].equals(INVOCATION_LIST) && splitted.length == 2; + } + @Override public void setValue(String path, Object newValue) throws ProviderException { throw new MalformedRequestException("Set not allowed at path '" + path + "'"); @@ -82,103 +90,156 @@ public void deleteValue(String path, Object obj) throws ProviderException { throw new MalformedRequestException("Delete not allowed at path '" + path + "'"); } + @SuppressWarnings("unchecked") @Override public Object invokeOperation(String path, Object... parameters) throws ProviderException { - // Fix path - boolean async = path.endsWith(ASYNC); - // remove the "invoke" from the end of the path + boolean isAsync = isAsyncInvokePath(path); path = VABPathTools.stripInvokeFromPath(path); - // TODO: Only allow wrapped parameters with InvokationRequests - Object[] unwrappedParameters; - InvocationRequest request = getInvocationRequest(parameters); - String requestId; - if (request != null) { - unwrappedParameters = request.unwrapInputParameters(); - requestId = request.getRequestId(); - } else { - // => not necessary, if it is only allowed to use InvocationRequests - unwrappedParameters = unwrapDirectParameters(parameters); - // Generate random request id - requestId = UUID.randomUUID().toString(); - } - // Invoke /invokable instead of an Operation property if existent Object childElement = modelProvider.getValue(path); - if (Operation.isOperation(childElement)) { - path = VABPathTools.concatenatePaths(path, Operation.INVOKABLE); + if (!Operation.isOperation(childElement)) { + throw new MalformedRequestException("Only operations can be invoked."); } + Operation op = Operation.createAsFacade((Map) childElement); - // Handle async operations - if (async) { - // Async call? No return value, yet - Collection outputVars = copyOutputVariables(); - IModelProvider provider = new VABElementProxy(path, modelProvider); - - // Only necessary as long as invocations without InvokationRequest is allowed - if (request != null) { - AsyncOperationHandler.invokeAsync(provider, operationId, request, outputVars); + if (DelegatedInvocationHelper.isDelegatingOperation(op)) { + return DelegatedInvocationHelper.invokeDelegatedOperation(op, parameters); + } else { + InvocationRequest request = getInvocationRequest(parameters, op); + + if (request != null && isAsync) { + return handleAsyncRequestInvokation(op, request); + } else if (request != null) { + return handleSyncRequestInvokation(op, request); + } else if (isAsync) {// => not necessary, if it is only allowed to use InvocationRequests + return handleAsyncParameterInvokation(op, parameters); } else { - AsyncOperationHandler.invokeAsync(provider, operationId, requestId, unwrappedParameters, outputVars, - 10000); - } - - // Request id has to be returned for caller to be able to retrieve result - // => Use callback response and leave url empty - return new CallbackResponse(requestId, ""); + return handleSyncParameterInvokation(op, parameters); + } } + } - // Handle synchronous operations - // Forward direct operation call to modelprovider - Object directResult = modelProvider.invokeOperation(path, unwrappedParameters); - if (request == null) { - // Parameters have been passed directly? Directly return the result - return directResult; - } - return createInvocationResponseFromDirectResult(request, directResult); + private CallbackResponse handleAsyncRequestInvokation(Operation operation, InvocationRequest request) { + Collection outputVars = copyOutputVariables(operation); + + AsyncOperationHandler.invokeAsync(operation, operationId, request, outputVars); + + // Request id has to be returned for caller to be able to retrieve result + // => Use callback response and leave url empty + return new CallbackResponse(request.getRequestId(), ""); } - /** - * Directly creates an InvocationResponse from an operation result - */ - private Object createInvocationResponseFromDirectResult(InvocationRequest request, Object directResult) { - // Get SubmodelElement output template - Collection outputs = copyOutputVariables(); - if(outputs.size() > 0) - { - SubmodelElement outputElem = (SubmodelElement) outputs.iterator().next().getValue(); - // Set result object - outputElem.setValue(directResult); - }; - - // Create and return InvokationResponse + private InvocationResponse handleSyncRequestInvokation(Operation operation, InvocationRequest request) { + SubmodelElement[] inputVariables = getSumbodelElementsFromInvocationRequest(request); + SubmodelElement[] submodelElementsResult = operation.invoke(inputVariables); + + return createInvocationResponseFromSubmodelElementsResult(request, submodelElementsResult); + } + + private SubmodelElement[] getSumbodelElementsFromInvocationRequest(InvocationRequest request) { + Collection inputVariables = request.getInputArguments(); + + Stream inputVariablesStream = StreamSupport.stream(inputVariables.spliterator(), false); + Stream submodelElementStream = inputVariablesStream.map(inputVar -> (SubmodelElement) inputVar.getValue()); + + return submodelElementStream.toArray(SubmodelElement[]::new); + } + + private CallbackResponse handleAsyncParameterInvokation(Operation operation, Object[] parameters) { + Object[] unwrappedParameters = unwrapDirectParameters(parameters); + Collection outputVars = copyOutputVariables(operation); + + String requestId = UUID.randomUUID().toString(); + + AsyncOperationHandler.invokeAsync(operation, operationId, requestId, unwrappedParameters, outputVars, 10000); + // Request id has to be returned for caller to be able to retrieve result + // => Use callback response and leave url empty + return new CallbackResponse(requestId, ""); + } + + private Object handleSyncParameterInvokation(Operation operation, Object[] parameters) { + Object[] unwrappedParameters = unwrapDirectParameters(parameters); + + Object directResult = operation.invokeSimple(unwrappedParameters); + + return directResult; + } + + private boolean isAsyncInvokePath(String path) { + return path.endsWith(ASYNC); + } + + private InvocationResponse createInvocationResponseFromSubmodelElementsResult(InvocationRequest request, SubmodelElement[] submodelElementsResult) { + Collection outputs; + if (submodelElementsResult == null) { + outputs = new ArrayList<>(); + } else { + Stream submodelElementsStream = Arrays.stream(submodelElementsResult); + Stream operationVariableStream = submodelElementsStream.map(submodelElement -> new OperationVariable(submodelElement)); + + outputs = operationVariableStream.collect(Collectors.toList()); + } return new InvocationResponse(request.getRequestId(), new ArrayList<>(), outputs, ExecutionState.COMPLETED); } /** - * Extracts an invokation request from a generic parameter array + * Extracts an invocation request from a generic parameter array Matches + * parameters to order of Operation inputs by id Throws + * MalformedArgumentException if a required parameter is missing * * @param parameters - * @return + * the input parameters + * @param op + * the Operation providing the inputVariables to be matched to the + * actual input + * @return the build InvocationRequest */ @SuppressWarnings("unchecked") - private InvocationRequest getInvocationRequest(Object[] parameters) { - if (parameters.length == 1 && parameters[0] instanceof Map) { - Map requestMap = (Map) parameters[0]; - return InvocationRequest.createAsFacade(requestMap); + private InvocationRequest getInvocationRequest(Object[] parameters, Operation op) { + if (!isInvokationRequest(parameters)) { + return null; } - return null; + + Map requestMap = (Map) parameters[0]; + InvocationRequest request = InvocationRequest.createAsFacade(requestMap); + + // Sort parameters in request by InputVariables of operation + Collection vars = op.getInputVariables(); + Collection ordered = createOrderedInputVariablesList(request, vars); + + return new InvocationRequest(request.getRequestId(), request.getInOutArguments(), ordered, request.getTimeout()); } - - /** - * Gets the (first) output parameter from the underlying object - * - * @return - */ - @SuppressWarnings("unchecked") - private Collection copyOutputVariables() { - Map operationMap = (Map) getValue(""); - Operation op = Operation.createAsFacade(operationMap); + + private Collection createOrderedInputVariablesList(InvocationRequest request, Collection vars) { + Collection ordered = new ArrayList<>(); + + for (IOperationVariable var : vars) { + String id = var.getValue().getIdShort(); + ordered.add(findOperationVariableById(id, request.getInputArguments())); + } + + return ordered; + } + + private IOperationVariable findOperationVariableById(String id, Collection vars) { + for (IOperationVariable input : vars) { + if (input.getValue().getIdShort().equals(id)) { + return input; + } + } + + throw new MalformedRequestException("Expected parameter " + id + " missing in request"); + } + + private boolean isInvokationRequest(Object[] parameters) { + if (parameters.length != 1) { + return false; + } + return InvocationRequest.isInvocationRequest(parameters[0]); + } + + private Collection copyOutputVariables(Operation op) { Collection outputs = op.getOutputVariables(); Collection outCopy = new ArrayList<>(); outputs.stream().forEach(o -> outCopy.add(new OperationVariable(o.getValue().getLocalCopy()))); @@ -187,7 +248,7 @@ private Collection copyOutputVariables() { @SuppressWarnings("unchecked") private String getIdShort(Object operation) { - if(Operation.isOperation(operation)) { + if (Operation.isOperation(operation)) { return Operation.createAsFacade((Map) operation).getIdShort(); } else { // Should never happen as SubmodelElementProvider.getElementProvider diff --git a/src/main/java/org/eclipse/basyx/submodel/restapi/SubmodelAPIHelper.java b/src/main/java/org/eclipse/basyx/submodel/restapi/SubmodelAPIHelper.java new file mode 100644 index 00000000..104fd311 --- /dev/null +++ b/src/main/java/org/eclipse/basyx/submodel/restapi/SubmodelAPIHelper.java @@ -0,0 +1,78 @@ +/******************************************************************************* +* Copyright (C) 2021 the Eclipse BaSyx Authors +* +* This program and the accompanying materials are made +* available under the terms of the Eclipse Public License 2.0 +* which is available at https://www.eclipse.org/legal/epl-2.0/ + +* +* SPDX-License-Identifier: EPL-2.0 +******************************************************************************/ + +package org.eclipse.basyx.submodel.restapi; + +import org.eclipse.basyx.submodel.metamodel.map.Submodel; +import org.eclipse.basyx.submodel.metamodel.map.submodelelement.dataelement.property.Property; +import org.eclipse.basyx.submodel.metamodel.map.submodelelement.operation.Operation; +import org.eclipse.basyx.vab.modelprovider.VABPathTools; + +/** + * Helper class for Submodel API + + * @author haque + */ +public class SubmodelAPIHelper { + + /** + * Retrieves base access path for Submodel API + * @return + */ + public static String getSubmodelPath() { + return ""; + } + + /** + * Retrieves base access path for submodel element + * @return + */ + public static String getSubmodelElementsPath() { + return Submodel.SUBMODELELEMENT; + } + + /** + * Retrieves access path for given element + * @param idShortPath + * @return + */ + public static String getSubmodelElementPath(String idShortPath) { + return VABPathTools.concatenatePaths(MultiSubmodelElementProvider.ELEMENTS, idShortPath); + } + + /** + * Retrieves access path for invocation of element's operation + * @param idShortPath + * @return + */ + public static String getSubmodelElementInvokePath(String idShortPath) { + return VABPathTools.concatenatePaths(getSubmodelElementPath(idShortPath), Operation.INVOKE + OperationProvider.ASYNC); + } + + /** + * Retrieves access path for element value + * @param idShortPath + * @return + */ + public static String getSubmodelElementValuePath(String idShortPath) { + return VABPathTools.concatenatePaths(getSubmodelElementPath(idShortPath), Property.VALUE); + } + + /** + * Retrieves access path for Element operation's result by request id + * @param idShortPath + * @param requestId + * @return + */ + public static String getSubmodelElementResultValuePath(String idShortPath, String requestId) { + return VABPathTools.concatenatePaths(getSubmodelElementPath(idShortPath), OperationProvider.INVOCATION_LIST, requestId); + } +} diff --git a/src/main/java/org/eclipse/basyx/submodel/restapi/SubmodelElementCollectionProvider.java b/src/main/java/org/eclipse/basyx/submodel/restapi/SubmodelElementCollectionProvider.java index 19f66b71..2769d5f0 100644 --- a/src/main/java/org/eclipse/basyx/submodel/restapi/SubmodelElementCollectionProvider.java +++ b/src/main/java/org/eclipse/basyx/submodel/restapi/SubmodelElementCollectionProvider.java @@ -9,6 +9,7 @@ ******************************************************************************/ package org.eclipse.basyx.submodel.restapi; +import java.util.Collection; import java.util.Map; import org.eclipse.basyx.submodel.metamodel.facade.SubmodelElementMapCollectionConverter; @@ -47,26 +48,19 @@ protected IModelProvider getElementProvider(String idShort) { return new SubmodelElementProvider(defaultProvider); } - @SuppressWarnings("unchecked") @Override public Object getValue(String path) throws ProviderException { path = VABPathTools.stripSlashes(path); String[] pathElements = VABPathTools.splitPath(path); if (path.isEmpty()) { - // Convert the internally used Map to a Collection before returning the smECollection - Map map = (Map) proxy.getValue(path); - SubmodelElementCollection smElemColl = SubmodelElementCollection.createAsFacade(map); - return SubmodelElementMapCollectionConverter.smElementToMap(smElemColl); - } else if(path.equals(MultiSubmodelElementProvider.VALUE)) { - // Return only a Collection of Elements. Not the internally used Map. - return SubmodelElementMapCollectionConverter.convertIDMapToCollection(proxy.getValue(path)); + return getSubmodelElementCollection(); + } else if(isValueAccess(path)) { + return getElements(); + } else if (isValuesAccess(path)) { + return getElementsValues(); } else { - // Directly access an element inside of the collection - String idShort = pathElements[0]; - String subPath = VABPathTools.buildPath(pathElements, 1); - - return getElementProvider(idShort).getValue(subPath); + return getElementByIdShort(pathElements); } } @@ -81,7 +75,7 @@ public void setValue(String path, Object newValue) throws ProviderException { Map value = SubmodelElementMapCollectionConverter.mapToSmECollection((Map) newValue); proxy.setValue(path, value); - } else if(path.equals(MultiSubmodelElementProvider.VALUE)) { + } else if(isValueAccess(path)) { // Convert the Collection of Elements to the internally used Map Map value = SubmodelElementMapCollectionConverter.convertCollectionToIDMap(newValue); proxy.setValue(path, value); @@ -120,7 +114,7 @@ public void deleteValue(String path) throws ProviderException { String[] pathElements = VABPathTools.splitPath(path); // "value" is a keyword and can not be used as the ID of an Element - if (path.isEmpty() || path.equals(MultiSubmodelElementProvider.VALUE)) { + if (path.isEmpty() || isValueAccess(path)) { throw new MalformedRequestException("Path must not be empty or /value"); } else { // If Path contains only one Element, use the proxy directly @@ -145,7 +139,7 @@ public Object invokeOperation(String path, Object... parameter) throws ProviderE path = VABPathTools.stripSlashes(path); String[] pathElements = VABPathTools.splitPath(path); - if (path.isEmpty() || path.equals(MultiSubmodelElementProvider.VALUE)) { + if (path.isEmpty() || isValueAccess(path)) { throw new MalformedRequestException("Path must not be empty or /value"); } else { // Directly access an element inside of the collection @@ -154,4 +148,56 @@ public Object invokeOperation(String path, Object... parameter) throws ProviderE return getElementProvider(idShort).invokeOperation(subPath, parameter); } } + + private boolean isValueAccess(String path) { + return path.equals(MultiSubmodelElementProvider.VALUE); + } + + private boolean isValuesAccess(String path) { + return path.equals(SubmodelProvider.VALUES); + } + + /** + * Gets single element by idShort + * @param pathElements containing idShort as the first item + * @return + */ + private Object getElementByIdShort(String[] pathElements) { + String idShort = pathElements[0]; + String subPath = VABPathTools.buildPath(pathElements, 1); + + return getElementProvider(idShort).getValue(subPath); + } + + /** + * Gets elements values from the proxy + * Converts the internally used Map to a Collection before returning the smECollection + * @return + */ + @SuppressWarnings("unchecked") + private Map getElementsValues() { + Map map = (Map) proxy.getValue(""); + SubmodelElementCollection smElemColl = SubmodelElementCollection.createAsFacade(map); + return smElemColl.getValues(); + } + + /** + * Gets element collection from the proxy + * @return Only a Collection of Elements. Not the internally used Map. + */ + private Collection> getElements() { + return SubmodelElementMapCollectionConverter.convertIDMapToCollection(proxy.getValue(MultiSubmodelElementProvider.VALUE)); + } + + /** + * Gets Submodel Element Collection from the proxy + * Converts the internally used Map to a Collection before returning the smECollection + * @return map of the SMElementCollection + */ + @SuppressWarnings("unchecked") + private Map getSubmodelElementCollection() { + Map map = (Map) proxy.getValue(""); + SubmodelElementCollection smElemColl = SubmodelElementCollection.createAsFacade(map); + return SubmodelElementMapCollectionConverter.smElementToMap(smElemColl); + } } diff --git a/src/main/java/org/eclipse/basyx/submodel/restapi/observing/ISubmodelAPIObserver.java b/src/main/java/org/eclipse/basyx/submodel/restapi/observing/ISubmodelAPIObserver.java new file mode 100644 index 00000000..d25f8d9a --- /dev/null +++ b/src/main/java/org/eclipse/basyx/submodel/restapi/observing/ISubmodelAPIObserver.java @@ -0,0 +1,45 @@ +/******************************************************************************* +* Copyright (C) 2021 the Eclipse BaSyx Authors +* +* This program and the accompanying materials are made +* available under the terms of the Eclipse Public License 2.0 +* which is available at https://www.eclipse.org/legal/epl-2.0/ +* +* SPDX-License-Identifier: EPL-2.0 +******************************************************************************/ +package org.eclipse.basyx.submodel.restapi.observing; + +import org.eclipse.basyx.aas.observer.IObserver; + +/** + * Interface for an observer of {@link ObservableSubmodelAPI} + * + * @author conradi + * + */ +public interface ISubmodelAPIObserver extends IObserver { + + /** + * Is called when a SubmodelElement is added + * + * @param idShortPath the idShortPath of the added element + * @param newValue the value of the new element + */ + public void elementAdded(String idShortPath, Object newValue); + + /** + * Is called when a SubmodelElement is deleted + * + * @param idShortPath the idShortPath of the deleted element + */ + public void elementDeleted(String idShortPath); + + /** + * Is called when a SubmodelElement is updated + * + * @param idShortPath the idShortPath of the updated element + * @param newValue the new value of the updated element + */ + public void elementUpdated(String idShortPath, Object newValue); + +} diff --git a/src/main/java/org/eclipse/basyx/submodel/restapi/observing/ObservableSubmodelAPI.java b/src/main/java/org/eclipse/basyx/submodel/restapi/observing/ObservableSubmodelAPI.java new file mode 100644 index 00000000..dd32084a --- /dev/null +++ b/src/main/java/org/eclipse/basyx/submodel/restapi/observing/ObservableSubmodelAPI.java @@ -0,0 +1,99 @@ +/******************************************************************************* +* Copyright (C) 2021 the Eclipse BaSyx Authors +* +* This program and the accompanying materials are made +* available under the terms of the Eclipse Public License 2.0 +* which is available at https://www.eclipse.org/legal/epl-2.0/ +* +* SPDX-License-Identifier: EPL-2.0 +******************************************************************************/ +package org.eclipse.basyx.submodel.restapi.observing; + +import java.util.Collection; + +import org.eclipse.basyx.aas.observer.Observable; +import org.eclipse.basyx.submodel.metamodel.api.ISubmodel; +import org.eclipse.basyx.submodel.metamodel.api.submodelelement.ISubmodelElement; +import org.eclipse.basyx.submodel.metamodel.api.submodelelement.operation.IOperation; +import org.eclipse.basyx.submodel.restapi.api.ISubmodelAPI; + +/** + * Implementation of {@link ISubmodelAPI} that calls back registered {@link ISubmodelAPIObserver} + * when changes on SubmodelElements occur + * + * @author conradi + * + */ +public class ObservableSubmodelAPI extends Observable implements ISubmodelAPI { + + ISubmodelAPI submodelAPI; + + public ObservableSubmodelAPI(ISubmodelAPI observerdAPI) { + submodelAPI = observerdAPI; + } + + @Override + public ISubmodel getSubmodel() { + return submodelAPI.getSubmodel(); + } + + @Override + public void addSubmodelElement(ISubmodelElement elem) { + submodelAPI.addSubmodelElement(elem); + observers.stream().forEach(o -> o.elementAdded(elem.getIdShort(), elem.getValue())); + } + + @Override + public void addSubmodelElement(String idShortPath, ISubmodelElement elem) { + submodelAPI.addSubmodelElement(idShortPath, elem); + observers.stream().forEach(o -> o.elementAdded(idShortPath, elem.getValue())); + } + + @Override + public ISubmodelElement getSubmodelElement(String idShortPath) { + return submodelAPI.getSubmodelElement(idShortPath); + } + + @Override + public void deleteSubmodelElement(String idShortPath) { + submodelAPI.deleteSubmodelElement(idShortPath); + observers.stream().forEach(o -> o.elementDeleted(idShortPath)); + } + + @Override + public Collection getOperations() { + return submodelAPI.getOperations(); + } + + @Override + public Collection getSubmodelElements() { + return submodelAPI.getSubmodelElements(); + } + + @Override + public void updateSubmodelElement(String idShortPath, Object newValue) { + submodelAPI.updateSubmodelElement(idShortPath, newValue); + observers.stream().forEach(o -> o.elementUpdated(idShortPath, newValue)); + } + + @Override + public Object getSubmodelElementValue(String idShortPath) { + return submodelAPI.getSubmodelElementValue(idShortPath); + } + + @Override + public Object invokeOperation(String idShortPath, Object... params) { + return submodelAPI.invokeOperation(idShortPath, params); + } + + @Override + public Object invokeAsync(String idShortPath, Object... params) { + return submodelAPI.invokeAsync(idShortPath, params); + } + + @Override + public Object getOperationResult(String idShort, String requestId) { + return submodelAPI.getOperationResult(idShort, requestId); + } + +} diff --git a/src/main/java/org/eclipse/basyx/submodel/restapi/operation/AsyncOperationHandler.java b/src/main/java/org/eclipse/basyx/submodel/restapi/operation/AsyncOperationHandler.java index d09b1721..16fb60d5 100644 --- a/src/main/java/org/eclipse/basyx/submodel/restapi/operation/AsyncOperationHandler.java +++ b/src/main/java/org/eclipse/basyx/submodel/restapi/operation/AsyncOperationHandler.java @@ -17,12 +17,11 @@ import java.util.concurrent.ScheduledThreadPoolExecutor; import java.util.concurrent.TimeUnit; -import org.apache.poi.ss.formula.functions.T; import org.eclipse.basyx.submodel.metamodel.api.submodelelement.operation.IOperationVariable; +import org.eclipse.basyx.submodel.metamodel.map.submodelelement.operation.Operation; import org.eclipse.basyx.submodel.metamodel.map.submodelelement.operation.OperationExecutionTimeoutException; import org.eclipse.basyx.vab.exception.provider.ProviderException; import org.eclipse.basyx.vab.exception.provider.ResourceNotFoundException; -import org.eclipse.basyx.vab.modelprovider.api.IModelProvider; /** * Helperclass used to keep and invoke operations asynchronously. @@ -38,27 +37,27 @@ public class AsyncOperationHandler { /** * Invokes an Operation with an invocation request */ - public static void invokeAsync(IModelProvider provider, String operationId, InvocationRequest request, + public static void invokeAsync(Operation operation, String operationId, InvocationRequest request, Collection outputArguments) { String requestId = request.getRequestId(); Collection inOutArguments = request.getInOutArguments(); Object[] parameters = request.unwrapInputParameters(); - invokeAsync(provider, operationId, requestId, parameters, inOutArguments, outputArguments, + invokeAsync(operation, operationId, requestId, parameters, inOutArguments, outputArguments, request.getTimeout()); } /** * Invokes an Operation without an invocation request */ - public static void invokeAsync(IModelProvider provider, String operationId, String requestId, Object[] inputs, + public static void invokeAsync(Operation operation, String operationId, String requestId, Object[] inputs, Collection outputArguments, int timeout) { - invokeAsync(provider, operationId, requestId, inputs, new ArrayList<>(), outputArguments, timeout); + invokeAsync(operation, operationId, requestId, inputs, new ArrayList<>(), outputArguments, timeout); } /** * Invokes an Operation and returns its requestId */ - private static void invokeAsync(IModelProvider provider, String operationId, String requestId, Object[] inputs, + private static void invokeAsync(Operation operation, String operationId, String requestId, Object[] inputs, Collection inOutArguments, Collection outputArguments, int timeout) { synchronized (responses) { @@ -70,7 +69,7 @@ private static void invokeAsync(IModelProvider provider, String operationId, Str CompletableFuture.supplyAsync( // Run Operation asynchronously - () -> provider.invokeOperation("", inputs)) + () -> operation.invokeSimple(inputs)) // Accept either result or throw exception on timeout .acceptEither(setTimeout(timeout, requestId), result -> { // result accepted? => Write execution state if there is an output @@ -102,8 +101,8 @@ private static void invokeAsync(IModelProvider provider, String operationId, Str /** * Function for scheduling a timeout function with completable futures */ - private static CompletableFuture setTimeout(int timeout, String requestId) { - CompletableFuture result = new CompletableFuture<>(); + private static CompletableFuture setTimeout(int timeout, String requestId) { + CompletableFuture result = new CompletableFuture<>(); delayer.schedule( () -> result.completeExceptionally( new OperationExecutionTimeoutException("Request " + requestId + " timed out")), @@ -114,7 +113,7 @@ private static CompletableFuture setTimeout(int timeout, String requestId) { /** * Gets the result of an invocation * - * @param operationIdShort the id of the requested Operation + * @param operationId the id of the requested Operation * @param requestId the id of the request * @return the result of the Operation or a Message that it is not yet finished */ diff --git a/src/main/java/org/eclipse/basyx/submodel/restapi/operation/DelegatedInvocationHelper.java b/src/main/java/org/eclipse/basyx/submodel/restapi/operation/DelegatedInvocationHelper.java new file mode 100644 index 00000000..f304e057 --- /dev/null +++ b/src/main/java/org/eclipse/basyx/submodel/restapi/operation/DelegatedInvocationHelper.java @@ -0,0 +1,87 @@ +/******************************************************************************* +* Copyright (C) 2021 the Eclipse BaSyx Authors +* +* This program and the accompanying materials are made +* available under the terms of the Eclipse Public License 2.0 +* which is available at https://www.eclipse.org/legal/epl-2.0/ + +* +* SPDX-License-Identifier: EPL-2.0 +******************************************************************************/ + +package org.eclipse.basyx.submodel.restapi.operation; + +import java.util.Collection; +import java.util.Map; + +import org.eclipse.basyx.submodel.metamodel.api.qualifier.qualifiable.IConstraint; +import org.eclipse.basyx.submodel.metamodel.map.qualifier.qualifiable.Qualifier; +import org.eclipse.basyx.submodel.metamodel.map.submodelelement.operation.Operation; +import org.eclipse.basyx.vab.protocol.http.connector.HTTPConnectorFactory; + +/** + * A helper class for operation invocation delegation + * @author haque + * + */ +public class DelegatedInvocationHelper { + public static final String DELEGATION_TYPE = "invocationDelegation"; + + /** + * Checks whether the given operation is delegated invocation + * @param operation + * @return + */ + public static boolean isDelegatingOperation(Operation operation) { + return getDelegatedQualifier(operation) != null; + } + + /** + * Invokes delegated operation using delegated URL + * @param operation + * @param parameters + * @return + */ + public static Object invokeDelegatedOperation(Operation operation, Object... parameters) { + String delegatedUrl = getDelegatedURL(operation); + return new HTTPConnectorFactory() + .getConnector(delegatedUrl) + .invokeOperation("", parameters); + } + + /** + * Retrieves the delegated URL of the operation invoke + * @param operation + * @return + * @throws RuntimeException if delegated qualifier does not exist + */ + private static String getDelegatedURL(Operation operation) throws RuntimeException { + Qualifier qualifier = getDelegatedQualifier(operation); + if (qualifier != null) { + return qualifier.getValue().toString(); + } else { + throw new RuntimeException("Qualifier with Delegated type does not exist"); + } + } + + /** + * Gets the delegated qualifier if exists. Otherwise null is returned + * @param operation + * @return + */ + @SuppressWarnings("unchecked") + private static Qualifier getDelegatedQualifier(Operation operation) { + Collection constraints = operation.getQualifiers(); + for (IConstraint constraint : constraints) { + Qualifier qualifier = Qualifier.createAsFacade((Map)constraint); + if (isDelegationQualifier(qualifier)) { + return qualifier; + } + } + return null; + } + + private static boolean isDelegationQualifier(Qualifier qualifier) { + return qualifier.getType() != null && qualifier.getType().equalsIgnoreCase(DELEGATION_TYPE); + } +} diff --git a/src/main/java/org/eclipse/basyx/submodel/restapi/operation/InvocationRequest.java b/src/main/java/org/eclipse/basyx/submodel/restapi/operation/InvocationRequest.java index 4b0178e0..017b2016 100644 --- a/src/main/java/org/eclipse/basyx/submodel/restapi/operation/InvocationRequest.java +++ b/src/main/java/org/eclipse/basyx/submodel/restapi/operation/InvocationRequest.java @@ -58,6 +58,29 @@ public static InvocationRequest createAsFacade(Map map) { return ret; } + + /** + * Returns true if the given map is recognized as an InvocationRequest + */ + @SuppressWarnings("unchecked") + public static boolean isInvocationRequest(Object value) { + if(!(value instanceof Map)) { + return false; + } + + Map map = (Map) value; + + return isValid(map); + } + + /** + * Check whether all mandatory elements for the metamodel + * exist in a map + * @return true/false + */ + public static boolean isValid(Map map) { + return map.containsKey(REQUESTID) && map.containsKey(TIMEOUT); + } /** * Unwraps the values of the inputVars in the order of occurance in the collection of input arguments diff --git a/src/main/java/org/eclipse/basyx/submodel/restapi/vab/VABSubmodelAPI.java b/src/main/java/org/eclipse/basyx/submodel/restapi/vab/VABSubmodelAPI.java index 3934a04c..bb7342bf 100644 --- a/src/main/java/org/eclipse/basyx/submodel/restapi/vab/VABSubmodelAPI.java +++ b/src/main/java/org/eclipse/basyx/submodel/restapi/vab/VABSubmodelAPI.java @@ -19,11 +19,9 @@ import org.eclipse.basyx.submodel.metamodel.api.submodelelement.operation.IOperation; import org.eclipse.basyx.submodel.metamodel.map.Submodel; import org.eclipse.basyx.submodel.metamodel.map.submodelelement.SubmodelElement; -import org.eclipse.basyx.submodel.metamodel.map.submodelelement.dataelement.property.Property; -import org.eclipse.basyx.submodel.metamodel.map.submodelelement.operation.Operation; import org.eclipse.basyx.submodel.restapi.MultiSubmodelElementProvider; -import org.eclipse.basyx.submodel.restapi.OperationProvider; import org.eclipse.basyx.submodel.restapi.api.ISubmodelAPI; +import org.eclipse.basyx.submodel.restapi.SubmodelAPIHelper; import org.eclipse.basyx.vab.modelprovider.VABElementProxy; import org.eclipse.basyx.vab.modelprovider.api.IModelProvider; @@ -58,7 +56,7 @@ public VABSubmodelAPI(IModelProvider modelProvider) { * submodelelements */ private MultiSubmodelElementProvider getElementProvider() { - IModelProvider elementProxy = new VABElementProxy(Submodel.SUBMODELELEMENT, modelProvider); + IModelProvider elementProxy = new VABElementProxy(SubmodelAPIHelper.getSubmodelElementsPath(), modelProvider); return new MultiSubmodelElementProvider(elementProxy); } @@ -66,7 +64,7 @@ private MultiSubmodelElementProvider getElementProvider() { @Override public ISubmodel getSubmodel() { // For access on the container property root, return the whole model - Map map = (Map) modelProvider.getValue(""); + Map map = (Map) modelProvider.getValue(SubmodelAPIHelper.getSubmodelPath()); // Only return a copy of the Submodel Map smCopy = new HashMap<>(); @@ -76,17 +74,17 @@ public ISubmodel getSubmodel() { @Override public void addSubmodelElement(ISubmodelElement elem) { - getElementProvider().createValue(MultiSubmodelElementProvider.ELEMENTS + "/" + elem.getIdShort(), elem); + getElementProvider().createValue(SubmodelAPIHelper.getSubmodelElementPath(elem.getIdShort()), elem); } @Override public void addSubmodelElement(String idShortPath, ISubmodelElement elem) { - getElementProvider().createValue(MultiSubmodelElementProvider.ELEMENTS + "/" + idShortPath, elem); + getElementProvider().createValue(SubmodelAPIHelper.getSubmodelElementPath(idShortPath), elem); } @Override public void deleteSubmodelElement(String idShortPath) { - getElementProvider().deleteValue(MultiSubmodelElementProvider.ELEMENTS + "/" + idShortPath); + getElementProvider().deleteValue(SubmodelAPIHelper.getSubmodelElementPath(idShortPath)); } @@ -99,45 +97,40 @@ public Collection getOperations() { @Override public Collection getSubmodelElements() { Collection> elements = (Collection>) getElementProvider() - .getValue(MultiSubmodelElementProvider.ELEMENTS); + .getValue(SubmodelAPIHelper.getSubmodelElementsPath()); return elements.stream().map(SubmodelElement::createAsFacade).collect(Collectors.toList()); } @Override public void updateSubmodelElement(String idShortPath, Object newValue) { - getElementProvider().setValue(buildValuePathForProperty(idShortPath), newValue); + getElementProvider().setValue(SubmodelAPIHelper.getSubmodelElementValuePath(idShortPath), newValue); } @Override public Object getSubmodelElementValue(String idShortPath) { - return getElementProvider().getValue(buildValuePathForProperty(idShortPath)); + return getElementProvider().getValue(SubmodelAPIHelper.getSubmodelElementValuePath(idShortPath)); } @SuppressWarnings("unchecked") @Override public ISubmodelElement getSubmodelElement(String idShortPath) { - return SubmodelElement.createAsFacade((Map) getElementProvider().getValue(MultiSubmodelElementProvider.ELEMENTS + "/" + idShortPath)); + return SubmodelElement.createAsFacade((Map) getElementProvider().getValue(SubmodelAPIHelper.getSubmodelElementPath(idShortPath))); } @Override public Object invokeOperation(String idShortPath, Object... params) { - return getElementProvider().invokeOperation(MultiSubmodelElementProvider.ELEMENTS + "/" + idShortPath, params); + return getElementProvider().invokeOperation(SubmodelAPIHelper.getSubmodelElementPath(idShortPath), params); } @Override public Object invokeAsync(String idShortPath, Object... params) { - return getElementProvider().invokeOperation(MultiSubmodelElementProvider.ELEMENTS + "/" + idShortPath +"/" + Operation.INVOKE + OperationProvider.ASYNC, params); + return getElementProvider().invokeOperation(SubmodelAPIHelper.getSubmodelElementInvokePath(idShortPath), params); } - - private String buildValuePathForProperty(String idShortPath) { - return MultiSubmodelElementProvider.ELEMENTS + "/" + idShortPath + "/" + Property.VALUE; - } - @Override public Object getOperationResult(String idShortPath, String requestId) { - return getElementProvider().getValue(MultiSubmodelElementProvider.ELEMENTS + "/" + idShortPath + "/" + OperationProvider.INVOCATION_LIST + "/" + requestId); + return getElementProvider().getValue(SubmodelAPIHelper.getSubmodelElementResultValuePath(idShortPath, requestId)); } diff --git a/src/main/java/org/eclipse/basyx/submodel/types/digitalnameplate/DigitalNameplateSubmodel.java b/src/main/java/org/eclipse/basyx/submodel/types/digitalnameplate/DigitalNameplateSubmodel.java index 1e76b2bb..f03ad345 100644 --- a/src/main/java/org/eclipse/basyx/submodel/types/digitalnameplate/DigitalNameplateSubmodel.java +++ b/src/main/java/org/eclipse/basyx/submodel/types/digitalnameplate/DigitalNameplateSubmodel.java @@ -34,7 +34,7 @@ import org.eclipse.basyx.submodel.types.digitalnameplate.submodelelementcollections.markings.Markings; /** - * DigitalNameplateSubmodel as defined in the AAS Digital Nameplate Template document
+ * DigitalNameplateSubmodel as defined in the AAS Digital Nameplate Template document
* this contains the nameplate information attached to the product * * @author haque diff --git a/src/main/java/org/eclipse/basyx/submodel/types/digitalnameplate/submodelelementcollections/address/Address.java b/src/main/java/org/eclipse/basyx/submodel/types/digitalnameplate/submodelelementcollections/address/Address.java index d0ebd781..ea3d94af 100644 --- a/src/main/java/org/eclipse/basyx/submodel/types/digitalnameplate/submodelelementcollections/address/Address.java +++ b/src/main/java/org/eclipse/basyx/submodel/types/digitalnameplate/submodelelementcollections/address/Address.java @@ -32,7 +32,7 @@ import org.eclipse.basyx.submodel.types.helper.SubmodelElementRetrievalHelper; /** - * Address as defined in the AAS Digital Nameplate Template document
+ * Address as defined in the AAS Digital Nameplate Template document
* It is a submodel element collection which contains * The standardized SMC Address contains information * about address of a partner within the value chain. @@ -512,7 +512,7 @@ public List getPhone() { /** * Sets Phone number including type - * @param phone + * @param phones */ public void setPhone(List phones) { if (phones != null && phones.size() > 0) { @@ -539,7 +539,7 @@ public List getFax() { /** * Sets fax number including type - * @param fax + * @param faxes */ public void setFax(List faxes) { if (faxes != null && faxes.size() > 0) { @@ -566,7 +566,7 @@ public List getEmail() { /** * Sets E-mail address and encryption method - * @param email + * @param emails */ public void setEmail(List emails) { if (emails != null && emails.size() > 0) { diff --git a/src/main/java/org/eclipse/basyx/submodel/types/digitalnameplate/submodelelementcollections/address/Email.java b/src/main/java/org/eclipse/basyx/submodel/types/digitalnameplate/submodelelementcollections/address/Email.java index 0f82b5aa..b830e03c 100644 --- a/src/main/java/org/eclipse/basyx/submodel/types/digitalnameplate/submodelelementcollections/address/Email.java +++ b/src/main/java/org/eclipse/basyx/submodel/types/digitalnameplate/submodelelementcollections/address/Email.java @@ -29,7 +29,7 @@ import org.eclipse.basyx.submodel.types.digitalnameplate.enums.MailType; /** - * Email as defined in the AAS Digital Nameplate Template document
+ * Email as defined in the AAS Digital Nameplate Template document
* It is a submodel element collection which contains email address and encryption method * * @author haque diff --git a/src/main/java/org/eclipse/basyx/submodel/types/digitalnameplate/submodelelementcollections/address/Fax.java b/src/main/java/org/eclipse/basyx/submodel/types/digitalnameplate/submodelelementcollections/address/Fax.java index 5bcc9e5f..9be6e8be 100644 --- a/src/main/java/org/eclipse/basyx/submodel/types/digitalnameplate/submodelelementcollections/address/Fax.java +++ b/src/main/java/org/eclipse/basyx/submodel/types/digitalnameplate/submodelelementcollections/address/Fax.java @@ -29,7 +29,7 @@ import org.eclipse.basyx.submodel.types.digitalnameplate.enums.FaxType; /** - * Fax as defined in the AAS Digital Nameplate Template document
+ * Fax as defined in the AAS Digital Nameplate Template document
* It is a submodel element collection which contains a fax number including type * * @author haque diff --git a/src/main/java/org/eclipse/basyx/submodel/types/digitalnameplate/submodelelementcollections/address/Phone.java b/src/main/java/org/eclipse/basyx/submodel/types/digitalnameplate/submodelelementcollections/address/Phone.java index 7ee01f25..e208c202 100644 --- a/src/main/java/org/eclipse/basyx/submodel/types/digitalnameplate/submodelelementcollections/address/Phone.java +++ b/src/main/java/org/eclipse/basyx/submodel/types/digitalnameplate/submodelelementcollections/address/Phone.java @@ -29,7 +29,7 @@ import org.eclipse.basyx.submodel.types.digitalnameplate.enums.PhoneType; /** - * Phone as defined in the AAS Digital Nameplate Template document
+ * Phone as defined in the AAS Digital Nameplate Template document
* It is a submodel element collection which contains a phone number including type * * @author haque diff --git a/src/main/java/org/eclipse/basyx/submodel/types/digitalnameplate/submodelelementcollections/assetspecificproperties/AssetSpecificProperties.java b/src/main/java/org/eclipse/basyx/submodel/types/digitalnameplate/submodelelementcollections/assetspecificproperties/AssetSpecificProperties.java index 03f504dd..e89fe051 100644 --- a/src/main/java/org/eclipse/basyx/submodel/types/digitalnameplate/submodelelementcollections/assetspecificproperties/AssetSpecificProperties.java +++ b/src/main/java/org/eclipse/basyx/submodel/types/digitalnameplate/submodelelementcollections/assetspecificproperties/AssetSpecificProperties.java @@ -24,7 +24,7 @@ import org.eclipse.basyx.submodel.types.helper.SubmodelElementRetrievalHelper; /** - * AssetSpecificProperties as defined in the AAS Digital Nameplate Template document
+ * AssetSpecificProperties as defined in the AAS Digital Nameplate Template document
* It is a submodel element collection which contains collection of guideline specific properties * * @author haque diff --git a/src/main/java/org/eclipse/basyx/submodel/types/digitalnameplate/submodelelementcollections/assetspecificproperties/GuidelineSpecificProperties.java b/src/main/java/org/eclipse/basyx/submodel/types/digitalnameplate/submodelelementcollections/assetspecificproperties/GuidelineSpecificProperties.java index b60d5a1c..7473dbc8 100644 --- a/src/main/java/org/eclipse/basyx/submodel/types/digitalnameplate/submodelelementcollections/assetspecificproperties/GuidelineSpecificProperties.java +++ b/src/main/java/org/eclipse/basyx/submodel/types/digitalnameplate/submodelelementcollections/assetspecificproperties/GuidelineSpecificProperties.java @@ -27,7 +27,7 @@ import org.eclipse.basyx.submodel.metamodel.map.submodelelement.dataelement.property.valuetype.ValueType; /** - * GuidelineSpecificProperties as defined in the AAS Digital Nameplate Template document
+ * GuidelineSpecificProperties as defined in the AAS Digital Nameplate Template document
* It is a submodel element collection which contains Asset specific nameplate * information required by guideline, stipulation or legislation. * diff --git a/src/main/java/org/eclipse/basyx/submodel/types/digitalnameplate/submodelelementcollections/markings/Marking.java b/src/main/java/org/eclipse/basyx/submodel/types/digitalnameplate/submodelelementcollections/markings/Marking.java index 7afd9e89..be52f3f3 100644 --- a/src/main/java/org/eclipse/basyx/submodel/types/digitalnameplate/submodelelementcollections/markings/Marking.java +++ b/src/main/java/org/eclipse/basyx/submodel/types/digitalnameplate/submodelelementcollections/markings/Marking.java @@ -30,7 +30,7 @@ import org.eclipse.basyx.submodel.types.helper.SubmodelElementRetrievalHelper; /** - * Marking as defined in the AAS Digital Nameplate Template document
+ * Marking as defined in the AAS Digital Nameplate Template document
* It is a submodel element collection which * contains information about the marking labelled on the device * diff --git a/src/main/java/org/eclipse/basyx/submodel/types/digitalnameplate/submodelelementcollections/markings/Markings.java b/src/main/java/org/eclipse/basyx/submodel/types/digitalnameplate/submodelelementcollections/markings/Markings.java index 41234430..ec0bab01 100644 --- a/src/main/java/org/eclipse/basyx/submodel/types/digitalnameplate/submodelelementcollections/markings/Markings.java +++ b/src/main/java/org/eclipse/basyx/submodel/types/digitalnameplate/submodelelementcollections/markings/Markings.java @@ -24,7 +24,7 @@ import org.eclipse.basyx.submodel.types.helper.SubmodelElementRetrievalHelper; /** - * Markings as defined in the AAS Digital Nameplate Template document
+ * Markings as defined in the AAS Digital Nameplate Template document
* It is a submodel element collection which contains a collection of product markings * * Note: CE marking is declared as mandatory according to EU Machine Directive 2006/42/EC. @@ -127,7 +127,7 @@ public static boolean isValid(Map obj) { * Note: CE marking is declared as mandatory according to EU Machine * Directive 2006/42/EC. - * @param markingName + * @param markings */ public void setMarking(List markings) { if (markings != null && markings.size() > 0) { diff --git a/src/main/java/org/eclipse/basyx/submodel/types/technicaldata/TechnicalDataSubmodel.java b/src/main/java/org/eclipse/basyx/submodel/types/technicaldata/TechnicalDataSubmodel.java index 8f61c9d9..8839c921 100644 --- a/src/main/java/org/eclipse/basyx/submodel/types/technicaldata/TechnicalDataSubmodel.java +++ b/src/main/java/org/eclipse/basyx/submodel/types/technicaldata/TechnicalDataSubmodel.java @@ -67,11 +67,10 @@ public TechnicalDataSubmodel( * Constructor with mandatory attributes * @param idShort * @param identifier - * @param manufacturerName - * @param manufacturerProductDesignation - * @param address - * @param manufacturerProductFamily - * @param yearsOfConstruction + * @param generalInformation + * @param productClassifications + * @param properties + * @param furtherInformation */ public TechnicalDataSubmodel( String idShort, diff --git a/src/main/java/org/eclipse/basyx/submodel/types/technicaldata/submodelelementcollections/generalinformation/GeneralInformation.java b/src/main/java/org/eclipse/basyx/submodel/types/technicaldata/submodelelementcollections/generalinformation/GeneralInformation.java index b7533ad1..27d72831 100644 --- a/src/main/java/org/eclipse/basyx/submodel/types/technicaldata/submodelelementcollections/generalinformation/GeneralInformation.java +++ b/src/main/java/org/eclipse/basyx/submodel/types/technicaldata/submodelelementcollections/generalinformation/GeneralInformation.java @@ -324,7 +324,7 @@ public IProperty getManufacturerOrderCode() { /** * Sets image file for associated product provided in common format (.png, .jpg). * - * @param image + * @param images */ public void setProductImages(List images) { if (images != null && images.size() > 0) { diff --git a/src/main/java/org/eclipse/basyx/submodel/types/technicaldata/submodelelementcollections/productclassifications/ProductClassificationItem.java b/src/main/java/org/eclipse/basyx/submodel/types/technicaldata/submodelelementcollections/productclassifications/ProductClassificationItem.java index 647e9a05..1638a378 100644 --- a/src/main/java/org/eclipse/basyx/submodel/types/technicaldata/submodelelementcollections/productclassifications/ProductClassificationItem.java +++ b/src/main/java/org/eclipse/basyx/submodel/types/technicaldata/submodelelementcollections/productclassifications/ProductClassificationItem.java @@ -172,7 +172,7 @@ public void setClassificationSystemVersion(Property version) { * Sets common version identifier of the used classification system, in order to distinguish different version of the property dictionary. * * Note: Casing is to be ignored. - * @param system + * @param version */ public void setClassificationSystemVersion(String version) { Property versionProp = new Property(CLASSIFICATIONSYSTEMVERSIONID, ValueType.String); diff --git a/src/main/java/org/eclipse/basyx/submodel/types/technicaldata/submodelelementcollections/technicalproperties/TechnicalProperties.java b/src/main/java/org/eclipse/basyx/submodel/types/technicaldata/submodelelementcollections/technicalproperties/TechnicalProperties.java index 78185f28..c891ad76 100644 --- a/src/main/java/org/eclipse/basyx/submodel/types/technicaldata/submodelelementcollections/technicalproperties/TechnicalProperties.java +++ b/src/main/java/org/eclipse/basyx/submodel/types/technicaldata/submodelelementcollections/technicalproperties/TechnicalProperties.java @@ -199,7 +199,7 @@ public List getMainSections() { * Sets subordinate subdivision possibility for properties. * * Note: Each Sub Section SMC may contain arbitray sets of SubmodelElements, SemanticIdNotAvailable, SubSection. - * @param mainSections + * @param subSections */ public void setSubSections(List subSections) { if (subSections != null && subSections.size() > 0) { diff --git a/src/main/java/org/eclipse/basyx/vab/coder/json/metaprotocol/Message.java b/src/main/java/org/eclipse/basyx/vab/coder/json/metaprotocol/Message.java index a11de573..ace50304 100644 --- a/src/main/java/org/eclipse/basyx/vab/coder/json/metaprotocol/Message.java +++ b/src/main/java/org/eclipse/basyx/vab/coder/json/metaprotocol/Message.java @@ -10,6 +10,7 @@ package org.eclipse.basyx.vab.coder.json.metaprotocol; import java.util.HashMap; +import java.util.Map; /** * @@ -32,6 +33,13 @@ public Message(MessageType messageType, String code, String text) { put(CODE, code); put(TEXT, text); } + + public static Message createAsFacade(Map map) { + MessageType type = MessageType.getById(((Number) map.get(MESSAGETYPE)).intValue()); + String code = (String) map.get(CODE); + String text = (String) map.get(TEXT); + return new Message(type, code, text); + } public String getText() { return (String) get(TEXT); diff --git a/src/main/java/org/eclipse/basyx/vab/coder/json/metaprotocol/MetaprotocolHandler.java b/src/main/java/org/eclipse/basyx/vab/coder/json/metaprotocol/MetaprotocolHandler.java index 09195e39..f80fb553 100644 --- a/src/main/java/org/eclipse/basyx/vab/coder/json/metaprotocol/MetaprotocolHandler.java +++ b/src/main/java/org/eclipse/basyx/vab/coder/json/metaprotocol/MetaprotocolHandler.java @@ -101,7 +101,7 @@ private Object handleResult(Map responseMap) throws ProviderExce } /** - * Creates a ProviderException from a String received form the Server
+ * Creates a ProviderException from a String received form the Server
* The String has to be formated e.g. "ResourceNotFoundException: Requested Item * was not found" * diff --git a/src/main/java/org/eclipse/basyx/vab/coder/json/metaprotocol/Result.java b/src/main/java/org/eclipse/basyx/vab/coder/json/metaprotocol/Result.java index 4fa9bc06..1b5e1437 100644 --- a/src/main/java/org/eclipse/basyx/vab/coder/json/metaprotocol/Result.java +++ b/src/main/java/org/eclipse/basyx/vab/coder/json/metaprotocol/Result.java @@ -9,6 +9,7 @@ ******************************************************************************/ package org.eclipse.basyx.vab.coder.json.metaprotocol; +import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.LinkedList; @@ -78,6 +79,19 @@ public Result(Result result) { public Result(Exception e) { this(false, getMessageListFromException(e)); } + + @SuppressWarnings("unchecked") + public static Result createAsFacade(Map map) { + boolean success = (Boolean) map.get(SUCCESS); + Object entity = map.get(ENTITY); + List messages = new ArrayList<>(); + + for(Map messageMap: (List>)map.get(MESSAGES)) { + messages.add(Message.createAsFacade(messageMap)); + } + + return new Result(success, entity, messages); + } private static List getMessageListFromException(Exception e) { diff --git a/src/main/java/org/eclipse/basyx/vab/coder/json/provider/JSONProvider.java b/src/main/java/org/eclipse/basyx/vab/coder/json/provider/JSONProvider.java index 1d3c2f77..ca63e60b 100644 --- a/src/main/java/org/eclipse/basyx/vab/coder/json/provider/JSONProvider.java +++ b/src/main/java/org/eclipse/basyx/vab/coder/json/provider/JSONProvider.java @@ -10,10 +10,13 @@ package org.eclipse.basyx.vab.coder.json.provider; import java.io.IOException; +import java.io.InputStream; import java.io.OutputStream; import java.nio.charset.StandardCharsets; import java.util.Collection; +import javax.servlet.ServletOutputStream; + import org.eclipse.basyx.vab.coder.json.metaprotocol.Result; import org.eclipse.basyx.vab.coder.json.serialization.DefaultTypeFactory; import org.eclipse.basyx.vab.coder.json.serialization.GSONTools; @@ -28,7 +31,7 @@ /** - * Provider class that supports JSON serialized communication
+ * Provider class that supports JSON serialized communication
* Generic Caller is required since messages can be technology specific. * * @@ -301,7 +304,7 @@ public void processBaSysDelete(String path, String serializedJSONValue, OutputSt * Creates a resource under the given path * * @param path - * @param parameter + * @param serializedJSONValue * @param outputStream * @throws ProviderException */ @@ -320,4 +323,21 @@ public void processBaSysCreate(String path, String serializedJSONValue, OutputSt sendException(outputStream, e); } } + + /** + * Uploads a resource at given path + * @param path + * @param fileContent + * @param outputStream + */ + public void processBaSysUpload(String path, InputStream fileContent, ServletOutputStream outputStream) { + try { + providerBackend.createValue(path, fileContent); + + // Send response + outputStream.write("".getBytes(StandardCharsets.UTF_8)); + } catch (Exception e) { + sendException(outputStream, e); + } + } } diff --git a/src/main/java/org/eclipse/basyx/vab/coder/json/serialization/GSONTools.java b/src/main/java/org/eclipse/basyx/vab/coder/json/serialization/GSONTools.java index fde9ef99..6a2f7483 100644 --- a/src/main/java/org/eclipse/basyx/vab/coder/json/serialization/GSONTools.java +++ b/src/main/java/org/eclipse/basyx/vab/coder/json/serialization/GSONTools.java @@ -39,10 +39,10 @@ /** * Provides means for (de-)serialization of Primitives (int, double, string, - * boolean), Maps, Sets and Lists.
+ * boolean), Maps, Sets and Lists.
* Since JSON is not able to differentiate between Sets and Lists, additional * information is added. When a Collection of objects is serialized, this - * information is directly added using an "index" key.
+ * information is directly added using an "index" key.
* However, collections of primitives do not allow adding an "index" key. To * handle this, a type tag is added on the same level as the collection. For * more details, see TestJson @@ -279,7 +279,7 @@ private JsonObject serializeMap(Map map) { } /** - * Deserializes a JsonArray to a Collection
+ * Deserializes a JsonArray to a Collection
* Remark: internally, a List will be used for deserialization & it is assumed, that * the order in the json equals the correct intended order for the list. * => The ordering will be preserved in the returned collection @@ -305,7 +305,8 @@ private boolean isFunction(Object value) { return (value instanceof Supplier) || (value instanceof Function) || (value instanceof Consumer) - || (value instanceof BiConsumer); + || (value instanceof BiConsumer + || (value instanceof Runnable)); } /** diff --git a/src/main/java/org/eclipse/basyx/vab/exception/provider/MalformedRequestException.java b/src/main/java/org/eclipse/basyx/vab/exception/provider/MalformedRequestException.java index 1137cd37..34464a20 100644 --- a/src/main/java/org/eclipse/basyx/vab/exception/provider/MalformedRequestException.java +++ b/src/main/java/org/eclipse/basyx/vab/exception/provider/MalformedRequestException.java @@ -9,9 +9,13 @@ ******************************************************************************/ package org.eclipse.basyx.vab.exception.provider; +import java.util.List; + +import org.eclipse.basyx.vab.coder.json.metaprotocol.Message; + /** * Used to indicate by a ModelProvider, - * that a given request was malformed.
+ * that a given request was malformed.
* e.g. an invalid path or a invalid JSON. * * @author conradi @@ -36,4 +40,8 @@ public MalformedRequestException(String msg) { public MalformedRequestException(Exception e) { super(e); } + + public MalformedRequestException(List msgs) { + super(msgs); + } } diff --git a/src/main/java/org/eclipse/basyx/vab/exception/provider/ProviderException.java b/src/main/java/org/eclipse/basyx/vab/exception/provider/ProviderException.java index 7e0e3af9..f8f257eb 100644 --- a/src/main/java/org/eclipse/basyx/vab/exception/provider/ProviderException.java +++ b/src/main/java/org/eclipse/basyx/vab/exception/provider/ProviderException.java @@ -9,6 +9,12 @@ ******************************************************************************/ package org.eclipse.basyx.vab.exception.provider; +import java.util.ArrayList; +import java.util.List; + +import org.eclipse.basyx.vab.coder.json.metaprotocol.Message; +import org.eclipse.basyx.vab.coder.json.metaprotocol.MessageType; + /** * Used to indicate a general exception in a ModelProvider * @@ -17,6 +23,7 @@ */ public class ProviderException extends RuntimeException { + private List messages = new ArrayList<>(); /** * Version information for serialized instances @@ -25,6 +32,12 @@ public class ProviderException extends RuntimeException { public ProviderException(String msg) { super(msg); + messages.add(new Message(MessageType.Exception, msg)); + } + + public ProviderException(List messages) { + super(); + this.messages = messages; } public ProviderException(Throwable cause) { @@ -34,4 +47,8 @@ public ProviderException(Throwable cause) { public ProviderException(String message, Throwable cause) { super(message, cause); } + + public List getMessages() { + return messages; + } } diff --git a/src/main/java/org/eclipse/basyx/vab/exception/provider/ResourceAlreadyExistsException.java b/src/main/java/org/eclipse/basyx/vab/exception/provider/ResourceAlreadyExistsException.java index 6b6a589d..f033f454 100644 --- a/src/main/java/org/eclipse/basyx/vab/exception/provider/ResourceAlreadyExistsException.java +++ b/src/main/java/org/eclipse/basyx/vab/exception/provider/ResourceAlreadyExistsException.java @@ -9,6 +9,10 @@ ******************************************************************************/ package org.eclipse.basyx.vab.exception.provider; +import java.util.List; + +import org.eclipse.basyx.vab.coder.json.metaprotocol.Message; + /** * Used to indicate by a ModelProvider, * that a resource to be created already exists @@ -35,4 +39,8 @@ public ResourceAlreadyExistsException(String msg) { public ResourceAlreadyExistsException(Exception e) { super(e); } + + public ResourceAlreadyExistsException(List msgs) { + super(msgs); + } } diff --git a/src/main/java/org/eclipse/basyx/vab/exception/provider/ResourceNotFoundException.java b/src/main/java/org/eclipse/basyx/vab/exception/provider/ResourceNotFoundException.java index 07e64e20..d612f26a 100644 --- a/src/main/java/org/eclipse/basyx/vab/exception/provider/ResourceNotFoundException.java +++ b/src/main/java/org/eclipse/basyx/vab/exception/provider/ResourceNotFoundException.java @@ -9,6 +9,9 @@ ******************************************************************************/ package org.eclipse.basyx.vab.exception.provider; +import java.util.List; + +import org.eclipse.basyx.vab.coder.json.metaprotocol.Message; /** * Exception that indicates that a requested resource (AAS, sub model, property) was not found @@ -35,4 +38,8 @@ public ResourceNotFoundException(String msg) { public ResourceNotFoundException(Exception e) { super(e); } + + public ResourceNotFoundException(List msgs) { + super(msgs); + } } diff --git a/src/main/java/org/eclipse/basyx/vab/exception/provider/WrongNumberOfParametersException.java b/src/main/java/org/eclipse/basyx/vab/exception/provider/WrongNumberOfParametersException.java index 32bff37d..22e833f1 100644 --- a/src/main/java/org/eclipse/basyx/vab/exception/provider/WrongNumberOfParametersException.java +++ b/src/main/java/org/eclipse/basyx/vab/exception/provider/WrongNumberOfParametersException.java @@ -18,11 +18,16 @@ public class WrongNumberOfParametersException extends MalformedRequestException * Version information for serialized instances */ private static final long serialVersionUID = 1L; + public WrongNumberOfParametersException(String operationIdShort, Collection expected, Object... actual) { - super(constructErrorMessage(operationIdShort, expected, actual)); + super(constructErrorMessage(operationIdShort, expected, actual.length)); + } + + public WrongNumberOfParametersException(String operationIdShort, Collection expected, int actualSize) { + super(constructErrorMessage(operationIdShort, expected, actualSize)); } - private static String constructErrorMessage(String operationIdShort, Collection expected, Object... actual) { - return "Operation with idShort " + operationIdShort + " was called using the wrong number of parameters. Expected size: " + expected.size() + ", actual: " + actual.length; + private static String constructErrorMessage(String operationIdShort, Collection expected, int actualSize) { + return "Operation with idShort " + operationIdShort + " was called using the wrong number of parameters. Expected size: " + expected.size() + ", actual size: " + actualSize; } } diff --git a/src/main/java/org/eclipse/basyx/vab/factory/xml/XmlParser.java b/src/main/java/org/eclipse/basyx/vab/factory/xml/XmlParser.java index adef4224..251e6f8a 100644 --- a/src/main/java/org/eclipse/basyx/vab/factory/xml/XmlParser.java +++ b/src/main/java/org/eclipse/basyx/vab/factory/xml/XmlParser.java @@ -31,37 +31,31 @@ /*** * A Generic XML Parser which transforms the given XML data to nested - * Map<String, Object>.
- *
+ * {@literal Map}.
+ *
* - * Examples:
- *
- * -Text Element
- * <a>v</a> => {a = v}

+ * Examples:
+ *
+ * -Text Element
+ * {@literal v => {a = v}}

* - * -Nested Element
- * <a><b>v</b></a> => {a={b= - * v}}

+ * -Nested Element
+ * {@literal v => {a={b=v}}}

* - * -Text Node with Attributes
- * <a b="v1" c="v2">v3</a> => - * {ele :{#text:v3,b:v1, c:v2}}
- *
+ * -Text Node with Attributes
+ * {@literal v3 => {ele :{#text:v3,b:v1, c:v2}}}
+ *
* - * -Multiple Text Nodes
- * <a><b>v1</b><b>v2</b></a> => {a={b=[v1, - * v2]}}

+ * -Multiple Text Nodes
+ * {@literal v1v2 => {a={b=[v1,v2]}}}

* - * -Multiple Text Nodes with Attributes
- * <a><b - * d="v3" e="v5">v1</b><b d="v4" e="v6">v2</b></a> => - * {a={b=[{#text=v1, d=v3, e=v5}, {#text=v2, d=v4, e=v6}]}}
- *
+ * -Multiple Text Nodes with Attributes
+ * {@literal v1v2 => {a={b=[{#text=v1, d=v3, e=v5}, {#text=v2, d=v4, e=v6}]}}}
+ *
* - * -Element Node Attributes
- * <a c="v1" d="v2"><b>v3</b></a> - * => {a={b=v3}, c=v1, d=v2}
- *
+ * -Element Node Attributes
+ * {@literal v3 => {a={b=v3}, c=v1, d=v2}}
+ *
* * @author kannoth * diff --git a/src/main/java/org/eclipse/basyx/vab/gateway/ConnectorProviderMapper.java b/src/main/java/org/eclipse/basyx/vab/gateway/ConnectorProviderMapper.java index 35fd2abd..54120a35 100644 --- a/src/main/java/org/eclipse/basyx/vab/gateway/ConnectorProviderMapper.java +++ b/src/main/java/org/eclipse/basyx/vab/gateway/ConnectorProviderMapper.java @@ -17,7 +17,7 @@ /** * Maps an incoming address to an IConnectorProvider based on the protocol used - * in the path
+ * in the path
* E.g. basyx://* can be mapped to the BasyxNative connector, http://* can be * mapped to the HTTP/REST connector * diff --git a/src/main/java/org/eclipse/basyx/vab/model/VABModelMap.java b/src/main/java/org/eclipse/basyx/vab/model/VABModelMap.java index 7acec691..92e3dae8 100644 --- a/src/main/java/org/eclipse/basyx/vab/model/VABModelMap.java +++ b/src/main/java/org/eclipse/basyx/vab/model/VABModelMap.java @@ -94,7 +94,7 @@ public > T putPath(String path, Object v } /** - * Get element from qualified path
+ * Get element from qualified path
* To retrieve the root element, use "" as path * * @param path diff --git a/src/main/java/org/eclipse/basyx/vab/modelprovider/VABElementProxy.java b/src/main/java/org/eclipse/basyx/vab/modelprovider/VABElementProxy.java index c70f149a..bfcf1493 100644 --- a/src/main/java/org/eclipse/basyx/vab/modelprovider/VABElementProxy.java +++ b/src/main/java/org/eclipse/basyx/vab/modelprovider/VABElementProxy.java @@ -69,9 +69,9 @@ public Object getValue(String elementPath) throws ProviderException { } /** - * Update VAB element value
- *
- * If the element does not exist it will be created
+ * Update VAB element value
+ *
+ * If the element does not exist it will be created
*/ @Override public void setValue(String elementPath, Object newValue) throws ProviderException { diff --git a/src/main/java/org/eclipse/basyx/vab/modelprovider/VABPathTools.java b/src/main/java/org/eclipse/basyx/vab/modelprovider/VABPathTools.java index 6657bbab..b78675f4 100644 --- a/src/main/java/org/eclipse/basyx/vab/modelprovider/VABPathTools.java +++ b/src/main/java/org/eclipse/basyx/vab/modelprovider/VABPathTools.java @@ -61,7 +61,7 @@ public static String encodePathElement(String elem) { /** * Decodes sensitive characters, e.g. "/" and "#" * - * @param elem + * @param encodedElem * @return */ public static String decodePathElement(String encodedElem) { @@ -110,7 +110,7 @@ public static String getEntry(String path, int entry) { } /** - * Split a path into path elements, e.g. /a/b/c -> [ a, b, c ] + * Split a path into path elements, e.g. /a/b/c {@literal ->} [ a, b, c ] */ public static String[] splitPath(String path) { // Return null result for null argument @@ -273,7 +273,7 @@ public static boolean isEmptyPath(String path) { /** * Gets the first endpoint of a path. - * @path + * @param fullPath * A path that can contain 0..* endpoints. * @return * The first address entry of a path. The address entry is the first endpoint combined with a protocol. @@ -297,8 +297,8 @@ public static String getFirstEndpoint(String fullPath) { } /** - * Removes the first endpoint from a path. See {@link #getAddressEntry}
- * @path + * Removes the first endpoint from a path.
+ * @param fullPath * @return * The first endpoint. E.g. basyx://127.0.0.1:6998//https://localhost/test/ will return * https://localhost/test/. @@ -381,11 +381,21 @@ public static void checkPathForNull(String path) throws MalformedRequestExceptio * @return path without last element "invoke" or unchanged path */ public static String stripInvokeFromPath(String path) { - + return stripFromPath(path, Operation.INVOKE); + } + + /** + * Strips the last path element if it is elementToStrip + * + * @param path + * @param elementToStrip + * @return path without last element "invoke" or unchanged path + */ + public static String stripFromPath(String path, String elementToStrip) { if(path == null) return null; - if(getLastElement(path).startsWith(Operation.INVOKE)) { + if(getLastElement(path).startsWith(elementToStrip)) { return getParentPath(path); } diff --git a/src/main/java/org/eclipse/basyx/vab/modelprovider/consistency/ConsistencyProvider.java b/src/main/java/org/eclipse/basyx/vab/modelprovider/consistency/ConsistencyProvider.java index 04b3a3f5..6b8c14a1 100644 --- a/src/main/java/org/eclipse/basyx/vab/modelprovider/consistency/ConsistencyProvider.java +++ b/src/main/java/org/eclipse/basyx/vab/modelprovider/consistency/ConsistencyProvider.java @@ -76,7 +76,7 @@ public Object getValue(String path) throws ProviderException { * * @param path * @param newValue - * @throws Exception + * @throws ProviderException */ @Override public void setValue(String path, Object newValue) throws ProviderException { @@ -105,7 +105,7 @@ public void setValue(String path, Object newValue) throws ProviderException { * * @param path * @param newEntity - * @throws Exception + * @throws ProviderException */ @Override public void createValue(String path, Object newEntity) throws ProviderException { @@ -119,7 +119,7 @@ public void createValue(String path, Object newEntity) throws ProviderException * Delete entity, check if submodel is frozen * * @param path - * @throws Exception + * @throws ProviderException */ @Override public void deleteValue(String path) throws ProviderException { @@ -136,7 +136,7 @@ public void deleteValue(String path) throws ProviderException { * * @param path * @param obj - * @throws Exception + * @throws ProviderException */ @Override public void deleteValue(String path, Object obj) throws ProviderException { @@ -160,7 +160,7 @@ public void deleteValue(String path, Object obj) throws ProviderException { * @param path * @param parameter * @return - * @throws Exception + * @throws ProviderException */ @Override public Object invokeOperation(String path, Object... parameter) throws ProviderException { diff --git a/src/main/java/org/eclipse/basyx/vab/modelprovider/filesystem/FileSystemProvider.java b/src/main/java/org/eclipse/basyx/vab/modelprovider/filesystem/FileSystemProvider.java index 90066813..6a3920d4 100644 --- a/src/main/java/org/eclipse/basyx/vab/modelprovider/filesystem/FileSystemProvider.java +++ b/src/main/java/org/eclipse/basyx/vab/modelprovider/filesystem/FileSystemProvider.java @@ -415,7 +415,7 @@ public synchronized void setValue(String path, Object newValue) throws ProviderE /** * Creates newEntity at the specified path - * If a collection exists at the specified path, add newEntity to it >IF< newEntity is not a collection + * If a collection exists at the specified path, add newEntity to it IF newEntity is not a collection */ @Override @SuppressWarnings("unchecked") diff --git a/src/main/java/org/eclipse/basyx/vab/modelprovider/filesystem/filesystem/FileSystem.java b/src/main/java/org/eclipse/basyx/vab/modelprovider/filesystem/filesystem/FileSystem.java index 0fe54b13..af068640 100644 --- a/src/main/java/org/eclipse/basyx/vab/modelprovider/filesystem/filesystem/FileSystem.java +++ b/src/main/java/org/eclipse/basyx/vab/modelprovider/filesystem/filesystem/FileSystem.java @@ -20,7 +20,7 @@ public interface FileSystem { /** - * Reads the content of a file formated as UTF-8.
+ * Reads the content of a file formated as UTF-8.
* Throws an Exception if file at given path does not exist. * * @param path @@ -30,9 +30,9 @@ public interface FileSystem { public String readFile(String path) throws IOException; /** - * Writes a given String to a file at the given path.
- * Does create the file if it does not exist.
- * Does overwrite the file if it exists.
+ * Writes a given String to a file at the given path.
+ * Does create the file if it does not exist.
+ * Does overwrite the file if it exists.
* Does not create nonexistent parent directories. * * @param path @@ -42,7 +42,7 @@ public interface FileSystem { public void writeFile(String path, String content) throws IOException; /** - * Deletes the file at the specified path.
+ * Deletes the file at the specified path.
* Does not throw an Exception if the file does not exist. * * @param path @@ -51,7 +51,7 @@ public interface FileSystem { public void deleteFile(String path) throws IOException; /** - * Creates a directory at the given path.
+ * Creates a directory at the given path.
* Creates parent directories if nonexistent. * * @param path @@ -60,7 +60,7 @@ public interface FileSystem { public void createDirectory(String path) throws IOException; /** - * Lists all directories and files at the specified path.
+ * Lists all directories and files at the specified path.
* Does not list items in subdirectories. * * @param path @@ -71,7 +71,7 @@ public interface FileSystem { /** * Deletes the directory at the specified path, - * including contained files and subdirectories.
+ * including contained files and subdirectories.
* Does not throw an Exception if directory at path does not exist. * * @param path diff --git a/src/main/java/org/eclipse/basyx/vab/modelprovider/generic/VABModelProvider.java b/src/main/java/org/eclipse/basyx/vab/modelprovider/generic/VABModelProvider.java index f1dc60be..d87c12ed 100644 --- a/src/main/java/org/eclipse/basyx/vab/modelprovider/generic/VABModelProvider.java +++ b/src/main/java/org/eclipse/basyx/vab/modelprovider/generic/VABModelProvider.java @@ -9,9 +9,12 @@ ******************************************************************************/ package org.eclipse.basyx.vab.modelprovider.generic; +import java.util.function.Consumer; import java.util.function.Function; +import java.util.function.Supplier; import org.eclipse.basyx.vab.exception.provider.NotAnInvokableException; +import org.eclipse.basyx.vab.exception.provider.ProviderException; import org.eclipse.basyx.vab.exception.provider.ResourceAlreadyExistsException; import org.eclipse.basyx.vab.exception.provider.ResourceNotFoundException; import org.eclipse.basyx.vab.modelprovider.VABPathTools; @@ -105,7 +108,6 @@ public void deleteValue(String path, Object obj) { handler.deleteValue(targetElement, obj); } - @SuppressWarnings("unchecked") @Override public Object invokeOperation(String path, Object... parameters) { @@ -115,13 +117,67 @@ public Object invokeOperation(String path, Object... parameters) { // Invoke operation for function interfaces if (childElement instanceof Function) { - Function function = (Function) childElement; - return function.apply(parameters); + return runFunction(childElement, parameters); + } else if (childElement instanceof Supplier) { + return runSupplier(childElement); + } else if (childElement instanceof Consumer) { + return runConsumer(childElement, parameters); + } else if (childElement instanceof Runnable) { + return runRunnable(childElement); } else { throw new NotAnInvokableException("Element \"" + path + "\" is not a function."); } } + private Object runRunnable(Object childElement) { + Runnable runnable = (Runnable) childElement; + try { + runnable.run(); + } catch (ProviderException e) { + throw e; + } catch (Exception e) { + throw new ProviderException(e); + } + return null; + } + + @SuppressWarnings("unchecked") + private Object runConsumer(Object childElement, Object... parameters) { + Consumer consumer = (Consumer) childElement; + try { + consumer.accept(parameters); + } catch (ProviderException e) { + throw e; + } catch (Exception e) { + throw new ProviderException(e); + } + return null; + } + + @SuppressWarnings("unchecked") + private Object runSupplier(Object childElement) { + Supplier supplier = (Supplier) childElement; + try { + return supplier.get(); + } catch (ProviderException e) { + throw e; + } catch (Exception e) { + throw new ProviderException(e); + } + } + + @SuppressWarnings("unchecked") + private Object runFunction(Object childElement, Object... parameters) { + Function function = (Function) childElement; + try { + return function.apply(parameters); + } catch (ProviderException e) { + throw e; + } catch (Exception e) { + throw new ProviderException(e); + } + } + /** * Get the parent of an element in this provider. The path should include the path to the element separated by '/'. * E.g., for accessing element c in path a/b, the path should be a/b/c. diff --git a/src/main/java/org/eclipse/basyx/vab/modelprovider/lambda/VABLambdaProvider.java b/src/main/java/org/eclipse/basyx/vab/modelprovider/lambda/VABLambdaProvider.java index 0e67bc86..2708bbbb 100644 --- a/src/main/java/org/eclipse/basyx/vab/modelprovider/lambda/VABLambdaProvider.java +++ b/src/main/java/org/eclipse/basyx/vab/modelprovider/lambda/VABLambdaProvider.java @@ -15,11 +15,11 @@ /** * Provider that optionally allows properties to be modifiable by hidden - * set/get/insert/remove property.
- * Supports nested lambda-expressions.
- * E.g.:
+ * set/get/insert/remove property.
+ * Supports nested lambda-expressions.
+ * E.g.:
* GET $path is internally delegated to a call of $path/get which is a - * {@link java.util.function.Consumer}.
+ * {@link java.util.function.Consumer}.
* SET $path is delegated to $path/set which is a * {@link java.util.function.Supplier}. * diff --git a/src/main/java/org/eclipse/basyx/vab/protocol/basyx/server/BaSyxTCPServer.java b/src/main/java/org/eclipse/basyx/vab/protocol/basyx/server/BaSyxTCPServer.java index 4af9d106..6063df38 100644 --- a/src/main/java/org/eclipse/basyx/vab/protocol/basyx/server/BaSyxTCPServer.java +++ b/src/main/java/org/eclipse/basyx/vab/protocol/basyx/server/BaSyxTCPServer.java @@ -146,8 +146,6 @@ public void acceptIncomingConnection() { /** * End server - * - * @throws IOException */ protected void shutdown() { // End thread diff --git a/src/main/java/org/eclipse/basyx/vab/protocol/http/connector/HTTPConnector.java b/src/main/java/org/eclipse/basyx/vab/protocol/http/connector/HTTPConnector.java index b1faddd6..d987698b 100644 --- a/src/main/java/org/eclipse/basyx/vab/protocol/http/connector/HTTPConnector.java +++ b/src/main/java/org/eclipse/basyx/vab/protocol/http/connector/HTTPConnector.java @@ -9,6 +9,9 @@ ******************************************************************************/ package org.eclipse.basyx.vab.protocol.http.connector; +import java.util.List; +import java.util.Map; + import javax.ws.rs.client.Client; import javax.ws.rs.client.ClientBuilder; import javax.ws.rs.client.Entity; @@ -18,6 +21,9 @@ import javax.ws.rs.core.Response; import javax.ws.rs.core.Response.Status; +import org.eclipse.basyx.vab.coder.json.metaprotocol.Message; +import org.eclipse.basyx.vab.coder.json.metaprotocol.MessageType; +import org.eclipse.basyx.vab.coder.json.metaprotocol.Result; import org.eclipse.basyx.vab.exception.provider.ProviderException; import org.eclipse.basyx.vab.modelprovider.VABPathTools; import org.eclipse.basyx.vab.protocol.api.IBaSyxConnector; @@ -26,6 +32,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.google.gson.Gson; + import io.netty.handler.codec.http.HttpMethod; /** @@ -45,8 +53,6 @@ public class HTTPConnector implements IBaSyxConnector { /** * Invoke a BaSys get operation via HTTP GET * - * @param address - * the server address from the directory * @param servicePath * the URL suffix for the requested path * @return the requested object @@ -74,8 +80,6 @@ public HTTPConnector(String address, String mediaType) { * Invokes BasysPut method via HTTP PUT. Overrides existing property, operation * or event. * - * @param address - * the server address from the directory * @param servicePath * the URL suffix for the requested property, operation or event * @param newValue @@ -91,8 +95,6 @@ public String setValue(String servicePath, String newValue) throws ProviderExcep * Invoke a BaSys Delete operation via HTTP PATCH. Deletes an element from a map * or collection by key * - * @param address - * the server address from the directory * @param servicePath * the URL suffix for the requested property * @param obj @@ -176,7 +178,7 @@ private String httpGet(String servicePath) throws ProviderException { rsp = request.get(); } finally { if (!isRequestSuccess(rsp)) { - throw this.handleProcessingException(HttpMethod.GET, getStatusCode(rsp)); + throw this.handleProcessingException(HttpMethod.GET, rsp); } } @@ -195,7 +197,7 @@ private String httpPut(String servicePath, String newValue) throws ProviderExcep rsp = request.put(Entity.entity(newValue, mediaType)); } finally { if (!isRequestSuccess(rsp)) { - throw this.handleProcessingException(HttpMethod.PUT, getStatusCode(rsp)); + throw this.handleProcessingException(HttpMethod.PUT, rsp); } } @@ -213,7 +215,7 @@ private String httpPatch(String servicePath, String newValue) throws ProviderExc rsp = this.client.target(VABPathTools.concatenatePaths(address, servicePath)).request().build("PATCH", Entity.text(newValue)).property(HttpUrlConnectorProvider.SET_METHOD_WORKAROUND, true).invoke(); } finally { if (!isRequestSuccess(rsp)) { - throw this.handleProcessingException(HttpMethod.PATCH, getStatusCode(rsp)); + throw this.handleProcessingException(HttpMethod.PATCH, rsp); } } @@ -232,7 +234,7 @@ private String httpPost(String servicePath, String parameter) throws ProviderExc rsp = request.post(Entity.entity(parameter, mediaType)); } finally { if (!isRequestSuccess(rsp)) { - throw this.handleProcessingException(HttpMethod.POST, getStatusCode(rsp)); + throw this.handleProcessingException(HttpMethod.POST, rsp); } } @@ -251,7 +253,7 @@ private String httpDelete(String servicePath) throws ProviderException { rsp = request.delete(); } finally { if (!isRequestSuccess(rsp)) { - throw this.handleProcessingException(HttpMethod.DELETE, getStatusCode(rsp)); + throw this.handleProcessingException(HttpMethod.DELETE, rsp); } } @@ -275,12 +277,21 @@ private Builder retrieveBuilder(String servicePath) { return buildRequest(client, VABPathTools.concatenatePaths(address, servicePath)); } - private ProviderException handleProcessingException(HttpMethod method, int statusCode) { - if (statusCode == 0) { - return ExceptionToHTTPCodeMapper.mapToException(404, "[HTTP " + method.name() + "] Failed to request " + this.address + " with mediatype " + this.mediaType); - } else { - return ExceptionToHTTPCodeMapper.mapToException(statusCode, "[HTTP " + method.name() + "] Failed to request " + this.address + " with mediatype " + this.mediaType); + private ProviderException handleProcessingException(HttpMethod method, Response rsp) { + if(rsp == null) { + return ExceptionToHTTPCodeMapper.mapToException(404, buildMessageString(method.name(), null)); } + + int statusCode = getStatusCode(rsp); + String responseJson = rsp.readEntity(String.class); + + Result result = buildResultFromJSON(responseJson); + + List messages = result.getMessages(); + messages.add(new Message(MessageType.Exception, buildMessageString(method.name(), result))); + + ProviderException e = ExceptionToHTTPCodeMapper.mapToException(statusCode, result.getMessages()); + return e; } /** @@ -310,4 +321,43 @@ private boolean isRequestSuccess(Response rsp) { public String getEndpointRepresentation(String path) { return VABPathTools.concatenatePaths(address, path); } + + /** + * Builds a Result object from the json response + * + * @param json The json response + * @return Result + */ + @SuppressWarnings("unchecked") + private Result buildResultFromJSON(String json) { + Gson gson = new Gson(); + Map map = gson.fromJson(json, Map.class); + return Result.createAsFacade(map); + } + + /** + * Builds the text for the message about the failed HTTP Request + * + * @param methodName the HTTP method that failed + * @param result the Messages returned by the server if any + * @return the message text + */ + private String buildMessageString(String methodName, Result result) { + String message = "[HTTP " + methodName + "] Failed to request " + this.address + " with mediatype " + this.mediaType; + + if(result == null) { + return message; + } + + String text = ""; + if(result.getMessages().size() > 0) { + text = result.getMessages().get(0).getText(); + } + + if(!text.isEmpty()) { + message += ". \"" + text + "\""; + } + + return message; + } } diff --git a/src/main/java/org/eclipse/basyx/vab/protocol/http/helper/HTTPUploadHelper.java b/src/main/java/org/eclipse/basyx/vab/protocol/http/helper/HTTPUploadHelper.java new file mode 100644 index 00000000..70fc3e3c --- /dev/null +++ b/src/main/java/org/eclipse/basyx/vab/protocol/http/helper/HTTPUploadHelper.java @@ -0,0 +1,91 @@ +/******************************************************************************* +* Copyright (C) 2021 the Eclipse BaSyx Authors +* +* This program and the accompanying materials are made +* available under the terms of the Eclipse Public License 2.0 +* which is available at https://www.eclipse.org/legal/epl-2.0/ + +* +* SPDX-License-Identifier: EPL-2.0 +******************************************************************************/ + +package org.eclipse.basyx.vab.protocol.http.helper; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; + +import org.apache.http.HttpEntity; +import org.apache.http.client.ClientProtocolException; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.entity.ContentType; +import org.apache.http.entity.mime.MultipartEntityBuilder; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; + +/** + * Helper class to achieve http post multipart/form-data upload + * @author haque + * + */ +public class HTTPUploadHelper { + + /** + * Uploads the given stream to the given API URL + * @param stream + * @param url + * @throws IOException + * @throws ClientProtocolException + */ + public static void uploadHTTPPost(InputStream stream, String url) throws ClientProtocolException, IOException { + CloseableHttpClient httpClient = getDefautlHTTPClient(); + HttpPost uploadPost = getUploadHTTPPost(stream, url); + executeUploadHTTPPost(httpClient, uploadPost); + } + + /** + * Executes the client with multipart entity as a post file + * @param httpClient + * @param uploadFile + * @throws ClientProtocolException + * @throws IOException + */ + private static void executeUploadHTTPPost(CloseableHttpClient httpClient, HttpPost uploadFile) throws ClientProtocolException, IOException { + CloseableHttpResponse response = httpClient.execute(uploadFile); + response.close(); + } + + /** + * Gets a default HTTP client + * @return + * @throws FileNotFoundException + */ + private static CloseableHttpClient getDefautlHTTPClient() throws FileNotFoundException { + CloseableHttpClient httpClient = HttpClients.createDefault(); + return httpClient; + } + + /** + * Gets HTTP post with a multipart entity + * @param stream + * @param url + * @return + * @throws FileNotFoundException + */ + private static HttpPost getUploadHTTPPost(InputStream stream, String url) throws FileNotFoundException { + HttpPost uploadFile = new HttpPost(url); + MultipartEntityBuilder builder = MultipartEntityBuilder.create(); + + builder.addBinaryBody( + "file", + stream, + ContentType.MULTIPART_FORM_DATA, + "" + ); + + HttpEntity multipart = builder.build(); + uploadFile.setEntity(multipart); + return uploadFile; + } +} diff --git a/src/main/java/org/eclipse/basyx/vab/protocol/http/server/BaSyxContext.java b/src/main/java/org/eclipse/basyx/vab/protocol/http/server/BaSyxContext.java index 9b776907..8b62e8a8 100644 --- a/src/main/java/org/eclipse/basyx/vab/protocol/http/server/BaSyxContext.java +++ b/src/main/java/org/eclipse/basyx/vab/protocol/http/server/BaSyxContext.java @@ -177,6 +177,20 @@ public int getPort() { return port; } + /** + * Return Tomcat context path. + */ + public String getContextPath() { + return contextPath; + } + + /** + * Return Tomcat hostname. + */ + public String getHostname() { + return hostname; + } + /** * Returns whether the secured connection enabled or not * @return diff --git a/src/main/java/org/eclipse/basyx/vab/protocol/http/server/ExceptionToHTTPCodeMapper.java b/src/main/java/org/eclipse/basyx/vab/protocol/http/server/ExceptionToHTTPCodeMapper.java index bcc23ea8..107979cc 100644 --- a/src/main/java/org/eclipse/basyx/vab/protocol/http/server/ExceptionToHTTPCodeMapper.java +++ b/src/main/java/org/eclipse/basyx/vab/protocol/http/server/ExceptionToHTTPCodeMapper.java @@ -9,6 +9,9 @@ ******************************************************************************/ package org.eclipse.basyx.vab.protocol.http.server; +import java.util.List; + +import org.eclipse.basyx.vab.coder.json.metaprotocol.Message; import org.eclipse.basyx.vab.exception.provider.MalformedRequestException; import org.eclipse.basyx.vab.exception.provider.ProviderException; import org.eclipse.basyx.vab.exception.provider.ResourceAlreadyExistsException; @@ -63,4 +66,25 @@ public static ProviderException mapToException(int statusCode, String text) { } + /** + * Maps HTTP-Codes to ProviderExceptions + * + * @param statusCode The received HTTP-code + * @return the corresponding ProviderException + */ + public static ProviderException mapToException(int statusCode, List messages) { + + switch(statusCode) { + case 400: + return new MalformedRequestException(messages); + case 422: + return new ResourceAlreadyExistsException(messages); + case 404: + return new ResourceNotFoundException(messages); + default: + return new ProviderException(messages); + } + + } + } diff --git a/src/main/java/org/eclipse/basyx/vab/protocol/http/server/VABHTTPInterface.java b/src/main/java/org/eclipse/basyx/vab/protocol/http/server/VABHTTPInterface.java index 00900ae7..4e34ff79 100644 --- a/src/main/java/org/eclipse/basyx/vab/protocol/http/server/VABHTTPInterface.java +++ b/src/main/java/org/eclipse/basyx/vab/protocol/http/server/VABHTTPInterface.java @@ -14,6 +14,7 @@ import java.io.PrintWriter; import java.io.UnsupportedEncodingException; import java.util.ArrayList; +import java.util.Collection; import java.util.Enumeration; import java.util.List; import java.util.StringJoiner; @@ -22,6 +23,10 @@ import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; +import org.apache.commons.fileupload.FileItem; +import org.apache.commons.fileupload.FileUploadException; +import org.apache.commons.fileupload.disk.DiskFileItemFactory; +import org.apache.commons.fileupload.servlet.ServletFileUpload; import org.eclipse.basyx.vab.coder.json.provider.JSONProvider; import org.eclipse.basyx.vab.exception.provider.MalformedRequestException; import org.eclipse.basyx.vab.exception.provider.ProviderException; @@ -36,17 +41,17 @@ /** * VAB provider class that enables access to an IModelProvider via HTTP REST - * interface
- *
- * REST http interface is as following:
+ * interface
+ *
+ * REST http interface is as following:
* - GET /aas/submodels/{subModelId} Retrieves submodel with ID {subModelId} - *
+ *
* - GET /aas/submodels/{subModelId}/properties/a Retrieve property a of - * submodel {subModelId}
+ * submodel {subModelId}
* - GET /aas/submodels/{subModelId}/properties/a/b Retrieve property a/b of - * submodel {subModelId}
+ * submodel {subModelId}
* - POST /aas/submodels/{subModelId}/operations/a Invoke operation a of - * submodel {subModelId}
+ * submodel {subModelId}
* - POST /aas/submodels/{subModelId}/operations/a/b Invoke operation a/b of * submodel {subModelId} * @@ -147,32 +152,20 @@ protected void doPut(HttpServletRequest req, HttpServletResponse resp) throws Se /** - *
 	 * Handle HTTP POST operation. Creates a new Property, Operation, Event,
-	 * Submodel or AAS or invokes an operation.
+	 * Submodel or AAS or AASX or invokes an operation.
 	 */
 	@Override
 	protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
 		try {
 			String path = extractPath(req);
-			String serValue = extractSerializedValue(req);
-
-			logger.trace("DoPost: {}", serValue);
-
-			// Setup HTML response header
-			resp.setStatus(201);
-			resp.setContentType("application/json");
-			resp.setCharacterEncoding("UTF-8");
-
-			// Check if request is for property creation or operation invoke
-			if (VABPathTools.isOperationInvokationPath(path)) {
-			// Invoke BaSys VAB 'invoke' primitive
-
-				providerBackend.processBaSysInvoke(path, serValue, resp.getOutputStream());
+			
+			setPostResponseHeader(resp);
 
+			if (ServletFileUpload.isMultipartContent(req)) {
+				handleMultipartFormDataRequest(req, path, resp);
 			} else {
-			// Invoke the BaSys 'create' primitive
-				providerBackend.processBaSysCreate(path, serValue, resp.getOutputStream());
+				handleJSONPostRequest(req, path, resp);
 			}
 		} catch (ProviderException e) {
 			int httpCode = ExceptionToHTTPCodeMapper.mapFromException(e);
@@ -300,13 +293,90 @@ private int getEnvironmentPathSize(HttpServletRequest req) {
 	 */
 	private String extractSerializedValue(HttpServletRequest req) throws IOException {
 		// https://www.baeldung.com/convert-input-stream-to-string#guava
-		ByteSource byteSource = new ByteSource() {
+        return getByteSource(req).asCharSource(Charsets.UTF_8).read();
+	}
+	
+	/**
+	 * Extracts input streams from request
+	 * @param req
+	 * @return
+	 * @throws IOException
+	 * @throws ServletException 
+	 */
+	private Collection extractInputStreams(HttpServletRequest req) throws IOException, ServletException{
+		Collection fileStreams = new ArrayList();
+		try {
+	        List items = new ServletFileUpload(new DiskFileItemFactory()).parseRequest(req);
+	        for (FileItem item : items) {
+	            if (!item.isFormField()) {
+	                fileStreams.add(item.getInputStream());
+	            }
+	        }
+	    } catch (FileUploadException e) {
+	    	throw new ServletException("Cannot parse multipart request.", e);
+	    }
+		return fileStreams;
+	}
+	
+	/**
+	 * Gets a {@link ByteSource} from request stream
+	 * @return
+	 */
+	private ByteSource getByteSource(HttpServletRequest req) {
+		return new ByteSource() {
 	        @Override
 	        public InputStream openStream() throws IOException {
 	            return req.getInputStream();
 	        }
 	    };
-	 
-	    return byteSource.asCharSource(Charsets.UTF_8).read();
+	}
+	
+	/**
+	 * Setup HTML response header for HttpPost
+	 * @param resp
+	 */
+	private void setPostResponseHeader(HttpServletResponse resp) {
+		resp.setStatus(201);
+		resp.setContentType("application/json");
+		resp.setCharacterEncoding("UTF-8");
+	}
+
+
+	/**
+	 * Handles multipart/form-data request in HttpPost
+	 * @param req
+	 * @param path
+	 * @param resp
+	 * @throws IOException
+	 * @throws ServletException 
+	 */
+	private void handleMultipartFormDataRequest(HttpServletRequest req, String path, HttpServletResponse resp) throws IOException, ServletException {
+		Collection fileStreams = extractInputStreams(req);	
+		for (InputStream fileStream : fileStreams) {
+		    providerBackend.processBaSysUpload(path, fileStream, resp.getOutputStream());
+		    fileStream.close();	
+		}
+	}
+
+
+	/**
+	 * Handles POST request with JSON body
+	 * @param req
+	 * @param path
+	 * @param resp
+	 * @throws IOException
+	 */
+	private void handleJSONPostRequest(HttpServletRequest req, String path, HttpServletResponse resp) throws IOException {
+		String serValue = extractSerializedValue(req);
+		logger.trace("DoPost: {}", serValue);
+		
+		// Check if request is for property creation or operation invoke
+		if (VABPathTools.isOperationInvokationPath(path)) {
+			// Invoke BaSys VAB 'invoke' primitive
+			providerBackend.processBaSysInvoke(path, serValue, resp.getOutputStream());
+		} else {
+			// Invoke the BaSys 'create' primitive
+			providerBackend.processBaSysCreate(path, serValue, resp.getOutputStream());
+		}
 	}
 }
diff --git a/src/main/java/org/eclipse/basyx/vab/protocol/opcua/CertificateHelper.java b/src/main/java/org/eclipse/basyx/vab/protocol/opcua/CertificateHelper.java
new file mode 100644
index 00000000..8624a401
--- /dev/null
+++ b/src/main/java/org/eclipse/basyx/vab/protocol/opcua/CertificateHelper.java
@@ -0,0 +1,270 @@
+/*******************************************************************************
+ * Copyright (C) 2021 Festo Didactic SE
+ *
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ ******************************************************************************/
+package org.eclipse.basyx.vab.protocol.opcua;
+
+import java.net.InetAddress;
+import java.security.KeyPair;
+import java.security.NoSuchAlgorithmException;
+import java.security.cert.CertificateException;
+import java.security.cert.X509Certificate;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CopyOnWriteArraySet;
+
+import org.eclipse.milo.opcua.stack.core.util.SelfSignedCertificateBuilder;
+import org.eclipse.milo.opcua.stack.core.util.SelfSignedCertificateGenerator;
+
+/**
+ * Builds self-signed X.509 certificates.
+ */
+public class CertificateHelper {
+    private boolean buildDefault = true;
+    private KeyPair keyPair;
+    private X509Certificate certificate;
+    private String commonName;
+    private String organization;
+    private String organizationalUnit;
+    private String locality;
+    private String state;
+    private String countryCode;
+    private String applicationUri;
+    private Set ipAddresses = new HashSet<>();
+    private Set dnsNames = new CopyOnWriteArraySet<>();
+
+    /**
+     * Creates a new {@link CertificateHelper}.
+     *
+     * 

+ * This constructor generates a new RSA-2048 key pair used for signing the certificate. That's why + * it might take an appreciable amount of time to return. + */ + public CertificateHelper() { + try { + // We use RSA 2048 because it is well-supported and can't fail. + keyPair = SelfSignedCertificateGenerator.generateRsaKeyPair(2048); + } catch (NoSuchAlgorithmException impossible) { + throw new AssertionError("Every Java implementation is required to implement 2048 bit RSA", impossible); + } + } + + /** + * Gets the generated key pair. + * + * @return The generated key pair. + * + * @throws IllegalStateException if called before {@link #build()}. + */ + public KeyPair getKeyPair() { + if (certificate == null) { + throw new IllegalStateException("Must build certificate first."); + } + + return keyPair; + } + + /** + * Gets the generated self-signed certificate. + * + * @return The generated certificate. + * + * @throws IllegalStateException if called before {@link #build()}. + */ + public X509Certificate getCertificate() { + if (certificate == null) { + throw new IllegalStateException("Must build certificate first."); + } + + return certificate; + } + + /** + * Sets the certificate's common name (CN) field. + * + * @param commonName The certificate's common name. + * + * @return This {@link CertificateHelper}. + */ + public CertificateHelper setCommonName(String commonName) { + buildDefault = false; + this.commonName = commonName; + return this; + } + + /** + * Sets the certificate's organization (O) field. + * + * @param organization The certificate's organization. + * + * @return This {@link CertificateHelper}. + */ + public CertificateHelper setOrganization(String organization) { + buildDefault = false; + this.organization = organization; + return this; + } + + /** + * Sets the certificate's organizational unit (OU) field. + * + * @param organizationalUnit The certificate's organizational unit. + * + * @return This {@link CertificateHelper}. + */ + public CertificateHelper setOrganizationalUnit(String organizationalUnit) { + buildDefault = false; + this.organizationalUnit = organizationalUnit; + return this; + } + + /** + * Sets the certificate's locality (L) field. + * + * @param locality The certificate's locality. + * + * @return This {@link CertificateHelper}. + */ + public CertificateHelper setLocality(String locality) { + buildDefault = false; + this.locality = locality; + return this; + } + + /** + * Sets the certificate's state (ST) field. + * + * @param state The certificate's state or region. + * + * @return This {@link CertificateHelper}. + */ + public CertificateHelper setState(String state) { + buildDefault = false; + this.state = state; + return this; + } + + /** + * Sets the certificate's country code (C) field. + * + * @param countryCode The certificate's country code. + * + * @return This {@link CertificateHelper}. + */ + public CertificateHelper setCountryCode(String countryCode) { + buildDefault = false; + this.countryCode = countryCode; + return this; + } + + /** + * Sets the certificate's application URI which will be added as a subject alternative name. + * + * @param applicationUri The certificate's application URI. + * + * @return This {@link CertificateHelper}. + */ + public CertificateHelper setApplicationUri(String applicationUri) { + buildDefault = false; + this.applicationUri = applicationUri; + return this; + } + + /** + * Adds a DNS name as a subject alternative name to the certificate. + * + *

+ * If you're adding an IP address to the certificate as well, you can use + * {@link #addIpAddress(InetAddress, boolean)} to add the associated host name automatically, + * without manually calling this method. However, see that method's documentation for important + * limitations. + * + * @param dnsName The name to add. Can be any kind of host name or FQDN. + * + * @return This {@link CertificateHelper}. + */ + public CertificateHelper addDnsName(String dnsName) { + buildDefault = false; + dnsNames.add(dnsName); + return this; + } + + /** + * Adds an IP address as a subject alternative name to the certificate. + * + *

+ * Optionally, this method can also attempt to lookup the matching host name in the background. If + * successful, the name will be added to the certificate.
+ * If the lookup fails or doesn't finish until {@link #build()} is called, the name won't be added. + *
+ * To guarantee the host name is added use {@link #addDnsName(String)}, instead. + * + * @param ipAddress The address to add. + * @param lookupHostName If true, the host name will be looked up in the background and added to the + * certificate. + * + * @return This {@link CertificateHelper}. + */ + public CertificateHelper addIpAddress(InetAddress ipAddress, boolean lookupHostName) { + buildDefault = false; + ipAddresses.add(ipAddress); + + if (lookupHostName) { + CompletableFuture.supplyAsync(ipAddress::getHostName) + .thenAccept(hostName -> dnsNames.add(hostName)); + } + + return this; + } + + /** + * Builds a self-signed certificate from the information previously provided to this helper. + * + *

+ * If no information has been provided, a default certificate is generated automatically. The + * default certificate carries only the common name CN=Unknown. + * + *

+ * After this method returns, the certificate can be acquired through {@link #getCertificate()}. + * + * @throws CertificateException if certificate generation fails. + */ + public void build() throws CertificateException { + try { + SelfSignedCertificateBuilder builder = buildDefault ? configureDefaultBuilder() + : configureBuilderWithInfo(); + certificate = builder.build(); + } catch (Exception e) { + throw new CertificateException("Failed to create self-signed certificate.", e); + } + } + + /** Returns a builder for a default certificate (CN=Unknown). */ + private SelfSignedCertificateBuilder configureDefaultBuilder() { + return new SelfSignedCertificateBuilder(keyPair) + .setCommonName("Unknown"); + } + + /** Returns a builder for a certificate with the user-provided information. */ + private SelfSignedCertificateBuilder configureBuilderWithInfo() { + SelfSignedCertificateBuilder builder = new SelfSignedCertificateBuilder(keyPair) + .setCommonName(commonName) + .setOrganization(organization) + .setOrganizationalUnit(organizationalUnit) + .setLocalityName(locality) + .setStateName(state) + .setCountryCode(countryCode) + .setApplicationUri(applicationUri); + + ipAddresses.forEach(ip -> builder.addIpAddress(ip.getHostName())); + dnsNames.forEach(builder::addDnsName); + + return builder; + } +} diff --git a/src/main/java/org/eclipse/basyx/vab/protocol/opcua/connector/ClientConfiguration.java b/src/main/java/org/eclipse/basyx/vab/protocol/opcua/connector/ClientConfiguration.java new file mode 100644 index 00000000..15ea96c9 --- /dev/null +++ b/src/main/java/org/eclipse/basyx/vab/protocol/opcua/connector/ClientConfiguration.java @@ -0,0 +1,247 @@ +/******************************************************************************* + * Copyright (C) 2021 Festo Didactic SE + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + ******************************************************************************/ +package org.eclipse.basyx.vab.protocol.opcua.connector; + +import java.security.KeyPair; +import java.security.cert.X509Certificate; + +import org.eclipse.basyx.vab.protocol.opcua.CertificateHelper; +import org.eclipse.basyx.vab.protocol.opcua.types.MessageSecurityMode; +import org.eclipse.basyx.vab.protocol.opcua.types.SecurityPolicy; + +/** + * Holds the configuration for an {@link IOpcUaClient}. + */ +public final class ClientConfiguration implements Cloneable { + private SecurityPolicy securityPolicy = SecurityPolicy.None; + private MessageSecurityMode messageSecurityMode = MessageSecurityMode.None; + private String applicationName; + private String applicationUri; + private KeyPair keyPair; + private X509Certificate certificate; + + /** + * Creates a new {@link ClientConfiguration} with default settings. + */ + public ClientConfiguration() { + // Nothing to be done, all settings are default. + } + + /** + * Creates a shallow copy of this object. + * + * @return A shallow copy. + */ + @Override + public ClientConfiguration clone() { + try { + return (ClientConfiguration) super.clone(); + } catch (CloneNotSupportedException impossible) { + // Can't happen because we support cloning. + throw new AssertionError(impossible); + } + } + + /** + * Gets the key pair used to generate the client certificate. + * + *

+ * Default: null (i.e., no client certificate is used). + * + * @return The certificate. + */ + public KeyPair getKeyPair() { + return keyPair; + } + + /** + * Gets the certificate for client identification towards the server. + * + *

+ * Default: null (i.e., no client certificate is used). + * + * @return The certificate. + */ + public X509Certificate getCertificate() { + return certificate; + } + + /** + * Sets the key pair and associated certificate for client identification towards the server. + * + *

+ * Default: No certificate is used. + * + * @param keyPair The key pair. If null then certificate must also be + * null. + * @param certificate The certificate. If null then keyPair must also be + * null. + * + * @return This {@link ClientConfiguration}. + * + * @see CertificateHelper + * + * @throws IllegalArgumentException if keyPair is null but certificate is not or vice + * versa. + */ + public ClientConfiguration setKeyPairAndCertificate(KeyPair keyPair, X509Certificate certificate) { + if ((keyPair == null) ^ (certificate == null)) { + throw new IllegalArgumentException("Either both keyPair and certificate are null or neither."); + } + + this.keyPair = keyPair; + this.certificate = certificate; + return this; + } + + /** + * Gets the OPC UA client's application name. + * + *

+ * The client uses this name to identify itself towards the server. It needs not follow any specific + * format. + * + * @return The application name. + */ + public String getApplicationName() { + return applicationName; + } + + /** + * Sets the OPC UA client's application name. + * + *

+ * The client uses this name to identify itself towards the server. It needs not follow any specific + * format. + * + *

+ * Default: null. + * + * @param applicationName The application name. + * + * @return This {@link ClientConfiguration}. + */ + public ClientConfiguration setApplicationName(String applicationName) { + this.applicationName = applicationName; + return this; + } + + /** + * Gets the OPC UA client's application URI. + * + *

+ * The client uses this URI to identify itself towards the server. + * + * @return The application URI. + */ + public String getApplicationUri() { + return applicationUri; + } + + /** + * Sets the OPC UA client's application URI. + * + *

+ * The client uses this URI to identify itself towards the server. + * + *

+ * Default: null. + * + * @param applicationUri The application URI. + * + * @return This {@link ClientConfiguration}. + */ + public ClientConfiguration setApplicationUri(String applicationUri) { + this.applicationUri = applicationUri; + return this; + } + + /** + * Sets the message security mode. + * + *

+ * Along with {@link #getSecurityPolicy()} this parameter controls the endpoint selection when + * connecting to a server. + * + *

+ * Default: {@link MessageSecurityMode#None}. + * + * @return The message security mode. + */ + public MessageSecurityMode getMessageSecurityMode() { + return messageSecurityMode; + } + + /** + * Sets the message security mode. + * + *

+ * Along with {@link #setSecurityPolicy(SecurityPolicy)} this parameter controls the endpoint + * selection when connecting to a server. + * + *

+ * Default: {@link MessageSecurityMode#None}. + * + * @param messageSecurityMode The message security mode. + * + * @return This {@link ClientConfiguration}. + * + * @throws IllegalArgumentException if messageSecurityMode is null. + */ + public ClientConfiguration setMessageSecurityMode(MessageSecurityMode messageSecurityMode) { + if (messageSecurityMode == null) { + throw new IllegalArgumentException("messageSecurityMode can not be null."); + } + + this.messageSecurityMode = messageSecurityMode; + return this; + } + + /** + * Gets the security policy. + * + *

+ * Along with {@link #getMessageSecurityMode()} this parameter controls the endpoint selection when + * connecting to a server. + * + *

+ * Default: {@link SecurityPolicy#None}. + * + * @return The security policy. + */ + public SecurityPolicy getSecurityPolicy() { + return securityPolicy; + } + + /** + * Sets the security policy. + * + *

+ * Along with {@link #setMessageSecurityMode(MessageSecurityMode)} this parameter controls the + * endpoint selection when connecting to a server. + * + *

+ * Default: {@link SecurityPolicy#None}. + * + * @param securityPolicy The security policy. + * + * @return This {@link ClientConfiguration}. + * + * @throws IllegalArgumentException if securityPolicy is null. + */ + public ClientConfiguration setSecurityPolicy(SecurityPolicy securityPolicy) { + if (securityPolicy == null) { + throw new IllegalArgumentException("securityPolicy can not be null."); + } + + this.securityPolicy = securityPolicy; + return this; + } +} diff --git a/src/main/java/org/eclipse/basyx/vab/protocol/opcua/connector/IOpcUaClient.java b/src/main/java/org/eclipse/basyx/vab/protocol/opcua/connector/IOpcUaClient.java new file mode 100644 index 00000000..4f3f85aa --- /dev/null +++ b/src/main/java/org/eclipse/basyx/vab/protocol/opcua/connector/IOpcUaClient.java @@ -0,0 +1,517 @@ +/******************************************************************************* + * Copyright (C) 2021 Festo Didactic SE + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + ******************************************************************************/ +package org.eclipse.basyx.vab.protocol.opcua.connector; + +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +import javax.xml.datatype.XMLGregorianCalendar; + +import org.eclipse.basyx.submodel.metamodel.map.submodelelement.dataelement.property.valuetype.ValueType; +import org.eclipse.basyx.vab.exception.provider.ResourceNotFoundException; +import org.eclipse.basyx.vab.protocol.opcua.connector.milo.MiloOpcUaClient; +import org.eclipse.basyx.vab.protocol.opcua.exception.AmbiguousBrowsePathException; +import org.eclipse.basyx.vab.protocol.opcua.exception.OpcUaException; +import org.eclipse.basyx.vab.protocol.opcua.types.NodeId; +import org.eclipse.basyx.vab.protocol.opcua.types.UnsignedByte; +import org.eclipse.basyx.vab.protocol.opcua.types.UnsignedInteger; +import org.eclipse.basyx.vab.protocol.opcua.types.UnsignedLong; +import org.eclipse.basyx.vab.protocol.opcua.types.UnsignedShort; + +/** + * Very simplified OPC UA client interface for reading and writing node values and invoking methods. + * + *

How to use

+ * + * This interface features a set of methods to call common OPC UA services on a server (e.g. to read + * a node). Each comes in a synchronous and an asynchronous variant, returning + * {@link CompletableFuture}s. When any of these methods is first called, a connection to the + * configured endpoint URL is automatically opened. + *

+ * Users can configure the client through {@link #setConfiguration(ClientConfiguration)} until the + * first connection attempt is made. After that point, that method will throw an exception.
+ * {@link #hasConnected()} lets users check whether such a connection attempt has already been made. + * + *

Regarding types

+ * + * This interface must necessarily translate between two distinct type systems. There is the + * {@link ValueType BaSyx type system} on the one hand, itself a mapping of XML Schema types to + * standard JDK types with some additional custom classes. And on the other hand, there is + * OPC UA's type system. + * The two aren't fully compatible, meaning that there isn't a simple one-to-one mapping of types. + * + *

+ * This implementation makes no attempt at complete coverage of all OPC UA types. Only a subset of + * the most common data types are supported. Below table shows which Java types can be used and what + * OPC UA type they map to. Callers can pass these types to any of the interface methods and expect + * these types to be returned. + * + *

+ * Please note that if the OPC UA server returns a value of a different type than those listed in + * the table, e.g. as the result of a read operation or method invocation, then that type will be + * returned from the API as is. It would likely be a class from the underlying OPC UA library and + * the caller would have to know how to handle that type. + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + *
Java / OPC UA type mapping
Java typeOPC UA type
boolean / {@link Boolean}Boolean
byte / {@link Byte}SByte
short / {@link Short}Int16
int / {@link Integer}Int32
long / {@link Long}Int64
float / {@link Float}Float
double / {@link Double}Double
{@link UnsignedByte}Byte
{@link UnsignedShort}UInt16
{@link UnsignedInteger}UInt32
{@link UnsignedLong}UInt64
{@link XMLGregorianCalendar}DateTime
{@link String}String
{@link UUID}GUID
Array of the aforementioned types
+ * (Single- or multi-dimensional)
Array of equivalent OPC UA type
+ * + *

Date & time types

+ * + * Special care must be taken when dealing with the {@link XMLGregorianCalendar} type. It allows for + * incomplete date or time specifications, such as March 13, without providing a year. Or + * 5 minutes without any information on the hour or seconds. Such date/time specifications + * are not supported by the OPC UA DateTime type. It functions like the {@link java.time.Instant} + * type and must always specify a precise moment in time. + */ +public interface IOpcUaClient { + /** + * Static factory method for creating a client using the default implementation based on eclipse + * Milo. + * + * @param endpointUrl The server endpoint to which the client connects. + * + * @return The new {@link MiloOpcUaClient}. + */ + static IOpcUaClient create(String endpointUrl) { + return new MiloOpcUaClient(endpointUrl); + } + + /** + * Gets the current client configuration. + * + *

+ * A copy of the configuration is made and returned. Subsequent changes to the returned object won't + * be reflected on this {@link IOpcUaClient} until the changed configuration is applied with + * {@link #setConfiguration(ClientConfiguration)}. + * + * @return A copy of the current client configuration. + */ + ClientConfiguration getConfiguration(); + + /** + * Sets the client configuration. + * + *

+ * A copy of the configuration is made and stored. Modifications done to the original object after + * invoking this method have no effect on this {@link IOpcUaClient}. + * + * @param configuration The configuration to set or null to use a default + * configuration. + * + * @throws IllegalStateException if this method is called after a connection has been established. + */ + void setConfiguration(ClientConfiguration configuration); + + /** + * Gets the endpoint URL that this client connects to. + * + * @return The endpoint URL to connect to. + */ + String getEndpointUrl(); + + /** + * Gets a value signifying whether this client has already attempted to establish a connection to + * the server endpoint. + * + *

+ * Note that the returned value does not say whether there is currently an active connection to the + * server. Only whether a connection attempt has been made. This is significant because changes to + * client configuration are no longer possible once this method returns true. + * + * @return true if a connection attempt has been made, false otherwise. + */ + boolean hasConnected(); + + /** + * Gets the id of the node pointed to by when resolving the given path against the starting node. + * + *

+ * Browse paths in OPC UA are comprised of a starting node (identified by it's node id) and a + * relative path to the target. This relative path is a sequence of pairs of a reference type and a + * browse name.
+ * The relative path must be formatted according to the + * BNF format of relative + * paths. + * + *

+ * The relative path is resolved against the starting node by following a reference of the type + * specified in the first pair from the starting node to a target node with the browse name from + * that same pair. From the target node thus reached the second pair is resolved and so on until the + * end of the path. The node id of the final node reached this way is returned. + * + *

+ * Unfortunately, browse paths can be ambiguous in that they can lead to more than one final target. + * That's because browse names of nodes are not required to be unique, even within the same + * namespace. So from any given node, several references of the same type can lead to several other + * nodes with the same browse name. + * + *

+ * This is a blocking call which returns only after the request to the server has been completed. + * For a non-blocking variant, see {@link #translateBrowsePathToNodeIdAsync(NodeId, String)}. + * + * @param startingNode The starting node from where to start resolving relativePath. + * @param relativePath The string representation of the relative path to resolve against + * startingNode. + * + * @return The id of the node matching the browse path. + * + * @throws ResourceNotFoundException if the path doesn't lead to a node. + * @throws AmbiguousBrowsePathException if the path cannot be unambiguously resolved. + * @throws OpcUaException if an OPC UA related error occurs. This is a generic wrapper + * type for exceptions thrown by the client library. + * @throws IllegalArgumentException if startingNode or relativePath is + * null or if relativePath is an + * empty string. + */ + NodeId translateBrowsePathToNodeId(NodeId startingNode, String relativePath); + + /** + * Gets the id of the node matching the given browse path when resolved against the root node. + * + *

+ * See {@link #translateBrowsePathToNodeId(NodeId, String)} for details on how browse paths are + * resolved. + * + *

+ * This overload doesn't take an explicit starting node. It implicitly starts at the root node. Use + * {@link #translateBrowsePathToNodeId(NodeId, String)} to specify a different starting node. + * + *

+ * This is a blocking call which returns only after the request to the server has been completed. + * For a non-blocking variant, see {@link #translateBrowsePathToNodeIdAsync(String)}. + * + * @param browsePath The string representation of the browse path. + * + * @return The id of the node matching the browse path. + * + * @throws ResourceNotFoundException if the path doesn't lead to a node. + * @throws AmbiguousBrowsePathException if the path cannot be unambiguously resolved. + * @throws OpcUaException if an OPC UA related error occurs. This is a generic wrapper + * type for exceptions thrown by the client library. + * @throws IllegalArgumentException if browsePath is null or an empty + * string. + */ + NodeId translateBrowsePathToNodeId(String browsePath); + + /** + * Gets the id of the node pointed to by when resolving the given path against the starting node. + * + *

+ * See {@link #translateBrowsePathToNodeId(NodeId, String)} for details on how browse paths are + * resolved. + * + *

+ * This is an asynchronous call returning a {@link CompletableFuture}. For more details about the + * parameter, the value returned by the future and possible exceptions, see + * {@link #translateBrowsePathToNodeId(NodeId, String)}. + * + * @param startingNode The starting node from where to start resolving relativePath. + * @param relativePath The string representation of the relative path to resolve against + * startingNode. + * + * @return A {@link CompletableFuture} for the target node's id. + * + * @throws IllegalArgumentException if startingNode or relativePath is + * null or if relativePath is an empty + * string. + */ + CompletableFuture translateBrowsePathToNodeIdAsync(NodeId startingNode, String relativePath); + + /** + * Gets the id of the node matching the given browse path when resolved against the root node. + * + *

+ * See {@link #translateBrowsePathToNodeId(NodeId, String)} for details on how browse paths are + * resolved. + * + *

+ * This overload doesn't take an explicit starting node. It implicitly starts at the root node. Use + * {@link #translateBrowsePathToNodeIdAsync(NodeId, String)} to specify a different starting node. + * + *

+ * This is an asynchronous call returning a {@link CompletableFuture}. For more details about the + * parameter, the value returned by the future and possible exceptions, see + * {@link #translateBrowsePathToNodeId(String)}. + * + * @param browsePath The string representation of the browse path. + * + * @return A {@link CompletableFuture} for the target node's id. + * + * @throws IllegalArgumentException if browsePath is null or an empty + * string. + */ + CompletableFuture translateBrowsePathToNodeIdAsync(String browsePath); + + /** + * Gets the id of the last two nodes pointed to by when resolving the given path against the + * starting node. + * + *

+ * This is not a typical use case for OPC UA applications but it is a useful helper for invoking + * BaSyx operations through OPC UA. That's because BaSyx identifies all elements in its information + * model only through a single path, represented as a string. These strings map directly to OPC UA + * browse paths in the case of {@link OpcUaConnector}). + * + *

+ * For BaSyx properties (i.e., variable nodes in OPC UA) that's all well and good. But for BaSyx + * operations (which correspond to OPC UA methods) it poses a problem.
+ * OPC UA methods must be invoked using their own node id as well as that of the owner object on + * which to invoke the method. See {@link #invokeMethod(NodeId, NodeId, Object...)} for more + * information.
+ * That's two independent identifiers necessary, while BaSyx only provides the one string. + * + *

+ * BaSyx solves this conundrum by assuming that the final node targeted by a browse path is the + * method node itself, while the second to last node in the path is the owner.
+ * For any BaSyx model directly mapped to OPC UA this assumption is guaranteed to hold true. The + * same can not be said for general purpose OPC UA servers. This should be kept in mind when + * utilizing this method with a server not provided by a BaSyx application. + * + *

+ * For general information on how browse paths are resolved, please refer to + * {@link #translateBrowsePathToNodeId(NodeId, String)}. + * + *

+ * Note that this method has no overload accepting an explicit starting node. That's because it's + * use case is specific to the {@link OpcUaConnector} which has no way of ever specifying an + * starting node. + * + *

+ * This is a blocking call which returns only after the request to the server has been completed. + * For a non-blocking variant, see {@link #translateBrowsePathToParentAndTargetNodeIdAsync(String)}. + * + * @param browsePath The string representation of the browse path. + * + * @return A list of two node ids, where the first is the method node and the second is its parent. + * + * @throws ResourceNotFoundException if the path doesn't lead to a node. + * @throws AmbiguousBrowsePathException if the path cannot be unambiguously resolved. + * @throws OpcUaException if an OPC UA related error occurs. This is a generic wrapper + * type for exceptions thrown by the client library. + * @throws IllegalArgumentException if browsePath is null or an empty + * string. + */ + List translateBrowsePathToParentAndTargetNodeId(String browsePath); + + /** + * Gets the id of the last two nodes pointed to by when resolving the given path against the + * starting node. + * + *

+ * This is an asynchronous call returning a {@link CompletableFuture}. For more details on this + * method's purpose, returned value and possible exceptions, see + * {@link #translateBrowsePathToParentAndTargetNodeId(String)}. + * + * @param browsePath The string representation of the browse path. + * + * @return A {@link CompletableFuture} for a list of parent and method node id. + * + * @throws IllegalArgumentException if browsePath is null or an empty + * string. + */ + CompletableFuture> translateBrowsePathToParentAndTargetNodeIdAsync(String browsePath); + + /** + * Invokes an OPC UA method on an object. + * + *

+ * OPC UA methods must always be invoked on an owner object which acts as the method's scope. That's + * because the same method (with the same node id) can be assigned to multiple objects or even + * object types.
+ * The owner must have a HasComponent reference to the method. For more details refer + * to the specification + * (part 4). + * + *

+ * The type of the input parameters and returned values depends on the method configuration on the + * server and the caller is expected to know these types. {@link IOpcUaClient} gives more + * information on types. + * + *

+ * This is a blocking call which returns only after the request to the server has been completed. + * For a non-blocking variant, see {@link #invokeMethodAsync(NodeId, NodeId, Object...)}. + * + * @param ownerId The node id of the object (or object type) on which to invoke the method. + * @param methodId The node id of the method to invoke. + * @param parameters The input parameters of the operation. + * + * @return The outputs from the operation. + * + * @throws OpcUaException if an OPC UA related error occurs. This is a generic wrapper + * type for exceptions thrown by the client library. + * @throws IllegalArgumentException if ownerId or methodId is + * null. + */ + List invokeMethod(NodeId ownerId, NodeId methodId, Object... parameters) throws OpcUaException; + + /** + * Invokes an OPC UA method on an object. + * + *

+ * OPC UA methods must always be invoked on an owner object which acts as the method's scope. That's + * because the same method (with the same node id) can be assigned to multiple objects or even + * object types.
+ * The owner must have a HasComponent reference to the method. For more details refer + * to the specification + * (part 4). + * + *

+ * This is an asynchronous call returning a {@link CompletableFuture} for the value returned by the + * method. For more details about possible exceptions, see + * {@link #invokeMethod(NodeId, NodeId, Object...)}. + * + * @param ownerId The node id of the object (or object type) on which to invoke the method. + * @param methodId The node id of the method to invoke. + * @param parameters The input parameters of the operation. + * + * @return A {@link CompletableFuture} for the method's outputs. + * + * @throws IllegalArgumentException if ownerId or methodId is + * null. + */ + CompletableFuture> invokeMethodAsync(NodeId ownerId, NodeId methodId, Object... parameters); + + /** + * Reads the current value from an OPC UA node. + * + *

+ * The returned type depends on the node's data type on the server and the caller is expected to + * know which type to expect. {@link IOpcUaClient} gives more information on types. + * + *

+ * This is a blocking call which returns only after the request to the server has been completed. + * For a non-blocking variant, see {@link #readValueAsync(NodeId)}. + * + * @param nodeId The id of the node to read. + * + * @return The node's current value. + * + * + * @throws OpcUaException if an OPC UA related error occurs or if the server can't provide + * a valid value at this time. This is a generic wrapper type for + * exceptions thrown by the client library. + * @throws IllegalArgumentException if nodeId is null. + */ + Object readValue(NodeId nodeId) throws OpcUaException; + + /** + * Reads the current value from an OPC UA node. + * + *

+ * This is an asynchronous call returning a {@link CompletableFuture}. For more details about the + * value returned by the future and possible exceptions, see {@link #readValue(NodeId)}. + * + * @param nodeId The id of the node to read. + * + * @return A {@link CompletableFuture} for the node's current value. + * + * @throws IllegalArgumentException if nodeId is null. + */ + CompletableFuture readValueAsync(NodeId nodeId); + + /** + * Writes the value of an OPC UA node. + * + *

+ * The type of the value to write depends on the node's data type on the server and the caller is + * expected to know which type to pass. {@link IOpcUaClient} gives more information on types. + * + *

+ * This is a blocking call which returns only after the request to the server has been completed. + * For a non-blocking variant, see {@link #writeValueAsync(NodeId, Object)}. + * + * @param nodeId The id of the node to write. + * @param value The new value to write. Can be null. + * + * @throws OpcUaException if an OPC UA related error occurs. This is a generic wrapper + * type for exceptions thrown by the client library. + * @throws IllegalArgumentException if nodeId is null. + */ + void writeValue(NodeId nodeId, Object value) throws OpcUaException; + + /** + * Writes the value of an OPC UA node. + * + *

+ * This is an asynchronous call returning a {@link CompletableFuture}. The future doesn't supply a + * value but can be used to wait for completion and to receive exceptions thrown during the write + * procedure. For more details about possible exceptions, see {@link #writeValue(NodeId, Object)}. + * + * @param nodeId The id of the node to write. + * @param value The new value to write. Can be null. + * + * @return A {@link CompletableFuture}. + * + * @throws IllegalArgumentException if MiloNodeIdWrapper is null. + */ + CompletableFuture writeValueAsync(NodeId nodeId, Object value); +} diff --git a/src/main/java/org/eclipse/basyx/vab/protocol/opcua/connector/OpcUaConnector.java b/src/main/java/org/eclipse/basyx/vab/protocol/opcua/connector/OpcUaConnector.java index c315e3fe..a33ea017 100644 --- a/src/main/java/org/eclipse/basyx/vab/protocol/opcua/connector/OpcUaConnector.java +++ b/src/main/java/org/eclipse/basyx/vab/protocol/opcua/connector/OpcUaConnector.java @@ -1,239 +1,222 @@ /******************************************************************************* - * Copyright (C) 2021 the Eclipse BaSyx Authors - * + * Copyright (C) 2021 Festo Didactic SE + * * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 * which is available at https://www.eclipse.org/legal/epl-2.0/ - * + * * SPDX-License-Identifier: EPL-2.0 ******************************************************************************/ package org.eclipse.basyx.vab.protocol.opcua.connector; -import java.util.ArrayList; +import java.time.Duration; import java.util.List; -import java.util.concurrent.CompletableFuture; +import java.util.Map; +import java.util.Timer; +import java.util.TimerTask; +import java.util.concurrent.ConcurrentHashMap; import org.eclipse.basyx.vab.exception.provider.ProviderException; import org.eclipse.basyx.vab.modelprovider.api.IModelProvider; -import org.eclipse.basyx.vab.protocol.opcua.server.BaSyxOpcUaClientRunner; -import org.eclipse.milo.opcua.stack.core.Identifiers; -import org.eclipse.milo.opcua.stack.core.types.builtin.DataValue; -import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId; -import org.eclipse.milo.opcua.stack.core.types.builtin.QualifiedName; -import org.eclipse.milo.opcua.stack.core.types.builtin.StatusCode; -import org.eclipse.milo.opcua.stack.core.types.builtin.Variant; -import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UInteger; -import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UShort; -import org.eclipse.milo.opcua.stack.core.types.structured.BrowsePath; -import org.eclipse.milo.opcua.stack.core.types.structured.CallResponse; -import org.eclipse.milo.opcua.stack.core.types.structured.RelativePath; -import org.eclipse.milo.opcua.stack.core.types.structured.RelativePathElement; -import org.eclipse.milo.opcua.stack.core.types.structured.TranslateBrowsePathsToNodeIdsResponse; +import org.eclipse.basyx.vab.protocol.opcua.exception.OpcUaException; +import org.eclipse.basyx.vab.protocol.opcua.types.NodeId; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** - * OPC UA connector class - * - * @author kdorofeev + * The OpcUaConnector can be used to connect to remote models over OPC UA. + * + *

+ * When this class is instantiated with an endpoint URL, an {@link IOpcUaClient} is automatically + * created with a default configuration. The default is an insecure, anonymous connection without a + * client certificate. + * + *

+ * The configuration can be changed as long as no connection has been established. A connection is + * established automatically the first time a node is read or written or an operation is invoked, + * either through this connector or directly through the associated {@link IOpcUaClient}.
+ * To change the configuration, do the following: + * + *

+ * ClientConfiguration config = opcUaConnector.getClient().getConfiguration();
+ * // Modify configuration
+ * opcUaConnector.getClient().setConfiguration(config);
+ * 
+ * + *

Using browse paths

* + * This class uses browses paths to identify OPC UA nodes for read, write and invoke requests. That + * has two noteworthy caveats: + *
    + *
  1. Browse paths aren't necessarily unambiguous. In other words, a browse path might match more + * than one node. This would lead to an exception being returned from this connector.
    + * It is up to the user to make sure their server's address space doesn't have that problem. + *
  2. The connector must translate the browse path to a unique node id in the background. That + * necessitates an additional network request to the server, every time a read, write or invoke + * request is made.
    + * To address this issue, this class contains a cache for translated browse paths. + *
+ * + *

Node id cache

+ * + * Using the cache is entirely optional and it is disabled be default. It can be enabled using + * {@link #setNodeIdCacheDuration(Duration)}. + * + *

+ * If it is enabled, it will cache any browse path passed to {@link #getValue(String)}, + * {@link #setValue(String, Object)} or {@link #invokeOperation(String, Object...)} and the matching + * node id for the specified duration. Subsequent requests using the same browse path within the + * configured timespan would omit the additional network request for browse path resolution. + * + *

+ * Caution: An OPC UA server can dynamically reconfigure their address space during runtime. + * This could even be done remotely from clients, if the server allows it.
+ * Such changes would render this cache invalid, but there is no way for this connector to get + * notified of them. Only use the cache with servers where you can be sure the address space doesn't + * change. */ public class OpcUaConnector implements IModelProvider { - - private Logger logger = LoggerFactory.getLogger(OpcUaConnector.class); - - private String address; - private BaSyxOpcUaClientRunner clientRunner; + /** + * {@link TimerTask} which removes an entry from a map. + */ + private static class RemoveEntryFromMapTimerTask extends TimerTask { + Map map; + TKey key; + + public RemoveEntryFromMapTimerTask(Map map, TKey key) { + this.map = map; + this.key = key; + } + + @Override + public void run() { + map.remove(key); + } + } + + private static final Timer cacheTimer = new Timer(true); + private final Logger logger = LoggerFactory.getLogger(getClass()); + private Duration cacheDuration = Duration.ZERO; + private IOpcUaClient client; + private Map nodeIdCache = new ConcurrentHashMap<>(); + private Map> operationNodeIdsCache = new ConcurrentHashMap<>(); + + public OpcUaConnector(String endpointUrl) { + client = IOpcUaClient.create(endpointUrl); + } /** - * Invoke a BaSys get operation via OPC UA - * - * @param servicePath - * requested node path - * @return the requested value - * @throws Exception + * Gets the OPC UA client used for communication to the server. + * + * @return The OPC UA client object. */ + public IOpcUaClient getClient() { + return client; + } + + /** + * Sets the duration for the NodeId cache. + * + *

+ * When a {@link OpcUaConnector} resolves a browse path to a NodeId, it can cache the result to make + * future access to that same browse path more efficient.
+ * This setting controls how long looked up NodeIds remain cached. A value of {@link Duration#ZERO} + * disables the cache. + * + *

+ * See {@link OpcUaConnector} for more information on the caching feature. + * + * @param cacheDuration The cache duration. {@link Duration#ZERO} disable the cache. + * + * @throws IllegalArgumentException if cacheDuration is null or negative. + */ + public void setNodeIdCacheDuration(Duration cacheDuration) { + if (cacheDuration == null || cacheDuration.isNegative()) { + throw new IllegalArgumentException("cacheDuration must not be negative."); + } + + this.cacheDuration = cacheDuration; + } + @Override - public String getValue(String servicePath) { + public Object getValue(String path) throws OpcUaException { try { - clientRunner = new BaSyxOpcUaClientRunner(address); - clientRunner.run(); - } catch (Exception e) { - logger.error("Exception in getModelPropertyValue", e); - throw new RuntimeException(e); + NodeId nodeId = getNodeIdForBrowsePath(path); + return client.readValue(nodeId); + } catch (OpcUaException e) { + logger.error("Failed to get node value."); + throw e; } - return opcUaRead(translateBrowsePathToNodeId(servicePath)[1]); } @Override - public void setValue(String servicePath, Object newValue) throws ProviderException { + public void setValue(String path, Object newValue) throws OpcUaException { try { - clientRunner = new BaSyxOpcUaClientRunner(address); - clientRunner.run(); - } catch (Exception e) { - logger.error("Exception in setModelPropertyValue", e); - throw new RuntimeException(e); + NodeId nodeId = getNodeIdForBrowsePath(path); + client.writeValue(nodeId, newValue); + } catch (OpcUaException e) { + logger.error("Failed to set node value."); + throw e; } - opcUaWrite(translateBrowsePathToNodeId(servicePath)[1], newValue); } @Override - public void createValue(String path, Object newEntity) throws ProviderException { - + public void createValue(String path, Object newEntity) throws ProviderException { + throw new UnsupportedOperationException("Cannot create values through OPC UA."); } @Override - public void deleteValue(String path) throws ProviderException { - + public void deleteValue(String path) throws ProviderException { + throw new UnsupportedOperationException("Cannot delete values through OPC UA."); } @Override - public void deleteValue(String path, Object obj) throws ProviderException { - + public void deleteValue(String path, Object obj) throws ProviderException { + throw new UnsupportedOperationException("Cannot delete values through OPC UA."); } @Override - public Object invokeOperation(String servicePath, Object... parameters) throws ProviderException { + public Object invokeOperation(String path, Object... parameters) throws OpcUaException { try { - clientRunner = new BaSyxOpcUaClientRunner(address); - clientRunner.run(); - } catch (Exception e) { - logger.error("Exception in invokeOperation", e); - throw new RuntimeException(e); + List nodeIds = getNodeIdsForOperationBrowsePath(path); + return client.invokeMethod(nodeIds.get(1), nodeIds.get(0), parameters); + } catch (OpcUaException e) { + logger.error("Failed to invoke operation."); + throw e; } - return opcUaMethodCall(translateBrowsePathToNodeId(servicePath), parameters); } - public OpcUaConnector(String address) { - this.address = address; - } - - /** - * Perform a OPC UA read request - * - * @param servicePath - * @return - */ - private String opcUaRead(NodeId servicePath) { - try { - List nodes = new ArrayList(); - nodes.add(servicePath); - CompletableFuture> result = clientRunner.read(nodes); + private NodeId getNodeIdForBrowsePath(String browsePath) { + if (nodeIdCache.containsKey(browsePath)) { + logger.debug("Using cached NodeId for browse path '{}'.", browsePath); + return nodeIdCache.get(browsePath); + } - return result.get().get(0).getValue().getValue().toString(); + NodeId nodeId = client.translateBrowsePathToNodeId(browsePath); - } catch (Exception e) { - logger.error("Exception in opcUaRead", e); + if (!cacheDuration.isZero()) { + nodeIdCache.put(browsePath, nodeId); + cacheTimer.schedule( + new RemoveEntryFromMapTimerTask<>(nodeIdCache, browsePath), + cacheDuration.toMillis()); } - return null; + return nodeId; } - private String opcUaMethodCall(NodeId[] methodNodes, Object[] inputParameters) { - try { - Variant[] inputs = new Variant[inputParameters.length]; - for (int i = 0; i < inputParameters.length; i++) { - inputs[i] = new Variant(inputParameters[i]); - } - CompletableFuture result = clientRunner.callMethod(methodNodes[0], methodNodes[1], inputs); - Variant[] outputs = result.get().getResults()[0].getOutputArguments(); - String ret = ""; - for (Variant var : outputs) { - ret += var.getValue() + " "; - } - return ret; - } catch (Exception e) { - logger.error("Exception in opcUaMethodCall", e); + private List getNodeIdsForOperationBrowsePath(String browsePath) { + if (operationNodeIdsCache.containsKey(browsePath)) { + logger.debug("Using cached NodeIds for operation at browse path '{}'.", browsePath); + return operationNodeIdsCache.get(browsePath); } - return null; - } - private String opcUaWrite(NodeId servicePath, Object parameter) throws ProviderException { - try { - List nodes = new ArrayList(); - nodes.add(servicePath); - List parameters = new ArrayList(); - parameters.add(new DataValue(new Variant(parameter), null, null, null)); - CompletableFuture> result = clientRunner.write(nodes, parameters); - - return result.get().get(0).toString(); - } catch (Exception e) { - logger.error("Exception in opcUaWrite", e); - } - return null; - } + List nodeIds = client.translateBrowsePathToParentAndTargetNodeId(browsePath); - private NodeId[] translateBrowsePathToNodeId(String path) { - String[] nodes = path.split("/"); - List rpe_list = new ArrayList(); - for (String node : nodes) { - if (node.split(":").length != 2) { - logger.warn("OpcUaName should be in form namespaceIdx:identifier"); - } - int nsIdx = Integer.valueOf(node.split(":")[0]); - String name = node.split(":")[1]; - rpe_list.add(new RelativePathElement(Identifiers.HierarchicalReferences, false, true, - new QualifiedName(nsIdx, name))); + if (!cacheDuration.isZero()) { + operationNodeIdsCache.put(browsePath, nodeIds); + cacheTimer.schedule( + new RemoveEntryFromMapTimerTask<>(operationNodeIdsCache, browsePath), + cacheDuration.toMillis()); } - RelativePathElement[] rpe_node_arr = new RelativePathElement[rpe_list.size()]; - RelativePathElement[] rpe_parent_arr = new RelativePathElement[rpe_list.size() - 1]; - rpe_node_arr = rpe_list.toArray(rpe_node_arr); - - // get list for parent (all but the last one) - rpe_list.remove(rpe_list.size() - 1); - rpe_parent_arr = rpe_list.toArray(rpe_parent_arr); - - BrowsePath bp_node = new BrowsePath(Identifiers.RootFolder, new RelativePath(rpe_node_arr)); - BrowsePath bp_parent = new BrowsePath(Identifiers.RootFolder, new RelativePath(rpe_parent_arr)); - List bp_node_list = new ArrayList(); - List bp_parent_list = new ArrayList(); - bp_node_list.add(bp_node); - bp_parent_list.add(bp_parent); - try { - CompletableFuture result_node = clientRunner.translate(bp_node_list); - CompletableFuture result_parent = clientRunner - .translate(bp_parent_list); - if (result_node.get().getResults().length == 0) { - logger.warn("TranslateBrowsePathsToNodeIdsResponse result size = 0, check the browse path!"); - return null; - } - if (result_node.get().getResults().length > 1) { - logger.warn("TranslateBrowsePathsToNodeIdsResponse result size > 1, the method returns only the first one!"); - } - if (result_node.get().getResults()[0].getTargets() == null || result_node.get().getResults()[0].getTargets().length == 0) { - logger.warn("TranslateBrowsePathsToNodeIdsResponse targets size = 0, check the browse path!"); - logger.trace(result_node.get().getResults()[0].getStatusCode().toString()); - return null; - } - if (result_node.get().getResults()[0].getTargets().length > 1) { - logger.warn("TranslateBrowsePathsToNodeIdsResponse targets size > 1, the method returns only the first one!"); - } - Object nodeIdentifier = result_node.get().getResults()[0].getTargets()[0].getTargetId().getIdentifier(); - Object parentIdentifier = result_parent.get().getResults()[0].getTargets()[0].getTargetId().getIdentifier(); - UShort nodeNsIdx = result_node.get().getResults()[0].getTargets()[0].getTargetId().getNamespaceIndex(); - UShort parentNsIdx = result_parent.get().getResults()[0].getTargets()[0].getTargetId().getNamespaceIndex(); - if (nodeIdentifier instanceof String && parentIdentifier instanceof String) { - return new NodeId[] { new NodeId(parentNsIdx, (String) parentIdentifier), - new NodeId(nodeNsIdx, (String) nodeIdentifier) }; - } - if (nodeIdentifier instanceof UInteger && parentIdentifier instanceof UInteger) { - return new NodeId[] { new NodeId(parentNsIdx, (UInteger) parentIdentifier), - new NodeId(nodeNsIdx, (UInteger) nodeIdentifier) }; - } - if (nodeIdentifier instanceof UInteger && parentIdentifier instanceof String) { - return new NodeId[] { new NodeId(parentNsIdx, (String) parentIdentifier), - new NodeId(nodeNsIdx, (UInteger) nodeIdentifier) }; - } - if (nodeIdentifier instanceof String && parentIdentifier instanceof UInteger) { - return new NodeId[] { new NodeId(parentNsIdx, (UInteger) parentIdentifier), - new NodeId(nodeNsIdx, (String) nodeIdentifier) }; - } else { - logger.error("NodeId identifier is not neither String, nor int"); - return null; - } - } catch (Exception e) { - logger.error("Exception in translateBrowsePathToNodeId", e); - } - return null; + return nodeIds; } - } diff --git a/src/main/java/org/eclipse/basyx/vab/protocol/opcua/connector/OpcUaConnectorFactory.java b/src/main/java/org/eclipse/basyx/vab/protocol/opcua/connector/OpcUaConnectorFactory.java new file mode 100644 index 00000000..8c351cda --- /dev/null +++ b/src/main/java/org/eclipse/basyx/vab/protocol/opcua/connector/OpcUaConnectorFactory.java @@ -0,0 +1,63 @@ +/******************************************************************************* + * Copyright (C) 2021 the Eclipse BaSyx Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + ******************************************************************************/ +package org.eclipse.basyx.vab.protocol.opcua.connector; + +import org.eclipse.basyx.vab.modelprovider.api.IModelProvider; +import org.eclipse.basyx.vab.protocol.api.ConnectorFactory; + +/** + * OPC UA connector factory. + */ +public class OpcUaConnectorFactory extends ConnectorFactory { + + private final ClientConfiguration defaultConfiguration; + + /** + * Creates a new connector factory. + */ + public OpcUaConnectorFactory() { + super(); + defaultConfiguration = null; + } + + /** + * Creates a new connector factory which applies a default client configuration to connectors. + * + *

+ * Note, that the configuration is copied internally, so changes made to the object after creating + * the factory do not apply to connectors created by it. + * + * @param defaultConfiguration The default connector configuration. + */ + public OpcUaConnectorFactory(ClientConfiguration defaultConfiguration) { + super(); + this.defaultConfiguration = defaultConfiguration; + } + + /** + * Creates a new {@link OpcUaConnector} for the given endpoint URL. + * + *

+ * If a default configuration has been set, it will be applied to the connector before it is + * returned. + * + * @return A new {@link OpcUaConnector} for the given endpoint URL. + */ + @Override + protected IModelProvider createProvider(String addr) { + OpcUaConnector c = new OpcUaConnector(addr); + + if (defaultConfiguration != null) { + c.getClient().setConfiguration(defaultConfiguration); + } + + return c; + } +} diff --git a/src/main/java/org/eclipse/basyx/vab/protocol/opcua/connector/OpcUaConnectorProvider.java b/src/main/java/org/eclipse/basyx/vab/protocol/opcua/connector/OpcUaConnectorProvider.java index c5c64770..27e9729a 100644 --- a/src/main/java/org/eclipse/basyx/vab/protocol/opcua/connector/OpcUaConnectorProvider.java +++ b/src/main/java/org/eclipse/basyx/vab/protocol/opcua/connector/OpcUaConnectorProvider.java @@ -1,33 +1,20 @@ /******************************************************************************* - * Copyright (C) 2021 the Eclipse BaSyx Authors - * + * Copyright (C) 2021 Festo Didactic SE + * * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 * which is available at https://www.eclipse.org/legal/epl-2.0/ - * + * * SPDX-License-Identifier: EPL-2.0 ******************************************************************************/ package org.eclipse.basyx.vab.protocol.opcua.connector; -import org.eclipse.basyx.vab.modelprovider.api.IModelProvider; -import org.eclipse.basyx.vab.protocol.api.ConnectorFactory; - /** - * OPC UA connector provider class - * - * @author kdorofeev + * OPC UA connector factory class. * + * @deprecated As of version 1.1. Replaced by {@link OpcUaConnectorFactory}. */ -public class OpcUaConnectorProvider extends ConnectorFactory { - - /** - * returns HTTPConnetor wrapped with ConnectedHashmapProvider that handles - * message header information - */ - @Override - protected IModelProvider createProvider(String addr) { - - return new OpcUaConnector(addr); - } +@Deprecated +public class OpcUaConnectorProvider extends OpcUaConnectorFactory { } diff --git a/src/main/java/org/eclipse/basyx/vab/protocol/opcua/connector/milo/BrowsePathHelper.java b/src/main/java/org/eclipse/basyx/vab/protocol/opcua/connector/milo/BrowsePathHelper.java new file mode 100644 index 00000000..2481645f --- /dev/null +++ b/src/main/java/org/eclipse/basyx/vab/protocol/opcua/connector/milo/BrowsePathHelper.java @@ -0,0 +1,271 @@ +/******************************************************************************* + * Copyright (C) 2021 Festo Didactic SE + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + ******************************************************************************/ +package org.eclipse.basyx.vab.protocol.opcua.connector.milo; + +import java.util.Arrays; +import java.util.LinkedList; +import java.util.List; + +import org.eclipse.basyx.vab.protocol.opcua.exception.OpcUaException; +import org.eclipse.basyx.vab.protocol.opcua.types.NodeId; +import org.eclipse.milo.opcua.stack.core.BuiltinReferenceType; +import org.eclipse.milo.opcua.stack.core.Identifiers; +import org.eclipse.milo.opcua.stack.core.types.builtin.QualifiedName; +import org.eclipse.milo.opcua.stack.core.types.structured.BrowsePath; +import org.eclipse.milo.opcua.stack.core.types.structured.RelativePath; +import org.eclipse.milo.opcua.stack.core.types.structured.RelativePathElement; + +/** + * Converts between string-formatted browse path and a {@link BrowsePath} object. + * + *

+ * The string must be formatted according to the + * BNF format of relative + * paths. + * + *

+ * Note that this helper doesn't currently support browse paths which directly specify a reference + * type to follow, using the <...> syntax. Only the more general hierarchical + * (/) and aggregate (.) reference types are supported. + */ +public final class BrowsePathHelper { + private static final String REFERENCE_TYPE_SPLIT = "(?<...> syntax. + * @throws IllegalArgumentException if s is null or an empty string. + */ + public static BrowsePath parse(String s) throws OpcUaException { + NodeId root = new NodeId(Identifiers.RootFolder); + return parse(root, s); + } + + /** + * Creates a browse path starting at the given node from the given string. + * + * @param startingNode The node from where to resolve the browse path. + * @param s The relative browse path starting at the root node. + * + * @return The {@link BrowsePath} object represented by the given string. + * + * @throws OpcUaException if the browse path is not valid. + * @throws UnsupportedOperationException if the browse path contains directly specified reference + * types using the <...> syntax. + * @throws IllegalArgumentException if startingNode or s are null or if s is an + * empty string. + */ + public static BrowsePath parse(NodeId startingNode, String s) throws OpcUaException { + if (startingNode == null || s == null) { + throw new IllegalArgumentException("startingNode and s must not be null."); + } + if (s.isEmpty()) { + throw new IllegalArgumentException("s must not be empty."); + } + + BrowsePathHelper helper = new BrowsePathHelper(s); + + List elements = new LinkedList<>(); + RelativePathElement elem; + while ((elem = helper.next()) != null) { + elements.add(elem); + } + + RelativePathElement[] arr = elements.toArray(new RelativePathElement[0]); + RelativePath relativePath = new RelativePath(arr); + + return new BrowsePath(startingNode.getInternalId(), relativePath); + } + + /** + * Creates a new browse path pointing to the second to last element of the given path. + * + * @param browsePath The original browse path. + * + * @return A new browse path which points to the second to last node in browsePath. + * + * @throws IllegalArgumentException if browsePath is null or it's relative + * path is empty. + */ + public static BrowsePath getParent(BrowsePath browsePath) { + if (browsePath == null) { + throw new IllegalArgumentException("browsePath must not be null."); + } + + RelativePathElement[] targetElements = browsePath.getRelativePath().getElements(); + + if (targetElements.length == 0) { + throw new IllegalArgumentException("Can't generate browse path to parent of an empty path."); + } + + // Copy all but the last element from the original relative path to a new array and + // create a new relative path from that. + RelativePathElement[] parentElements = Arrays.copyOf(targetElements, targetElements.length - 1); + RelativePath parentPath = new RelativePath(parentElements); + + return new BrowsePath(browsePath.getStartingNode(), parentPath); + } + + /** + * Returns a string representation of the given relative path. + * + * @param relativePath The relative path of a browse path. + * + * @return The string representation in accordance with the + * BNF format of + * relative paths. + * + * @throws IllegalArgumentException if relativePath contains references, which don't map to either + * '/' or '.' or if it is invalid. + */ + public static String toString(RelativePath relativePath) { + StringBuilder sb = new StringBuilder(); + + RelativePathElement[] elems = relativePath.getElements(); + + for (int i = 0; i < elems.length; i++) { + if (isAllHierarchicalReferences(elems[i])) { + sb.append('/'); + } else if (isAllAggregatesReferences(elems[i])) { + sb.append('.'); + } else { + throw new IllegalArgumentException( + "relativePath contains directly specified references which aren't supported."); + } + + if (elems[i].getTargetName() != null) { + sb.append(qualifiedNameToString(elems[i].getTargetName())); + } else if (i != elems.length - 1) { + throw new IllegalArgumentException( + "Only the last element in a relative path is allowed to have no name."); + } + } + + return sb.toString(); + } + + private static boolean isAllHierarchicalReferences(RelativePathElement rpe) { + return rpe.getReferenceTypeId().equals(BuiltinReferenceType.HierarchicalReferences.getNodeId()) + && !rpe.getIsInverse() + && rpe.getIncludeSubtypes(); + } + + private static boolean isAllAggregatesReferences(RelativePathElement rpe) { + return rpe.getReferenceTypeId().equals(BuiltinReferenceType.Aggregates.getNodeId()) + && !rpe.getIsInverse() + && rpe.getIncludeSubtypes(); + } + + private static String qualifiedNameToString(QualifiedName qn) { + return qn.getNamespaceIndex().toString() + ':' + qn.getName(); + } + + private RelativePathElement next() { + if (index >= path.length()) { + return null; + } + + org.eclipse.milo.opcua.stack.core.types.builtin.NodeId referenceType = parseReferenceType(); + QualifiedName qualifiedName = parseQualifiedName(); + return new RelativePathElement(referenceType, false, true, qualifiedName); + } + + private org.eclipse.milo.opcua.stack.core.types.builtin.NodeId parseReferenceType() { + switch (path.charAt(index)) { + case '/': + index++; + return BuiltinReferenceType.HierarchicalReferences.getNodeId(); + + case '.': + index++; + return BuiltinReferenceType.Aggregates.getNodeId(); + + case '<': + throw new IllegalArgumentException("This helper doesn't supported directly specified reference types."); + + default: + throw new OpcUaException(String.format("Invalid browse path at index %d: %s", index, path)); + } + } + + private QualifiedName parseQualifiedName() { + if (index == path.length()) { + // The last element in a browse path is allowed to have no qualified name. + return null; + } + + // Returns only the part of the path between the current index and the next separator. + String pathElement = path.substring(index).split(REFERENCE_TYPE_SPLIT, 2)[0]; + + String[] s = pathElement.split(NAMESPACE_SPLIT, 3); + QualifiedName qn; + if (s.length == 1) { + // RelativePathElement has no namespace index -> Uses default namespace '0'. + qn = parseQualifiedName(s[0]); + } else if (s.length == 2) { + // RelativePathElement has a namespace index. + qn = parseQualifiedName(s[0], s[1]); + } else { + // RelativePathElement contains more than one colon. + throw new OpcUaException(String.format("Not a valid relative path element starting at index %d: %s", index, + path)); + } + + index += pathElement.length(); + return qn; + } + + private QualifiedName parseQualifiedName(String browseName) { + String unescaped = unescape(browseName); + if (unescaped.isEmpty()) { + throw new OpcUaException(String.format("Browse path contains invalid browse name at index %d: %s", index, + path)); + } + + return new QualifiedName(0, unescaped); + } + + private QualifiedName parseQualifiedName(String namespaceIndex, String browseName) { + int ns; + try { + ns = Integer.parseUnsignedInt(namespaceIndex); + } catch (NumberFormatException e) { + throw new OpcUaException(String.format("Browse path contains invalid namespace index at index %d: %s", + index, path)); + } + + String escaped = unescape(browseName); + if (escaped.isEmpty()) { + throw new OpcUaException(String.format("Browse path contains invalid browse name at index %d: %s", index, + path)); + } + return new QualifiedName(ns, escaped); + } + + private String unescape(String s) { + return s.replace("&", ""); + } +} diff --git a/src/main/java/org/eclipse/basyx/vab/protocol/opcua/connector/milo/MiloOpcUaClient.java b/src/main/java/org/eclipse/basyx/vab/protocol/opcua/connector/milo/MiloOpcUaClient.java new file mode 100644 index 00000000..5d575d6b --- /dev/null +++ b/src/main/java/org/eclipse/basyx/vab/protocol/opcua/connector/milo/MiloOpcUaClient.java @@ -0,0 +1,816 @@ +/******************************************************************************* + * Copyright (C) 2021 Festo Didactic SE + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + ******************************************************************************/ +package org.eclipse.basyx.vab.protocol.opcua.connector.milo; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.GregorianCalendar; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutionException; +import java.util.function.BiFunction; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.function.UnaryOperator; +import java.util.stream.Collectors; + +import javax.xml.datatype.DatatypeConfigurationException; +import javax.xml.datatype.DatatypeConstants; +import javax.xml.datatype.DatatypeFactory; +import javax.xml.datatype.XMLGregorianCalendar; + +import org.eclipse.basyx.vab.exception.provider.ResourceNotFoundException; +import org.eclipse.basyx.vab.protocol.opcua.connector.ClientConfiguration; +import org.eclipse.basyx.vab.protocol.opcua.connector.IOpcUaClient; +import org.eclipse.basyx.vab.protocol.opcua.exception.AmbiguousBrowsePathException; +import org.eclipse.basyx.vab.protocol.opcua.exception.OpcUaException; +import org.eclipse.basyx.vab.protocol.opcua.types.MessageSecurityMode; +import org.eclipse.basyx.vab.protocol.opcua.types.NodeId; +import org.eclipse.basyx.vab.protocol.opcua.types.SecurityPolicy; +import org.eclipse.basyx.vab.protocol.opcua.types.UnsignedByte; +import org.eclipse.basyx.vab.protocol.opcua.types.UnsignedInteger; +import org.eclipse.basyx.vab.protocol.opcua.types.UnsignedLong; +import org.eclipse.basyx.vab.protocol.opcua.types.UnsignedShort; +import org.eclipse.milo.opcua.sdk.client.AddressSpace; +import org.eclipse.milo.opcua.sdk.client.OpcUaClient; +import org.eclipse.milo.opcua.sdk.client.api.UaClient; +import org.eclipse.milo.opcua.sdk.client.api.config.OpcUaClientConfig; +import org.eclipse.milo.opcua.sdk.client.api.config.OpcUaClientConfigBuilder; +import org.eclipse.milo.opcua.sdk.client.api.identity.AnonymousProvider; +import org.eclipse.milo.opcua.stack.client.DiscoveryClient; +import org.eclipse.milo.opcua.stack.core.UaException; +import org.eclipse.milo.opcua.stack.core.types.builtin.DataValue; +import org.eclipse.milo.opcua.stack.core.types.builtin.DateTime; +import org.eclipse.milo.opcua.stack.core.types.builtin.ExpandedNodeId; +import org.eclipse.milo.opcua.stack.core.types.builtin.LocalizedText; +import org.eclipse.milo.opcua.stack.core.types.builtin.Variant; +import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UByte; +import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UInteger; +import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.ULong; +import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UShort; +import org.eclipse.milo.opcua.stack.core.types.enumerated.TimestampsToReturn; +import org.eclipse.milo.opcua.stack.core.types.structured.BrowsePath; +import org.eclipse.milo.opcua.stack.core.types.structured.BrowsePathResult; +import org.eclipse.milo.opcua.stack.core.types.structured.BrowsePathTarget; +import org.eclipse.milo.opcua.stack.core.types.structured.CallMethodRequest; +import org.eclipse.milo.opcua.stack.core.types.structured.EndpointDescription; +import org.eclipse.milo.opcua.stack.core.types.structured.TranslateBrowsePathsToNodeIdsResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Provides a wrapper around the eclipse Milo OPC UA client that works in the BaSyx environment. + */ +public class MiloOpcUaClient implements IOpcUaClient { + private static final Logger logger = LoggerFactory.getLogger(MiloOpcUaClient.class); + private static DatatypeFactory xmlDatatypeFactory; + + private ClientConfiguration configuration = new ClientConfiguration(); + private OpcUaClientConfigBuilder miloConfiguration; + private CompletableFuture futureClient; + private String endpointUrl; + + static { + try { + xmlDatatypeFactory = DatatypeFactory.newInstance(); + } catch (DatatypeConfigurationException e) { + logger.error( + "Failed to instantiate XML DatatypeFactory. This will lead to NullPointerExceptions, if DateTime values are received from the OPC UA server.", + e); + } + } + + /** + * Creates a new OPC UA client for the given endpoint URL with a default configuration. + * + *

+ * The client will attempt to discover available endpoint at this URL. The one to use will be + * selected by looking at the security policy and message security mode. These can be configured + * using {@link #setConfiguration(ClientConfiguration)}. + * + * @param endpointUrl The client will attempt to discover available endpoints at this URL. Among all + * of these the one that matches the configured security policy and message + * security mode will be selected. + * + * @throws IllegalArgumentException if endpointUrl is null. + */ + public MiloOpcUaClient(String endpointUrl) { + if (endpointUrl == null || endpointUrl.isEmpty()) { + throw new IllegalArgumentException("endpointUrl must not be null."); + } + + this.endpointUrl = endpointUrl; + } + + /** + * Gets the current client configuration. + * + *

+ * See the documentation of {@link IOpcUaClient#getConfiguration()} for more information. + * + * @return A copy of the current configuration. + */ + @Override + public synchronized ClientConfiguration getConfiguration() { + // Return a copy to protect from external changes. + return configuration.clone(); + } + + /** + * Sets the client configuration. + * + *

+ * See the documentation of {@link IOpcUaClient#setConfiguration(ClientConfiguration)} for more + * information. + */ + @Override + public synchronized void setConfiguration(ClientConfiguration configuration) { + if (hasConnected()) { + throw new IllegalStateException("Cannot change security configuration after opening the connection."); + } + + // Create a local copy to protect from external changes. + this.configuration = (configuration != null) ? configuration.clone() : new ClientConfiguration(); + } + + /** + * Sets advanced configuration settings not available in {@link ClientConfiguration}. + * + *

+ * This method allows setting a Milo-specific configuration object which gives access to the full + * range of options that Milo supports as opposed to the limited selection in + * {@link ClientConfiguration}. + * + *

+ * Be aware, that the security policy and message security mode for endpoint selection + * can only be set using {@link #setConfiguration(ClientConfiguration)}.
+ * Additionally, for settings which are available in both configuration objects, + * {@link ClientConfiguration} will take precedence over {@link OpcUaClientConfigBuilder}. + * + *

+ * The order in which you call {@link #setConfiguration(ClientConfiguration)} and + * {@link #setConfiguration(OpcUaClientConfigBuilder)} is not important. + * + * Caution: Use this method at your own risk. Your code might break if BaSyx switches to a + * newer version of Milo or a different OPC UA implementation altogether. + * + * @param configuration The Milo client configuration object. + * + * @throws IllegalStateException if this method is called after a connection has been established. + */ + public synchronized void setConfiguration(OpcUaClientConfigBuilder configuration) { + if (hasConnected()) { + throw new IllegalStateException("Cannot change security configuration after opening the connection."); + } + + miloConfiguration = configuration; + } + + /** + * Gets the endpoint URL that this client connects to. + * + * @return The endpoint URL of the server to connect to. + */ + @Override + public String getEndpointUrl() { + return endpointUrl; + } + + /** + * Gets a value signifying whether this client has already attempted to establish a connection to + * the server endpoint. + * + *

+ * See the documentation of {@link IOpcUaClient#hasConnected()} for more information. + */ + @Override + public boolean hasConnected() { + synchronized (this) { + return futureClient != null; + } + } + + /** + * Gets the client object from the underlying OPC UA library. + * + *

+ * If the client hasn't been created, yet, it will be created automatically, making any subsequent + * changes to the configuration impossible. + * + * Caution: Use this method at your own risk. The underlying client API might change without + * notice, making any user code relying on this method fragile. + * + * @return The underlying client object. + */ + public synchronized CompletableFuture getClient() { + if (futureClient != null) { + return futureClient; + } else { + OpcUaClient client = createClient(); + futureClient = client.connect(); + return futureClient; + } + } + + /** + * Creates an instance of the underlying client using the current configuration. + * + * @return The created client. + * + * @throws OpcUaException as a wrapper exception around any Milo exceptions thrown during client + * creation. + */ + private OpcUaClient createClient() { + // Set up a filter which determines the correct endpoint by its security settings. + SecurityPolicy securityPolicy = configuration.getSecurityPolicy(); + MessageSecurityMode messageSecurityMode = configuration.getMessageSecurityMode(); + Predicate endpointFilter = ep -> { + boolean securityPolicyMatches = ep.getSecurityPolicyUri() + .equals(mapSecurityPolicy(securityPolicy).getUri()); + boolean messageSecurityModeMatches = (ep.getSecurityMode() == mapMessageSecurityMode(messageSecurityMode)); + return securityPolicyMatches && messageSecurityModeMatches; + }; + + EndpointDescription endpoint = discoverEndpoint(endpointUrl, endpointFilter); + + logger.debug("Using endpoint: {} [{}/{}]", endpoint.getEndpointUrl(), securityPolicy, + messageSecurityMode); + + try { + OpcUaClientConfig config = buildMiloConfiguration(endpoint); + return OpcUaClient.create(config); + } catch (UaException e) { + throw new OpcUaException(e); + } + } + + private OpcUaClientConfig buildMiloConfiguration(EndpointDescription endpoint) { + OpcUaClientConfigBuilder builder = (miloConfiguration != null) ? miloConfiguration + : createDefaultMiloConfigBuilder(); + builder.setApplicationName(LocalizedText.english(configuration.getApplicationName())) + .setApplicationUri(configuration.getApplicationUri()) + .setCertificate(configuration.getCertificate()) + .setKeyPair(configuration.getKeyPair()) + .setEndpoint(endpoint); + + return builder.build(); + } + + private OpcUaClientConfigBuilder createDefaultMiloConfigBuilder() { + return OpcUaClientConfig.builder() + .setIdentityProvider(new AnonymousProvider()); + } + + /** + * Returns the first endpoint matching the filter predicate discovered at the endpointUrl. + * + * @param endpointUrl The discovery endpoint to query. + * @param filter A predicate that returns 'true' for any endpoint that can be used. + * + * @return The first endpoint at the URL which matches the filter. + * + * @throws OpcUaException if no endpoint matching the filter can be found at the url or if the + * discovery thread is interrupted. + */ + private EndpointDescription discoverEndpoint(String endpointUrl, Predicate filter) + throws OpcUaException { + try { + return discoverEndpoints(endpointUrl) + .thenApply(list -> list.stream() + .filter(filter) + .findFirst() + .orElseThrow(() -> new OpcUaException("No endpoint found at " + endpointUrl))) + .get(); + } catch (ExecutionException e) { + throw (OpcUaException) e.getCause(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new OpcUaException("Endpoint discovery interrupted", e); + } + } + + /** + * Discovers all OPC UA endpoints at the given URL. + * + * @param endpointUrl The URL where to discover endpoints. + * + * @return A future which will either result in a list of endpoints or completes with an + * {@link OpcUaException}. + */ + private CompletableFuture> discoverEndpoints(String endpointUrl) { + return DiscoveryClient.getEndpoints(endpointUrl) + .handleAsync((list, ex) -> { + if (ex != null) { + return retryDiscovery(endpointUrl); + } else { + return list; + } + }); + } + + private List retryDiscovery(String endpointUrl) { + String discoveryUrl = createExplicitDiscoveryUrl(endpointUrl); + logger.debug("Discovery failed at original endpoint URL. Trying with explicit discovery URL: {}", discoveryUrl); + + try { + return DiscoveryClient.getEndpoints(discoveryUrl).get(); + } catch (InterruptedException e) { + logger.error("Endpoint discovery failed because thread was interrupted."); + Thread.currentThread().interrupt(); + throw new OpcUaException(e); + } catch (ExecutionException e) { + logger.error("Endpoint discovery failed."); + throw makeOpcUaExceptionFromCause(e); + } + } + + private String createExplicitDiscoveryUrl(String discoveryUrl) { + discoveryUrl = discoveryUrl.endsWith("/") ? discoveryUrl : discoveryUrl + "/"; + return discoveryUrl + "discovery"; + } + + /** Maps the BaSyx SecurityPolicy enum to the matching Milo enum */ + private org.eclipse.milo.opcua.stack.core.security.SecurityPolicy mapSecurityPolicy(SecurityPolicy securityPolicy) { + return org.eclipse.milo.opcua.stack.core.security.SecurityPolicy.valueOf(securityPolicy.toString()); + } + + /** Maps the BaSyx MessageSecurityMode enum to the matching Milo enum */ + private org.eclipse.milo.opcua.stack.core.types.enumerated.MessageSecurityMode mapMessageSecurityMode( + MessageSecurityMode messageSecurityMode) { + return org.eclipse.milo.opcua.stack.core.types.enumerated.MessageSecurityMode.valueOf(messageSecurityMode + .toString()); + } + + /** + * Gets the id of the node matching the given browse path when resolved against the root node. + * + *

+ * See the documentation of {@link IOpcUaClient#translateBrowsePathToNodeId(String)} for more + * information. + */ + @Override + public NodeId translateBrowsePathToNodeId(String browsePath) { + try { + return translateBrowsePathToNodeIdAsync(browsePath).get(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new OpcUaException(e); + } catch (ExecutionException e) { + throw makeOpcUaExceptionFromCause(e); + } + } + + /** + * Gets the id of the node matching the given browse path when resolved against the root node. + * + *

+ * See the documentation of {@link IOpcUaClient#translateBrowsePathToNodeIdAsync(String)} for more + * information. + */ + @Override + public CompletableFuture translateBrowsePathToNodeIdAsync(String browsePath) { + BrowsePath bp = BrowsePathHelper.parse(browsePath); + return translateBrowsePathToNodeId(bp); + } + + /** + * Gets the id of the node pointed to by when resolving the given path against the starting node. + * + *

+ * See the documentation of {@link IOpcUaClient#translateBrowsePathToNodeId(NodeId, String)} for + * more information. + */ + @Override + public NodeId translateBrowsePathToNodeId(NodeId startingNode, String relativePath) { + try { + return translateBrowsePathToNodeIdAsync(startingNode, relativePath).get(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new OpcUaException(e); + } catch (ExecutionException e) { + throw makeOpcUaExceptionFromCause(e); + } + } + + /** + * Gets the id of the node pointed to by when resolving the given path against the starting node. + * + *

+ * See the documentation of {@link IOpcUaClient#translateBrowsePathToNodeId(NodeId, String)} for + * more information. + */ + @Override + public CompletableFuture translateBrowsePathToNodeIdAsync(NodeId startingNode, String relativePath) { + if (!(startingNode instanceof NodeId)) { + throw new IllegalArgumentException(); + } + + BrowsePath bp = BrowsePathHelper.parse(startingNode, relativePath); + return translateBrowsePathToNodeId(bp); + } + + /** + * Gets the id of the last two nodes pointed to by when resolving the given path against the + * starting node. + * + *

+ * See the documentation of {@link IOpcUaClient#translateBrowsePathToParentAndTargetNodeId(String)} + * for more information. + */ + @Override + public List translateBrowsePathToParentAndTargetNodeId(String browsePath) { + try { + return translateBrowsePathToParentAndTargetNodeIdAsync(browsePath).get(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new OpcUaException(e); + } catch (ExecutionException e) { + throw makeOpcUaExceptionFromCause(e); + } + } + + /** + * Gets the id of the last two nodes pointed to by when resolving the given path against the + * starting node. + * + *

+ * See the documentation of + * {@link IOpcUaClient#translateBrowsePathToParentAndTargetNodeIdAsync(String)} for more + * information. + */ + @Override + public CompletableFuture> translateBrowsePathToParentAndTargetNodeIdAsync(String browsePath) { + BrowsePath targetPath = BrowsePathHelper.parse(browsePath); + BrowsePath parentPath = BrowsePathHelper.getParent(targetPath); + + List browsePaths = new ArrayList<>(2); + browsePaths.add(0, targetPath); + browsePaths.add(1, parentPath); + + return translateBrowsePathsToNodeIds(browsePaths); + } + + /** + * Translates a list of browse paths to a list of associated node ids. + * + * @param browsePaths A list of browse paths to resolve. + * + * @return A future for a list of node ids. Ids are ordered in the same way as their matching browse + * paths. + * + * @throws ResourceNotFoundException if any of the browse paths doesn't lead to any node at all. + * @throws AmbiguousBrowsePathException if any of the browse paths is ambiguous, i.e., it leads to + * multiple nodes. + * @throws OpcUaException if any other OPC UA related error occurs. This is a generic + * wrapper type for exceptions thrown by the client library. + */ + private CompletableFuture> translateBrowsePathsToNodeIds(List browsePaths) { + // Prepare this 'address space' for later when we need to convert an expanded node id + // to a regular one. That requires a round-trip with the server. + CompletableFuture futureAddressSpace = getClient() + .thenApplyAsync(UaClient::getAddressSpace); + + // This function gets the results from the response, unless the service failed in some way. + Function getResults = response -> { + if (!response.getResponseHeader().getServiceResult().isGood()) { + throw new OpcUaException("TranslateBrowsePaths failed with status code: " + + response.getResponseHeader().getServiceResult()); + } else { + return response.getResults(); + } + }; + + // This function gets the expanded node id matching each browse path, unless at least one + // of them failed to resolve or was ambiguous. + Function> extractExpandedNodeIds = results -> { + List exNodeIds = new ArrayList<>(results.length); + for (int i = 0; i < results.length; i++) { + BrowsePathResult r = results[i]; + + if (!r.getStatusCode().isGood()) { + String exceptionMessage = String.format("Browse path [%s] failed to resolve with status code: %s", + browsePaths.get(i), r.getStatusCode()); + throw new ResourceNotFoundException(exceptionMessage); + } else { + BrowsePathTarget[] targets = r.getTargets(); + if (targets.length > 1) { + String exceptionMessage = String.format("Browse path [%s] leads to multiple targets.", + browsePaths.get(i)); + throw new AmbiguousBrowsePathException(exceptionMessage); + } + exNodeIds.add(i, targets[0].getTargetId()); + } + } + return exNodeIds; + }; + + // This function maps the expanded node ids to regular node ids, using a provided + // address space object. + BiFunction, AddressSpace, List> mapToNodeIds = (expandedNodeIds, + addressSpace) -> expandedNodeIds.stream() + .map(addressSpace::toNodeId) + .map(NodeId::new) + .collect(Collectors.toList()); + + // This function logs the result and returns it without any changes. + UnaryOperator> log = nodeIds -> { + List bpStrings = browsePaths.stream() + .map(bp -> BrowsePathHelper.toString(bp.getRelativePath())) + .collect(Collectors.toList()); + logger.debug("Translated browse paths {} to node ids {}", bpStrings, nodeIds); + + return nodeIds; + }; + + // Prepare the future which returns the node ids. + CompletableFuture> future = getClient() + .thenCompose(client -> client.translateBrowsePaths(browsePaths)) + .thenApply(getResults) + .thenApply(extractExpandedNodeIds) + .thenCombine(futureAddressSpace, mapToNodeIds); + + // Add the logger stage only if required because it is computationally rather expensive. + return logger.isDebugEnabled() ? future.thenApply(log) : future; + } + + private CompletableFuture translateBrowsePathToNodeId(BrowsePath browsePath) { + List browsePaths = Collections.singletonList(browsePath); + + return translateBrowsePathsToNodeIds(browsePaths) + .thenApply(nodeIds -> nodeIds.get(0)); + } + + /** + * Reads the current value from an OPC UA node. + * + *

+ * See the documentation of {@link IOpcUaClient#readValue(NodeId)} for more information. + */ + @Override + public Object readValue(NodeId nodeId) throws OpcUaException { + try { + return readValueAsync(nodeId).get(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new OpcUaException(e); + } catch (ExecutionException e) { + throw makeOpcUaExceptionFromCause(e); + } + } + + /** + * Reads the current value from an OPC UA node. + * + *

+ * See the documentation of {@link IOpcUaClient#readValueAsync(NodeId)} for more information. + */ + @Override + public CompletableFuture readValueAsync(NodeId nodeId) { + if (nodeId == null) { + throw new IllegalArgumentException("nodeId must not be null."); + } + + logger.debug("Reading node '{}'.", nodeId); + + return getClient() + .thenCompose(client -> client.readValue(0, TimestampsToReturn.Neither, nodeId.getInternalId())) + .thenApply(dv -> { + if (dv.getStatusCode().isGood()) { + return dv.getValue(); + } else { + throw new OpcUaException("Read failed with: " + dv.getStatusCode()); + } + }) + .thenApply(this::unwrapVariant) + .exceptionally(e -> { + if (e instanceof CompletionException) { + throw makeOpcUaExceptionFromCause(e); + } else { + throw ensureOpcUaException(e); + } + }); + } + + /** + * Writes the value of an OPC UA node. + * + *

+ * See the documentation of {@link IOpcUaClient#writeValue(NodeId, Object)} for more information. + */ + @Override + public void writeValue(NodeId nodeId, Object value) throws OpcUaException { + try { + writeValueAsync(nodeId, value).get(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new OpcUaException(e); + } catch (ExecutionException e) { + throw makeOpcUaExceptionFromCause(e); + } + } + + /** + * Writes the value of an OPC UA node. + * + *

+ * See the documentation of {@link IOpcUaClient#writeValueAsync(NodeId, Object)} for more + * information. + */ + @Override + public CompletableFuture writeValueAsync(NodeId nodeId, Object value) { + if (nodeId == null) { + throw new IllegalArgumentException("nodeId must not be null."); + } + + logger.debug("Writing node '{}' with value {}.", nodeId, value); + + DataValue dv = new DataValue(wrapVariant(value)); + + return getClient() + .thenCompose(client -> client.writeValue(nodeId.getInternalId(), dv)) + .thenAccept(status -> { + if (!status.isGood()) { + throw new OpcUaException("Write failed with: " + status); + } + }).exceptionally(e -> { + if (e instanceof CompletionException) { + throw makeOpcUaExceptionFromCause(e); + } else { + throw ensureOpcUaException(e); + } + }); + } + + /** + * Invokes an OPC UA method on an object. + * + *

+ * See the documentation of {@link IOpcUaClient#invokeMethod(NodeId, NodeId, Object...)} for more + * information. + */ + @Override + public List invokeMethod(NodeId ownerId, NodeId methodId, Object... parameters) throws OpcUaException { + try { + return invokeMethodAsync(ownerId, methodId, parameters).get(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new OpcUaException(e); + } catch (ExecutionException e) { + throw makeOpcUaExceptionFromCause(e); + } + } + + /** + * Invokes an OPC UA method on an object. + * + *

+ * See the documentation of {@link IOpcUaClient#invokeMethodAsync(NodeId, NodeId, Object...)} for + * more information. + */ + @Override + public CompletableFuture> invokeMethodAsync(NodeId ownerId, NodeId methodId, Object... parameters) { + if (ownerId == null || methodId == null) { + throw new IllegalArgumentException("ownerId and methodId must not be null."); + } + + logger.debug("Invoking method '{}' on node '{}' with arguments {}.", methodId, ownerId, parameters); + + Variant[] inputs = new Variant[parameters.length]; + for (int i = 0; i < parameters.length; i++) { + inputs[i] = wrapVariant(parameters[i]); + } + + CallMethodRequest req = new CallMethodRequest(ownerId.getInternalId(), methodId.getInternalId(), inputs); + + return getClient() + .thenCompose(client -> client.call(req)) + .thenApply(result -> { + if (!result.getStatusCode().isGood()) { + throw new OpcUaException("Method invocation failed with: " + + result.getStatusCode()); + } + return Arrays.stream(result.getOutputArguments()) + .map(this::unwrapVariant) + .collect(Collectors.toList()); + }).exceptionally(e -> { + if (e instanceof CompletionException) { + throw makeOpcUaExceptionFromCause(e); + } else { + throw ensureOpcUaException(e); + } + }); + } + + /** + * Wraps a data value in a {@link Variant}. + * + *

+ * While wrapping, value types for which equivalent types exist in Milo's type system are + * automatically converted. + * + * @param value The value to wrap in a Variant. + * + * @return A new Variant wrapping value. + */ + private Variant wrapVariant(Object value) { + return new Variant(mapBaSyxToMiloTypes(value)); + } + + /** + * Unwraps a data value from a {@link Variant}. + * + *

+ * While unwrapping, data values with Milo-specific types will be converted to their respective + * BaSyx equivalents. + * + * @param variant The Variant to unwrap. + * + * @return The raw data value contained in variant. + */ + private Object unwrapVariant(Variant variant) { + if (variant == null || variant.getValue() == null) { + return null; + } + + ExpandedNodeId typeId = variant.getDataType().orElse(null); + if (typeId == null) { + return null; + } + + return mapMiloToBaSyxTypes(variant.getValue()); + } + + /** + * Converts from OPC UA data types implemented in Milo to types used in BaSyx. + * + * @param value The data value coming from Milo. + * + * @return The corresponding BaSyx object. + */ + private Object mapMiloToBaSyxTypes(Object value) { + if (value instanceof DateTime) { + long millis = ((DateTime) value).getJavaTime(); + GregorianCalendar cal = new GregorianCalendar(); + cal.setTimeInMillis(millis); + return xmlDatatypeFactory.newXMLGregorianCalendar(cal); + } else if (value instanceof UByte) { + return new UnsignedByte((UByte) value); + } else if (value instanceof UShort) { + return new UnsignedShort((UShort) value); + } else if (value instanceof UInteger) { + return new UnsignedInteger((UInteger) value); + } else if (value instanceof ULong) { + return new UnsignedLong((ULong) value); + } else { + return value; + } + } + + /** + * Converts from BaSyx types to OPC UA data types implemented in Milo. + * + * @param value The value coming from BaSyx. + * + * @return The corresponding Milo object. + */ + private Object mapBaSyxToMiloTypes(Object value) { + if (value instanceof XMLGregorianCalendar) { + XMLGregorianCalendar v = (XMLGregorianCalendar) value; + if (v.getXMLSchemaType() != DatatypeConstants.DATETIME) { + throw new OpcUaException( + "The OPC UA DateTime type doesn't support incomplete date/time specifications. Illegal value: " + + v); + } + Instant i = Instant.ofEpochMilli(v.toGregorianCalendar().getTimeInMillis()); + return new DateTime(i); + } else if (value instanceof UnsignedByte) { + return ((UnsignedByte) value).getInternalValue(); + } else if (value instanceof UnsignedShort) { + return ((UnsignedShort) value).getInternalValue(); + } else if (value instanceof UnsignedInteger) { + return ((UnsignedInteger) value).getInternalValue(); + } else if (value instanceof UnsignedLong) { + return ((UnsignedLong) value).getInternalValue(); + } else { + return value; + } + } + + private OpcUaException makeOpcUaExceptionFromCause(Throwable e) { + return ensureOpcUaException(e.getCause()); + } + + private OpcUaException ensureOpcUaException(Throwable e) { + return (e instanceof OpcUaException) ? (OpcUaException) e : new OpcUaException(e); + } +} diff --git a/src/main/java/org/eclipse/basyx/vab/protocol/opcua/exception/AmbiguousBrowsePathException.java b/src/main/java/org/eclipse/basyx/vab/protocol/opcua/exception/AmbiguousBrowsePathException.java new file mode 100644 index 00000000..7a4b23fe --- /dev/null +++ b/src/main/java/org/eclipse/basyx/vab/protocol/opcua/exception/AmbiguousBrowsePathException.java @@ -0,0 +1,35 @@ +/******************************************************************************* + * Copyright (C) 2021 Festo Didactic SE + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + ******************************************************************************/ +package org.eclipse.basyx.vab.protocol.opcua.exception; + +/** + * Indicates that a browse path was used which matches for than one node on the server. + * + *

+ * While it is perfectly valid in OPC UA to have browse paths which match more than one single node, + * BaSyx doesn't deal well with these cases. That's why this exception is thrown whenever such a + * browse path is encountered. + */ +public final class AmbiguousBrowsePathException extends OpcUaException { + private static final long serialVersionUID = 1L; + + public AmbiguousBrowsePathException(String msg) { + super(msg); + } + + public AmbiguousBrowsePathException(Throwable cause) { + super(cause); + } + + public AmbiguousBrowsePathException(String message, Throwable cause) { + super(message, cause); + } + +} diff --git a/src/main/java/org/eclipse/basyx/vab/protocol/opcua/exception/OpcUaException.java b/src/main/java/org/eclipse/basyx/vab/protocol/opcua/exception/OpcUaException.java new file mode 100644 index 00000000..c8049a7b --- /dev/null +++ b/src/main/java/org/eclipse/basyx/vab/protocol/opcua/exception/OpcUaException.java @@ -0,0 +1,38 @@ +/******************************************************************************* + * Copyright (C) 2021 Festo Didactic SE + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + ******************************************************************************/ +package org.eclipse.basyx.vab.protocol.opcua.exception; + +import org.eclipse.basyx.vab.exception.provider.ProviderException; + +/** + * Generic wrapper type for exceptions from the underlying client library. + * + *

+ * User code can catch this exception type to handle all OPC UA related exceptions uniformly. If + * more fine-grained handling of different failure modes is required, calling code must inspect the + * cause through {@link #getCause()} and deal with exception types of the underlying library. + */ +public class OpcUaException extends ProviderException { + + private static final long serialVersionUID = 1L; + + public OpcUaException(String msg) { + super(msg); + } + + public OpcUaException(Throwable cause) { + super(cause); + } + + public OpcUaException(String message, Throwable cause) { + super(message, cause); + } + +} diff --git a/src/main/java/org/eclipse/basyx/vab/protocol/opcua/server/BaSyxOpcUaClient.java b/src/main/java/org/eclipse/basyx/vab/protocol/opcua/server/BaSyxOpcUaClient.java index f08a4531..dd9f58ae 100644 --- a/src/main/java/org/eclipse/basyx/vab/protocol/opcua/server/BaSyxOpcUaClient.java +++ b/src/main/java/org/eclipse/basyx/vab/protocol/opcua/server/BaSyxOpcUaClient.java @@ -13,6 +13,10 @@ import org.eclipse.milo.opcua.sdk.client.OpcUaClient; +/** + * @deprecated As of version 1.1. Was never actually used, so no direct replacement available. + */ +@Deprecated public interface BaSyxOpcUaClient { void run(OpcUaClient client, CompletableFuture future) throws Exception; diff --git a/src/main/java/org/eclipse/basyx/vab/protocol/opcua/server/BaSyxOpcUaClientRunner.java b/src/main/java/org/eclipse/basyx/vab/protocol/opcua/server/BaSyxOpcUaClientRunner.java index 8c38c1f8..94f33051 100644 --- a/src/main/java/org/eclipse/basyx/vab/protocol/opcua/server/BaSyxOpcUaClientRunner.java +++ b/src/main/java/org/eclipse/basyx/vab/protocol/opcua/server/BaSyxOpcUaClientRunner.java @@ -21,6 +21,8 @@ import java.util.concurrent.ExecutionException; import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.eclipse.basyx.vab.protocol.opcua.connector.IOpcUaClient; +import org.eclipse.basyx.vab.protocol.opcua.connector.milo.MiloOpcUaClient; import org.eclipse.milo.opcua.sdk.client.OpcUaClient; import org.eclipse.milo.opcua.sdk.client.api.config.OpcUaClientConfig; import org.eclipse.milo.opcua.sdk.client.api.identity.AnonymousProvider; @@ -42,6 +44,10 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +/** + * @deprecated As of version 1.1. Replaced by {@link IOpcUaClient} and {@link MiloOpcUaClient}. + */ +@Deprecated public class BaSyxOpcUaClientRunner { private static Logger logger = LoggerFactory.getLogger(BaSyxOpcUaClientRunner.class); diff --git a/src/main/java/org/eclipse/basyx/vab/protocol/opcua/server/KeyStoreLoaderClient.java b/src/main/java/org/eclipse/basyx/vab/protocol/opcua/server/KeyStoreLoaderClient.java index 535c9942..085931ac 100644 --- a/src/main/java/org/eclipse/basyx/vab/protocol/opcua/server/KeyStoreLoaderClient.java +++ b/src/main/java/org/eclipse/basyx/vab/protocol/opcua/server/KeyStoreLoaderClient.java @@ -21,12 +21,24 @@ import java.security.cert.X509Certificate; import java.util.regex.Pattern; +import org.eclipse.basyx.vab.protocol.opcua.CertificateHelper; +import org.eclipse.basyx.vab.protocol.opcua.connector.ClientConfiguration; import org.eclipse.milo.opcua.sdk.server.util.HostnameUtil; import org.eclipse.milo.opcua.stack.core.util.SelfSignedCertificateBuilder; import org.eclipse.milo.opcua.stack.core.util.SelfSignedCertificateGenerator; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +/** + * @deprecated + * As of version 1.1. No full replacement planned. + *
A limited replacement is available in {@link CertificateHelper} which creates self-signed + * certificates in memory to be passed to + * {@link ClientConfiguration#setKeyPairAndCertificate(KeyPair, X509Certificate)}. But it is the + * user's responsibility to persist these in a {@link KeyStore}, if they wish. + * + */ +@Deprecated public class KeyStoreLoaderClient { private static final Pattern IP_ADDR_PATTERN = Pattern diff --git a/src/main/java/org/eclipse/basyx/vab/protocol/opcua/types/MessageSecurityMode.java b/src/main/java/org/eclipse/basyx/vab/protocol/opcua/types/MessageSecurityMode.java new file mode 100644 index 00000000..e1ba6811 --- /dev/null +++ b/src/main/java/org/eclipse/basyx/vab/protocol/opcua/types/MessageSecurityMode.java @@ -0,0 +1,19 @@ +/******************************************************************************* + * Copyright (C) 2021 Festo Didactic SE + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + ******************************************************************************/ +package org.eclipse.basyx.vab.protocol.opcua.types; + +/** + * Available message security modes for OPC UA connections. + */ +public enum MessageSecurityMode { + None, + Sign, + SignAndEncrypt +} diff --git a/src/main/java/org/eclipse/basyx/vab/protocol/opcua/types/NodeId.java b/src/main/java/org/eclipse/basyx/vab/protocol/opcua/types/NodeId.java new file mode 100644 index 00000000..8244d769 --- /dev/null +++ b/src/main/java/org/eclipse/basyx/vab/protocol/opcua/types/NodeId.java @@ -0,0 +1,233 @@ +/******************************************************************************* + * Copyright (C) 2021 Festo Didactic SE + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + ******************************************************************************/ +package org.eclipse.basyx.vab.protocol.opcua.types; + +import java.util.Objects; +import java.util.UUID; + +import org.eclipse.basyx.vab.protocol.opcua.exception.OpcUaException; +import org.eclipse.milo.opcua.stack.core.UaRuntimeException; +import org.eclipse.milo.opcua.stack.core.types.builtin.ByteString; +import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UInteger; + +/** + * A node id unique identifies a node within an OPC UA server's address space. + */ +public final class NodeId { + private final org.eclipse.milo.opcua.stack.core.types.builtin.NodeId internalId; + private String cachedStringRepresentation; + + /** + * Creates a numeric node id with the given namespace. + * + *

+ * Numeric node ids technically use an unsigned 32 bit integer type. Since that type doesn't exist + * in Java, this method accepts a long, instead. The identifier is, however, not allowed to be + * negative. + * + * @param namespaceIndex The index of the node's namespace. + * @param identifier The numeric id. Must be greater or equal 0. + * + * @throws IllegalArgumentException if the identifier is negative. + */ + public NodeId(int namespaceIndex, long identifier) { + if (identifier < 0) { + throw new IllegalArgumentException("Numeric identifier must not be negative."); + } + + internalId = new org.eclipse.milo.opcua.stack.core.types.builtin.NodeId(namespaceIndex, UInteger.valueOf( + identifier)); + } + + /** + * Creates a string node id with the given namespace. + * + * @param namespaceIndex The index of the node's namespace. + * @param identifier The string id. Maximum length is 4096 characters. + * + * @throws IllegalArgumentException if identifier is null or exceeds the maximum + * length. + */ + public NodeId(int namespaceIndex, String identifier) { + Objects.requireNonNull(identifier); + + if (identifier.length() > 4096) { + throw new IllegalArgumentException("String identifier must not be longer than 4096 characters."); + } + + internalId = new org.eclipse.milo.opcua.stack.core.types.builtin.NodeId(namespaceIndex, identifier); + } + + /** + * Creates a GUID node id with the given namespace. + * + * @param namespaceIndex The index of the node's namespace. + * @param identifier The GUID id. + * + * @throws IllegalArgumentException if identifier is null. + */ + public NodeId(int namespaceIndex, UUID identifier) { + Objects.requireNonNull(identifier); + + internalId = new org.eclipse.milo.opcua.stack.core.types.builtin.NodeId(namespaceIndex, identifier); + } + + /** + * Creates a ByteString node id with the given namespace. + * + *

+ * ByteString is a special OPC UA type that loosely corresponds to a Java array of bytes. + * + * @param namespaceIndex The index of the node's namespace. + * @param identifier The ByteString id. Maximum length is 4096 bytes. + * + * @throws IllegalArgumentException if identifier is null or exceeds the maximum + * length. + */ + public NodeId(int namespaceIndex, byte[] identifier) { + Objects.requireNonNull(identifier); + + if (identifier.length > 4096) { + throw new IllegalArgumentException("ByteString identifier must not be longer than 4096 bytes."); + } + + internalId = new org.eclipse.milo.opcua.stack.core.types.builtin.NodeId(namespaceIndex, ByteString.of( + identifier)); + } + + public NodeId(org.eclipse.milo.opcua.stack.core.types.builtin.NodeId miloId) { + internalId = miloId; + } + + /** + * Creates a node id from a string representation in the standard string format (e.g. + * ns=1;i=1234). + * + *

+ * For a variant of this method which doesn't throw exceptions, see {@link #tryParse(String)}. + * + * @param s String representation of the NodeId. + * + * @return The created node id. + * + * @throws OpcUaException if the string doesn't represent a valid node id. + */ + public static NodeId parse(String s) { + try { + org.eclipse.milo.opcua.stack.core.types.builtin.NodeId miloId; + miloId = org.eclipse.milo.opcua.stack.core.types.builtin.NodeId.parse(s); + return new NodeId(miloId); + } catch (UaRuntimeException e) { + throw new OpcUaException(e); + } + } + + /** + * Creates a node id from a string representation in the standard string format (e.g. + * ns=1;i=1234). + * + *

+ * This method is similar to {@link #parse(String)} but returns null instead of + * throwing an exception if the given string doesn't represent a node id. + * + * @param s String representation of the NodeId. + * + * @return The created node id or null if s doesn't represent a node id. + */ + public static NodeId tryParse(String s) { + org.eclipse.milo.opcua.stack.core.types.builtin.NodeId miloId; + miloId = org.eclipse.milo.opcua.stack.core.types.builtin.NodeId.parseOrNull(s); + return (miloId != null) ? new NodeId(miloId) : null; + } + + /** + * Gets the wrapped Milo NodeId. + * + * @return The wrapped {@link org.eclipse.milo.opcua.stack.core.types.builtin.NodeId}. + */ + public org.eclipse.milo.opcua.stack.core.types.builtin.NodeId getInternalId() { + return internalId; + } + + /** + * Gets the namespace index of this node id. + * + * @return The namespace part of this node id. + */ + public int getNamespaceIndex() { + return internalId.getNamespaceIndex().intValue(); + } + + /** + * Gets the identifier of this node id. + * + *

+ * The returned type depends on the type of the node id. + *

    + *
  • Numeric id: {@link Long} + *
  • String id: {@link String} + *
  • GUID id: {@link UUID} + *
  • Opaque id: byte[] + *
+ * + * @return The identifier part of the node id. + */ + public Object getIdentifier() { + switch (internalId.getType()) { + case Opaque: + return ((ByteString) internalId.getIdentifier()).bytes(); + case Numeric: + return ((UInteger) internalId.getIdentifier()).longValue(); + default: + return internalId.getIdentifier(); + } + } + + /** + * Returns a machine-parseable string representation of this node id. + * + *

+ * The returned string is formatted according to the machine-parseable formatting rules outlined in + * part 6 of + * the specification. + * + *

+ * During the first call to this method the string is initially generated and cached. Subsequent + * calls come at no additional computation cost. + * + * @return A machine-parseable string representation of the node id. + */ + @Override + public String toString() { + if (cachedStringRepresentation == null) { + cachedStringRepresentation = internalId.toParseableString(); + } + + return cachedStringRepresentation; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof NodeId)) { + return false; + } + + NodeId other = (NodeId) obj; + return internalId.equals(other.internalId); + } + + @Override + public int hashCode() { + return internalId.hashCode(); + } +} diff --git a/src/main/java/org/eclipse/basyx/vab/protocol/opcua/types/SecurityPolicy.java b/src/main/java/org/eclipse/basyx/vab/protocol/opcua/types/SecurityPolicy.java new file mode 100644 index 00000000..c0eb85ba --- /dev/null +++ b/src/main/java/org/eclipse/basyx/vab/protocol/opcua/types/SecurityPolicy.java @@ -0,0 +1,22 @@ +/******************************************************************************* + * Copyright (C) 2021 Festo Didactic SE + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + ******************************************************************************/ +package org.eclipse.basyx.vab.protocol.opcua.types; + +/** + * Available security policies for OPC UA connections. + */ +public enum SecurityPolicy { + None, + Basic128Rsa15, + Basic256, + Basic256Sha256, + Aes128_Sha256_RsaOaep, + Aes256_Sha256_RsaPss +} diff --git a/src/main/java/org/eclipse/basyx/vab/protocol/opcua/types/UnsignedByte.java b/src/main/java/org/eclipse/basyx/vab/protocol/opcua/types/UnsignedByte.java new file mode 100644 index 00000000..adf36122 --- /dev/null +++ b/src/main/java/org/eclipse/basyx/vab/protocol/opcua/types/UnsignedByte.java @@ -0,0 +1,216 @@ +/******************************************************************************* + * Copyright (C) 2021 Festo Didactic SE + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + ******************************************************************************/ +package org.eclipse.basyx.vab.protocol.opcua.types; + +import java.math.BigInteger; + +import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UByte; + +/** An unsigned 8-bit integer, matching the UInt8 type from OPC UA. */ +public final class UnsignedByte { + /** The lowest possible value of an unsigned 8-bit integer. */ + public static final short MIN_VALUE = UByte.MIN_VALUE; + /** The highest possible value of an unsigned 8-bit integer. */ + public static final short MAX_VALUE = UByte.MAX_VALUE; + /** A constant instance holding the lowest possible value of an unsigned 8-bit integer. */ + public static final UnsignedByte MIN = new UnsignedByte(UByte.MIN); + /** A constant instance holding the highest possible value of an unsigned 8-bit integer. */ + public static final UnsignedByte MAX = new UnsignedByte(UByte.MAX); + + private final UByte value; + + /** + * Creates a new instance from the given byte. + * + * @param value The value. + * + * @throws NumberFormatException if the value is outside the allowed range of {@link #MIN_VALUE} to + * {@link #MAX_VALUE}. + */ + public UnsignedByte(byte value) throws NumberFormatException { + this.value = UByte.valueOf(value); + } + + /** + * Creates a new instance from the given short integer. + * + * @param value The value. + * + * @throws NumberFormatException if the value is outside the allowed range of {@link #MIN_VALUE} to + * {@link #MAX_VALUE}. + */ + public UnsignedByte(short value) throws NumberFormatException { + this.value = UByte.valueOf(value); + } + + /** + * Creates a new instance from the given integer. + * + * @param value The value. + * + * @throws NumberFormatException if the value is outside the allowed range of {@link #MIN_VALUE} to + * {@link #MAX_VALUE}. + */ + public UnsignedByte(int value) throws NumberFormatException { + this.value = UByte.valueOf(value); + } + + /** + * Creates a new instance by parsing the given string. + * + * @param value A decimal string representation of the value. + * + * @throws NumberFormatException if the string doesn't contain a decimal integer or the value is + * outside the allowed range of an unsigned 64 bit integer. + */ + public UnsignedByte(String value) throws NumberFormatException { + this.value = UByte.valueOf(value); + } + + /** + * Creates a new instance with the given internal value. Client code should normally not need to use + * this. + * + * @param value The internal value. + */ + public UnsignedByte(UByte value) { + this.value = value; + } + + /** + * Gets the internal representation of this value. Client code should not normally use this. + * + * @return The internal representation of this value. + */ + public UByte getInternalValue() { + return value; + } + + /** + * Adds another {@link UnsignedByte} to this one. + * + * @param other The value to add. + * + * @return A new instance holding the sum of this one and other. + * + * @throws NumberFormatException if the result is outside the allowed range of {@link #MIN_VALUE} to + * {@link #MAX_VALUE}. + */ + public UnsignedByte add(UnsignedByte other) throws NumberFormatException { + return new UnsignedByte(value.add(other.toByte())); + } + + /** + * Adds a byte to this {@link UnsignedByte}. + * + * @param other The value to add. + * + * @return A new instance holding the sum of this one and other. + * + * @throws NumberFormatException if the result is outside the allowed range of {@link #MIN_VALUE} to + * {@link #MAX_VALUE}. + */ + public UnsignedByte add(short other) throws NumberFormatException { + return new UnsignedByte(value.add(other)); + } + + /** + * Subtracts another {@link UnsignedByte} from this one. + * + * @param other The value to subtract. + * + * @return A new instance holding the difference between this one and other. + * + * @throws NumberFormatException if the result is outside the allowed range of {@link #MIN_VALUE} to + * {@link #MAX_VALUE}. + */ + public UnsignedByte subtract(UnsignedByte other) throws NumberFormatException { + return new UnsignedByte(value.subtract(other.toByte())); + } + + /** + * Subtracts a byte from this {@link UnsignedByte}. + * + * @param other The value to subtract. + * + * @return A new instance holding the difference between this one and other. + * + * @throws NumberFormatException if the result is outside the allowed range of {@link #MIN_VALUE} to + * {@link #MAX_VALUE}. + */ + public UnsignedByte subtract(short other) throws NumberFormatException { + return new UnsignedByte(value.subtract(other)); + } + + /** + * Gets a byte with this object's value. + * + *

+ * The returned value is signed. Calling code needs to take care to stick to unsigned semantics when + * dealing with it. + * + * @return A byte with this object's value. + */ + public short toByte() { + return value.byteValue(); + } + + /** + * Gets a short integer with this object's value. + * + *

+ * The returned value is signed. Calling code needs to take care to stick to unsigned semantics when + * dealing with it. + * + * @return A short integer with this object's value. + */ + public short toShort() { + return value.shortValue(); + } + + /** + * Gets an integer with this object's value. + * + *

+ * The returned value is signed. Calling code needs to take care to stick to unsigned semantics when + * dealing with it. + * + * @return An integer with this object's value. + */ + public int toInt() { + return value.intValue(); + } + + /** + * Gets a long with this object's value. + * + *

+ * The returned value is signed. Calling code needs to take care to stick to unsigned semantics when + * dealing with it. + * + * @return A long with this object's value. + */ + public long toLong() { + return value.longValue(); + } + + /** + * Gets a {@link BigInteger} with this object's value. + * + *

+ * The returned value is signed. Calling code needs to take care to stick to unsigned semantics when + * dealing with it. + * + * @return A {@link BigInteger} with this object's value. + */ + public BigInteger toBigInteger() { + return value.toBigInteger(); + } +} \ No newline at end of file diff --git a/src/main/java/org/eclipse/basyx/vab/protocol/opcua/types/UnsignedInteger.java b/src/main/java/org/eclipse/basyx/vab/protocol/opcua/types/UnsignedInteger.java new file mode 100644 index 00000000..dfe69ddd --- /dev/null +++ b/src/main/java/org/eclipse/basyx/vab/protocol/opcua/types/UnsignedInteger.java @@ -0,0 +1,182 @@ +/******************************************************************************* + * Copyright (C) 2021 Festo Didactic SE + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + ******************************************************************************/ +package org.eclipse.basyx.vab.protocol.opcua.types; + +import java.math.BigInteger; + +import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UInteger; + +/** An unsigned 32-bit integer, matching the UInt32 type from OPC UA. */ +public final class UnsignedInteger { + /** The lowest possible value of an unsigned 32-bit integer. */ + public static final long MIN_VALUE = UInteger.MIN_VALUE; + + /** The highest possible value of an unsigned 32-bit integer. */ + public static final long MAX_VALUE = UInteger.MAX_VALUE; + + /** A constant instance holding the lowest possible value of an unsigned 32-bit integer. */ + public static final UnsignedInteger MIN = new UnsignedInteger(UInteger.MIN); + + /** A constant instance holding the highest possible value of an unsigned 32-bit integer. */ + public static final UnsignedInteger MAX = new UnsignedInteger(UInteger.MAX); + + private final UInteger value; + + /** + * Creates a new instance from the given int value. + * + * @param value The value. + * + * @throws NumberFormatException if the value is outside the allowed range of {@link #MIN_VALUE} to + * {@link #MAX_VALUE}. + */ + public UnsignedInteger(int value) throws NumberFormatException { + this.value = UInteger.valueOf(value); + } + + /** + * Creates a new instance from the given long value. + * + * @param value The value. + * + * @throws NumberFormatException if the value is outside the allowed range of {@link #MIN_VALUE} to + * {@link #MAX_VALUE}. + */ + public UnsignedInteger(long value) throws NumberFormatException { + this.value = UInteger.valueOf(value); + } + + /** + * Creates a new instance by parsing the given string. + * + * @param value A decimal string representation of the value. + * + * @throws NumberFormatException if the string doesn't contain a decimal integer or the value is + * outside the allowed range of {@link #MIN_VALUE} to + * {@link #MAX_VALUE}. + */ + public UnsignedInteger(String value) throws NumberFormatException { + this.value = UInteger.valueOf(value); + } + + /** + * Creates a new instance with the given internal value. Client code should normally not need to use + * this. + * + * @param value The internal value. + */ + public UnsignedInteger(UInteger value) { + this.value = value; + } + + /** + * Gets the internal representation of this value. Client code should not normally use this. + * + * @return The internal representation of this value. + */ + public UInteger getInternalValue() { + return value; + } + + /** + * Adds another {@link UnsignedInteger} to this one. + * + * @param other The value to add. + * + * @return A new instance holding the sum of this one and other. + * + * @throws NumberFormatException if the result is outside the allowed range of {@link #MIN_VALUE} to + * {@link #MAX_VALUE}. + */ + public UnsignedInteger add(UnsignedInteger other) throws NumberFormatException { + return new UnsignedInteger(value.add(other.toInt())); + } + + /** + * Adds an integer to this {@link UnsignedInteger}. + * + * @param other The value to add. + * + * @return A new instance holding the sum of this one and other. + * + * @throws NumberFormatException if the result is outside the allowed range of {@link #MIN_VALUE} to + * {@link #MAX_VALUE}. + */ + public UnsignedInteger add(int other) throws NumberFormatException { + return new UnsignedInteger(value.add(other)); + } + + /** + * Subtracts another {@link UnsignedInteger} from this one. + * + * @param other The value to subtract. + * + * @return A new instance holding the difference between this one and other. + * + * @throws NumberFormatException if the result is outside the allowed range of {@link #MIN_VALUE} to + * {@link #MAX_VALUE}. + */ + public UnsignedInteger subtract(UnsignedInteger other) throws NumberFormatException { + return new UnsignedInteger(value.subtract(other.toInt())); + } + + /** + * Subtracts an integer from this {@link UnsignedInteger}. + * + * @param other The value to subtract. + * + * @return A new instance holding the difference between this one and other. + * + * @throws NumberFormatException if the result is outside the allowed range of {@link #MIN_VALUE} to + * {@link #MAX_VALUE}. + */ + public UnsignedInteger subtract(int other) throws NumberFormatException { + return new UnsignedInteger(value.subtract(other)); + } + + /** + * Gets an integer with this object's value. + * + *

+ * The returned value is signed. Calling code needs to take care to stick to unsigned semantics when + * dealing with it. + * + * @return An integer with this object's value. + */ + public int toInt() { + return value.intValue(); + } + + /** + * Gets a long with this object's value. + * + *

+ * The returned value is signed. Calling code needs to take care to stick to unsigned semantics when + * dealing with it. + * + * @return A long with this object's value. + */ + public long toLong() { + return value.longValue(); + } + + /** + * Gets a {@link BigInteger} with this object's value. + * + *

+ * The returned value is signed. Calling code needs to take care to stick to unsigned semantics when + * dealing with it. + * + * @return A {@link BigInteger} with this object's value. + */ + public BigInteger toBigInteger() { + return value.toBigInteger(); + } +} \ No newline at end of file diff --git a/src/main/java/org/eclipse/basyx/vab/protocol/opcua/types/UnsignedLong.java b/src/main/java/org/eclipse/basyx/vab/protocol/opcua/types/UnsignedLong.java new file mode 100644 index 00000000..a9dfadd4 --- /dev/null +++ b/src/main/java/org/eclipse/basyx/vab/protocol/opcua/types/UnsignedLong.java @@ -0,0 +1,168 @@ +/******************************************************************************* + * Copyright (C) 2021 Festo Didactic SE + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + ******************************************************************************/ +package org.eclipse.basyx.vab.protocol.opcua.types; + +import java.math.BigInteger; + +import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.ULong; + +/** An unsigned 64-bit integer, matching the UInt64 type from OPC UA. */ +public final class UnsignedLong { + /** The lowest possible value of an unsigned 64-bit integer. */ + public static final BigInteger MIN_VALUE = ULong.MIN_VALUE; + + /** The highest possible value of an unsigned 64-bit integer. */ + public static final BigInteger MAX_VALUE = ULong.MAX_VALUE; + + /** A constant instance holding the lowest possible value of an unsigned 64-bit integer. */ + public static final UnsignedLong MIN = new UnsignedLong(ULong.MIN); + + /** A constant instance holding the highest possible value of an unsigned 64-bit integer. */ + public static final UnsignedLong MAX = new UnsignedLong(ULong.MAX); + + private final ULong value; + + /** + * Creates a new instance from the given long value. + * + * @param value The value. + * + * @throws NumberFormatException if the value is outside the allowed range of {@link #MIN_VALUE} to + * {@link #MAX_VALUE}. + */ + public UnsignedLong(long value) throws NumberFormatException { + this.value = ULong.valueOf(value); + } + + /** + * Creates a new instance from the given {@link BigInteger}. + * + * @param value The value. + * + * @throws NumberFormatException if the value is outside the allowed range of {@link #MIN_VALUE} to + * {@link #MAX_VALUE}. + */ + public UnsignedLong(BigInteger value) throws NumberFormatException { + this.value = ULong.valueOf(value); + } + + /** + * Creates a new instance by parsing the given string. + * + * @param value A decimal string representation of the value. + * + * @throws NumberFormatException if the string doesn't contain a decimal integer or the value is + * outside the allowed range of an unsigned 64 bit integer. + */ + public UnsignedLong(String value) throws NumberFormatException { + this.value = ULong.valueOf(value); + } + + /** + * Creates a new instance with the given internal value. Client code should normally not need to use + * this. + * + * @param value The internal value. + */ + public UnsignedLong(ULong value) { + this.value = value; + } + + /** + * Gets the internal representation of this value. Client code should not normally use this. + * + * @return The internal representation of this value. + */ + public ULong getInternalValue() { + return value; + } + + /** + * Adds another {@link UnsignedLong} to this one. + * + * @param other The value to add. + * + * @return A new instance holding the sum of this one and other. + * + * @throws NumberFormatException if the result is outside the allowed range of {@link #MIN_VALUE} to + * {@link #MAX_VALUE}. + */ + public UnsignedLong add(UnsignedLong other) throws NumberFormatException { + return new UnsignedLong(value.add(other.toLong())); + } + + /** + * Adds a long integer to this {@link UnsignedLong}. + * + * @param other The value to add. + * + * @return A new instance holding the sum of this one and other. + * + * @throws NumberFormatException if the result is outside the allowed range of {@link #MIN_VALUE} to + * {@link #MAX_VALUE}. + */ + public UnsignedLong add(long other) throws NumberFormatException { + return new UnsignedLong(value.add(other)); + } + + /** + * Subtracts another {@link UnsignedLong} from this one. + * + * @param other The value to subtract. + * + * @return A new instance holding the difference between this one and other. + * + * @throws NumberFormatException if the result is outside the allowed range of {@link #MIN_VALUE} to + * {@link #MAX_VALUE}. + */ + public UnsignedLong subtract(UnsignedLong other) throws NumberFormatException { + return new UnsignedLong(value.subtract(other.toLong())); + } + + /** + * Subtracts a long integer from this {@link UnsignedLong}. + * + * @param other The value to subtract. + * + * @return A new instance holding the difference between this one and other. + * + * @throws NumberFormatException if the result is outside the allowed range of {@link #MIN_VALUE} to + * {@link #MAX_VALUE}. + */ + public UnsignedLong subtract(long other) throws NumberFormatException { + return new UnsignedLong(value.subtract(other)); + } + + /** + * Gets a long with this object's value. + * + *

+ * The returned value is signed. Calling code needs to take care to stick to unsigned semantics when + * dealing with it. + * + * @return A long with this object's value. + */ + public long toLong() { + return value.longValue(); + } + + /** + * Gets a {@link BigInteger} with this object's value. + * + *

+ * The returned value is signed. Calling code needs to take care to stick to unsigned semantics when + * dealing with it. + * + * @return A {@link BigInteger} with this object's value. + */ + public BigInteger toBigInteger() { + return value.toBigInteger(); + } +} \ No newline at end of file diff --git a/src/main/java/org/eclipse/basyx/vab/protocol/opcua/types/UnsignedShort.java b/src/main/java/org/eclipse/basyx/vab/protocol/opcua/types/UnsignedShort.java new file mode 100644 index 00000000..24246d51 --- /dev/null +++ b/src/main/java/org/eclipse/basyx/vab/protocol/opcua/types/UnsignedShort.java @@ -0,0 +1,194 @@ +/******************************************************************************* + * Copyright (C) 2021 Festo Didactic SE + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + ******************************************************************************/ +package org.eclipse.basyx.vab.protocol.opcua.types; + +import java.math.BigInteger; + +import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UShort; + +/** An unsigned 16-bit integer, matching the UInt16 type from OPC UA. */ +public final class UnsignedShort { + /** The lowest possible value of an unsigned 16-bit integer. */ + public static final int MIN_VALUE = UShort.MIN_VALUE; + + /** The highest possible value of an unsigned 16-bit integer. */ + public static final int MAX_VALUE = UShort.MAX_VALUE; + + /** A constant instance holding the lowest possible value of an unsigned 16-bit integer. */ + public static final UnsignedShort MIN = new UnsignedShort(UShort.MIN); + + /** A constant instance holding the highest possible value of an unsigned 16-bit integer. */ + public static final UnsignedShort MAX = new UnsignedShort(UShort.MAX); + + private final UShort value; + + /** + * Creates a new instance from the given short integer. + * + * @param value The value. + * + * @throws NumberFormatException if the value is outside the allowed range of {@link #MIN_VALUE} to + * {@link #MAX_VALUE}. + */ + public UnsignedShort(short value) throws NumberFormatException { + this.value = UShort.valueOf(value); + } + + /** + * Creates a new instance from the given integer. + * + * @param value The value. + * + * @throws NumberFormatException if the value is outside the allowed range of {@link #MIN_VALUE} to + * {@link #MAX_VALUE}. + */ + public UnsignedShort(int value) throws NumberFormatException { + this.value = UShort.valueOf(value); + } + + /** + * Creates a new instance by parsing the given string. + * + * @param value A decimal string representation of the value. + * + * @throws NumberFormatException if the string doesn't contain a decimal integer or the value is + * outside the allowed range of an unsigned 64 bit integer. + */ + public UnsignedShort(String value) throws NumberFormatException { + this.value = UShort.valueOf(value); + } + + /** + * Creates a new instance with the given internal value. Client code should normally not need to use + * this. + * + * @param value The internal value. + */ + public UnsignedShort(UShort value) { + this.value = value; + } + + /** + * Gets the internal representation of this value. Client code should not normally use this. + * + * @return The internal representation of this value. + */ + public UShort getInternalValue() { + return value; + } + + /** + * Adds another {@link UnsignedShort} to this one. + * + * @param other The value to add. + * + * @return A new instance holding the sum of this one and other. + * + * @throws NumberFormatException if the result is outside the allowed range of {@link #MIN_VALUE} to + * {@link #MAX_VALUE}. + */ + public UnsignedShort add(UnsignedShort other) throws NumberFormatException { + return new UnsignedShort(value.add(other.toShort())); + } + + /** + * Adds a short integer to this {@link UnsignedShort}. + * + * @param other The value to add. + * + * @return A new instance holding the sum of this one and other. + * + * @throws NumberFormatException if the result is outside the allowed range of {@link #MIN_VALUE} to + * {@link #MAX_VALUE}. + */ + public UnsignedShort add(short other) throws NumberFormatException { + return new UnsignedShort(value.add(other)); + } + + /** + * Subtracts another {@link UnsignedShort} from this one. + * + * @param other The value to subtract. + * + * @return A new instance holding the difference between this one and other. + * + * @throws NumberFormatException if the result is outside the allowed range of {@link #MIN_VALUE} to + * {@link #MAX_VALUE}. + */ + public UnsignedShort subtract(UnsignedShort other) throws NumberFormatException { + return new UnsignedShort(value.subtract(other.toShort())); + } + + /** + * Subtracts a short integer from this {@link UnsignedShort}. + * + * @param other The value to subtract. + * + * @return A new instance holding the difference between this one and other. + * + * @throws NumberFormatException if the result is outside the allowed range of {@link #MIN_VALUE} to + * {@link #MAX_VALUE}. + */ + public UnsignedShort subtract(short other) throws NumberFormatException { + return new UnsignedShort(value.subtract(other)); + } + + /** + * Gets a short integer with this object's value. + * + *

+ * The returned value is signed. Calling code needs to take care to stick to unsigned semantics when + * dealing with it. + * + * @return A short integer with this object's value. + */ + public short toShort() { + return value.shortValue(); + } + + /** + * Gets an integer with this object's value. + * + *

+ * The returned value is signed. Calling code needs to take care to stick to unsigned semantics when + * dealing with it. + * + * @return An integer with this object's value. + */ + public int toInt() { + return value.intValue(); + } + + /** + * Gets a long with this object's value. + * + *

+ * The returned value is signed. Calling code needs to take care to stick to unsigned semantics when + * dealing with it. + * + * @return A long with this object's value. + */ + public long toLong() { + return value.longValue(); + } + + /** + * Gets a {@link BigInteger} with this object's value. + * + *

+ * The returned value is signed. Calling code needs to take care to stick to unsigned semantics when + * dealing with it. + * + * @return A {@link BigInteger} with this object's value. + */ + public BigInteger toBigInteger() { + return value.toBigInteger(); + } +} \ No newline at end of file diff --git a/src/main/java/org/eclipse/basyx/vab/support/TypeDestroyingProvider.java b/src/main/java/org/eclipse/basyx/vab/support/TypeDestroyingProvider.java index 90ba2bf0..aacab264 100644 --- a/src/main/java/org/eclipse/basyx/vab/support/TypeDestroyingProvider.java +++ b/src/main/java/org/eclipse/basyx/vab/support/TypeDestroyingProvider.java @@ -13,7 +13,7 @@ import org.eclipse.basyx.vab.modelprovider.api.IModelProvider; /** - * Provider used for removing type information from Objects.
+ * Provider used for removing type information from Objects.
* Similar to a communication over VAB. * * @author conradi diff --git a/src/test/java/org/eclipse/basyx/testsuite/regression/aas/aggregator/TestAASAggregatorProxy.java b/src/test/java/org/eclipse/basyx/testsuite/regression/aas/aggregator/TestAASAggregatorProxy.java index 00a6fc58..4c4b45c4 100644 --- a/src/test/java/org/eclipse/basyx/testsuite/regression/aas/aggregator/TestAASAggregatorProxy.java +++ b/src/test/java/org/eclipse/basyx/testsuite/regression/aas/aggregator/TestAASAggregatorProxy.java @@ -38,7 +38,7 @@ public class TestAASAggregatorProxy extends AASAggregatorSuite { @Override protected IAASAggregator getAggregator() { - return new AASAggregatorProxy(new VABElementProxy("/shells", new AASAggregatorProvider(new AASAggregator()))); + return new AASAggregatorProxy(new VABElementProxy("", new AASAggregatorProvider(new AASAggregator()))); } /** @@ -83,7 +83,7 @@ public void testFeedThrough() throws Exception { // Test feedthrough of INVOKE // Use short form of invoke with operation variable matching and no parameters (empty object array) - assertTrue((boolean) ((IOperation) sm.getSubmodelElement(op.getIdShort())).invoke(new Object[0])); + assertTrue((boolean) ((IOperation) sm.getSubmodelElement(op.getIdShort())).invokeSimple(new Object[0])); // Test feedthrough of DELETE retrievedAAS.removeSubmodel(sm.getIdentification()); diff --git a/src/test/java/org/eclipse/basyx/testsuite/regression/aas/aggregator/observing/ObservableAASAggregatorTest.java b/src/test/java/org/eclipse/basyx/testsuite/regression/aas/aggregator/observing/ObservableAASAggregatorTest.java new file mode 100644 index 00000000..86b5a647 --- /dev/null +++ b/src/test/java/org/eclipse/basyx/testsuite/regression/aas/aggregator/observing/ObservableAASAggregatorTest.java @@ -0,0 +1,120 @@ +/******************************************************************************* +* Copyright (C) 2021 the Eclipse BaSyx Authors +* +* This program and the accompanying materials are made +* available under the terms of the Eclipse Public License 2.0 +* which is available at https://www.eclipse.org/legal/epl-2.0/ + +* +* SPDX-License-Identifier: EPL-2.0 +******************************************************************************/ + +package org.eclipse.basyx.testsuite.regression.aas.aggregator.observing; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.eclipse.basyx.aas.aggregator.AASAggregator; +import org.eclipse.basyx.aas.aggregator.api.IAASAggregator; +import org.eclipse.basyx.aas.aggregator.observing.IAASAggregatorObserver; +import org.eclipse.basyx.aas.aggregator.observing.ObservableAASAggregator; +import org.eclipse.basyx.aas.metamodel.api.parts.asset.AssetKind; +import org.eclipse.basyx.aas.metamodel.map.AssetAdministrationShell; +import org.eclipse.basyx.aas.metamodel.map.parts.Asset; +import org.eclipse.basyx.submodel.metamodel.api.identifier.IdentifierType; +import org.eclipse.basyx.submodel.metamodel.map.identifier.Identifier; +import org.junit.Before; +import org.junit.Test; + + +public class ObservableAASAggregatorTest { + protected AssetAdministrationShell shell; + private static final String AASID = "aasid1"; + private static final Identifier AASIDENTIFIER = new Identifier(IdentifierType.IRI, AASID); + + private ObservableAASAggregator observerdAASAggregator; + private MockObserver observer; + + @Before + public void setup() { + IAASAggregator aggregator = new AASAggregator(); + shell = new AssetAdministrationShell(AASID, AASIDENTIFIER, new Asset(AASID, AASIDENTIFIER, AssetKind.INSTANCE)); + aggregator.createAAS(shell); + + observerdAASAggregator = new ObservableAASAggregator(aggregator); + + // Create an Observer + observer = new MockObserver(); + + // Register the observer at the API + observerdAASAggregator.addObserver(observer); + } + + @Test + public void testCreateAAS() { + String aasId2 = "aas2"; + Identifier identifier2 = new Identifier(IdentifierType.IRDI, aasId2); + AssetAdministrationShell shell2 = new AssetAdministrationShell(aasId2, identifier2, new Asset("assetid2", new Identifier(IdentifierType.IRI, "assetid2"), AssetKind.INSTANCE)); + observerdAASAggregator.createAAS(shell2); + + assertEquals(aasId2, observer.aasId); + assertTrue(observer.createdNotified); + } + + @Test + public void testUpdateAAS() { + shell.setCategory("newCategory"); + observerdAASAggregator.updateAAS(shell); + + assertTrue(observer.updatedNotified); + assertEquals(AASID, observer.aasId); + } + + @Test + public void testDeleteAAS() { + observerdAASAggregator.deleteAAS(AASIDENTIFIER); + assertTrue(observer.deletedNotified); + assertEquals(AASID, observer.aasId); + } + + @Test + public void testRemoveObserver() { + assertTrue(observerdAASAggregator.removeObserver(observer)); + observerdAASAggregator.deleteAAS(AASIDENTIFIER); + assertFalse(observer.deletedNotified); + } + + private class MockObserver implements IAASAggregatorObserver { + + public boolean createdNotified = false; + public boolean deletedNotified = false; + public boolean updatedNotified = false; + + public String aasId = ""; + + @Override + public void aasCreated(String aasId) { + createdNotified = true; + deletedNotified = false; + updatedNotified = false; + this.aasId = aasId; + } + + @Override + public void aasUpdated(String aasId) { + createdNotified = false; + deletedNotified = false; + updatedNotified = true; + this.aasId = aasId; + } + + @Override + public void aasDeleted(String aasId) { + createdNotified = false; + deletedNotified = true; + updatedNotified = false; + this.aasId = aasId; + } + } +} diff --git a/src/test/java/org/eclipse/basyx/testsuite/regression/aas/bundle/TestAASBundleDescriptorFactory.java b/src/test/java/org/eclipse/basyx/testsuite/regression/aas/bundle/TestAASBundleDescriptorFactory.java new file mode 100644 index 00000000..c0163960 --- /dev/null +++ b/src/test/java/org/eclipse/basyx/testsuite/regression/aas/bundle/TestAASBundleDescriptorFactory.java @@ -0,0 +1,56 @@ +/******************************************************************************* + * Copyright (C) 2021 the Eclipse BaSyx Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + ******************************************************************************/ +package org.eclipse.basyx.testsuite.regression.aas.bundle; + +import static org.junit.Assert.assertEquals; + +import java.util.Collections; + +import org.eclipse.basyx.aas.bundle.AASBundle; +import org.eclipse.basyx.aas.bundle.AASBundleDescriptorFactory; +import org.eclipse.basyx.aas.metamodel.map.AssetAdministrationShell; +import org.eclipse.basyx.aas.metamodel.map.descriptor.AASDescriptor; +import org.eclipse.basyx.submodel.metamodel.api.identifier.IdentifierType; +import org.eclipse.basyx.submodel.metamodel.map.Submodel; +import org.eclipse.basyx.submodel.metamodel.map.identifier.Identifier; +import org.eclipse.basyx.vab.modelprovider.VABPathTools; +import org.junit.Test; + +/** + * Tests the methods of AASBundleDescriptorFactory for their correctness + * + * @author schnicke + * + */ +public class TestAASBundleDescriptorFactory { + @Test + public void testDescriptorCreation() { + String aasId = "aasId"; + AssetAdministrationShell shell = new AssetAdministrationShell(); + shell.setIdentification(new Identifier(IdentifierType.CUSTOM, aasId)); + + String smId = "smId"; + Submodel sm = new Submodel(); + sm.setIdShort(smId); + sm.setIdentification(IdentifierType.IRI, "aasIdIRI"); + + AASBundle bundle = new AASBundle(shell, Collections.singleton(sm)); + + String basePath = "http://localhost:4040/test"; + AASDescriptor desc = AASBundleDescriptorFactory.createAASDescriptor(bundle, basePath); + + String aasPath = VABPathTools.concatenatePaths(basePath, aasId, "aas"); + String smPath = VABPathTools.concatenatePaths(aasPath, "submodels", sm.getIdShort(), "submodel"); + assertEquals(aasPath, desc.getFirstEndpoint()); + assertEquals(smPath, desc.getSubmodelDescriptorFromIdShort(smId).getFirstEndpoint()); + + } + +} diff --git a/src/test/java/org/eclipse/basyx/testsuite/regression/aas/bundle/TestAASBundleFactory.java b/src/test/java/org/eclipse/basyx/testsuite/regression/aas/bundle/TestAASBundleFactory.java new file mode 100644 index 00000000..22ad890b --- /dev/null +++ b/src/test/java/org/eclipse/basyx/testsuite/regression/aas/bundle/TestAASBundleFactory.java @@ -0,0 +1,39 @@ +/******************************************************************************* + * Copyright (C) 2021 the Eclipse BaSyx Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + ******************************************************************************/ + + +package org.eclipse.basyx.testsuite.regression.aas.bundle; + +import java.util.Collections; + +import org.eclipse.basyx.aas.bundle.AASBundleFactory; +import org.eclipse.basyx.aas.metamodel.api.parts.asset.AssetKind; +import org.eclipse.basyx.aas.metamodel.map.AssetAdministrationShell; +import org.eclipse.basyx.aas.metamodel.map.descriptor.CustomId; +import org.eclipse.basyx.aas.metamodel.map.parts.Asset; +import org.junit.Test; + +/** + * Tests AASBundleFactory + * + * @author schnicke + * + */ +public class TestAASBundleFactory { + + @Test + public void testBundleCreationAssetAlreadySetInAAS() { + Asset asset = new Asset("assetIdShort", new CustomId("assetId"), AssetKind.INSTANCE); + + AssetAdministrationShell shell = new AssetAdministrationShell("aasIdShort", new CustomId("aasId"), asset); + new AASBundleFactory().create(Collections.singleton(shell), Collections.emptySet(), + Collections.singleton(asset)); + } +} diff --git a/src/test/java/org/eclipse/basyx/testsuite/regression/aas/bundle/TestAASBundleHelper.java b/src/test/java/org/eclipse/basyx/testsuite/regression/aas/bundle/TestAASBundleHelper.java new file mode 100644 index 00000000..78ade3e6 --- /dev/null +++ b/src/test/java/org/eclipse/basyx/testsuite/regression/aas/bundle/TestAASBundleHelper.java @@ -0,0 +1,174 @@ +/******************************************************************************* + * Copyright (C) 2021 the Eclipse BaSyx Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + ******************************************************************************/ +package org.eclipse.basyx.testsuite.regression.aas.bundle; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.eclipse.basyx.aas.aggregator.AASAggregator; +import org.eclipse.basyx.aas.aggregator.proxy.AASAggregatorProxy; +import org.eclipse.basyx.aas.aggregator.restapi.AASAggregatorProvider; +import org.eclipse.basyx.aas.bundle.AASBundle; +import org.eclipse.basyx.aas.bundle.AASBundleHelper; +import org.eclipse.basyx.aas.metamodel.api.IAssetAdministrationShell; +import org.eclipse.basyx.aas.metamodel.map.AssetAdministrationShell; +import org.eclipse.basyx.aas.registration.api.IAASRegistry; +import org.eclipse.basyx.aas.registration.memory.InMemoryRegistry; +import org.eclipse.basyx.submodel.metamodel.api.ISubmodel; +import org.eclipse.basyx.submodel.metamodel.api.identifier.IIdentifier; +import org.eclipse.basyx.submodel.metamodel.api.identifier.IdentifierType; +import org.eclipse.basyx.submodel.metamodel.facade.SubmodelElementMapCollectionConverter; +import org.eclipse.basyx.submodel.metamodel.map.Submodel; +import org.eclipse.basyx.submodel.metamodel.map.identifier.Identifier; +import org.eclipse.basyx.submodel.restapi.SubmodelProvider; +import org.eclipse.basyx.vab.modelprovider.VABElementProxy; +import org.eclipse.basyx.vab.modelprovider.api.IModelProvider; +import org.junit.Before; +import org.junit.Test; + +/** + * Test for the AASBundelIntegrator + * + * @author conradi + * + */ +public class TestAASBundleHelper { + + private static final String AAS_ID = "TestAAS"; + private static final String SM_ID = "TestSM"; + + + private AASAggregatorProxy aggregator; + private List bundles; + private AASAggregatorProvider provider; + + + + @Before + public void init() { + provider = new AASAggregatorProvider(new AASAggregator()); + aggregator = new AASAggregatorProxy(new VABElementProxy("", provider)); + bundles = new ArrayList<>(); + } + + /** + * This test loads an AAS and its two Submodels into the Aggregator, + * runs the integration with AAS and Submodels with the same IDs, but different content, + * checks if integration does NOT replace the models in the Aggregator. + */ + @Test + public void testIntegrationOfExistingAASAndSM() { + AASBundle bundle = getTestBundle(); + bundles.add(bundle); + + // Load AAS and SM AASAggregator + AssetAdministrationShell aas = (AssetAdministrationShell) bundle.getAAS(); + Set submodels = bundle.getSubmodels(); + Submodel sm = (Submodel) submodels.iterator().next(); + pushAAS(aas); + pushSubmodel(sm, aas.getIdentification()); + + assertFalse(AASBundleHelper.integrate(aggregator, bundles)); + checkAggregatorContent(); + } + + /** + * This test loads an AAS into the Aggregator, + * runs the integration with the AAS and a SM, + * checks if both is present in Aggregator afterwards. + */ + @Test + public void testIntegrationOfExistingAASAndNonexistingSM() { + AASBundle bundle = getTestBundle(); + bundles.add(bundle); + + // Load only AAS into AASAggregator + AssetAdministrationShell aas = (AssetAdministrationShell) bundle.getAAS(); + pushAAS(aas); + + assertTrue(AASBundleHelper.integrate(aggregator, bundles)); + checkAggregatorContent(); + } + + /** + * This test loads nothing into the Aggregator, + * runs the integration with the AAS and a SM, + * checks if both is present in Aggregator afterwards. + */ + @Test + public void testIntegrationOfNonexistingAASAndSM() { + AASBundle bundle = getTestBundle(); + bundles.add(bundle); + + assertTrue(AASBundleHelper.integrate(aggregator, bundles)); + checkAggregatorContent(); + } + + /** + * This test loads nothing into the Aggregator, + * runs the integration with the AAS and a SM, + * checks if both is present in Aggregator afterwards. Furthermore, + * the AASAggregator has a registry for registering and resolving potential + * submodels. + */ + @Test + public void testIntegrationOfNonexistingAASAndSMWithRegistry() { + IAASRegistry registry = new InMemoryRegistry(); + provider = new AASAggregatorProvider(new AASAggregator(registry)); + aggregator = new AASAggregatorProxy(new VABElementProxy("", provider)); + + AASBundle bundle = getTestBundle(); + bundles.add(bundle); + + assertTrue(AASBundleHelper.integrate(aggregator, bundles)); + checkAggregatorContent(); + } + + @SuppressWarnings("unchecked") + private void checkAggregatorContent() { + IAssetAdministrationShell aas = aggregator.getAAS(new Identifier(IdentifierType.CUSTOM, AAS_ID)); + assertEquals(AAS_ID, aas.getIdShort()); + IModelProvider provider = aggregator.getAASProvider(new Identifier(IdentifierType.CUSTOM, AAS_ID)); + + Submodel sm = SubmodelElementMapCollectionConverter.mapToSM( + (Map) provider.getValue("/aas/submodels/" + SM_ID + "/" + SubmodelProvider.SUBMODEL)); + + assertEquals(SM_ID, sm.getIdentification().getId()); + } + + private void pushAAS(AssetAdministrationShell aas) { + aggregator.createAAS(aas); + } + + private void pushSubmodel(Submodel sm, IIdentifier aasIdentifier) { + provider.setValue("/" + AASAggregatorProvider.PREFIX + "/" + aasIdentifier.getId() + "/aas/submodels/" + sm.getIdShort(), sm); + } + + private AASBundle getTestBundle() { + Submodel sm = new Submodel(); + sm.setIdShort(SM_ID); + sm.setIdentification(IdentifierType.CUSTOM, SM_ID); + + AssetAdministrationShell aas = new AssetAdministrationShell(); + aas.setIdentification(IdentifierType.CUSTOM, AAS_ID); + aas.setIdShort(AAS_ID); + aas.addSubmodel(sm); + + return new AASBundle(aas, new HashSet<>(Arrays.asList(sm))); + } +} diff --git a/src/test/java/org/eclipse/basyx/testsuite/regression/aas/factory/aasx/TestAASXToMetamodelConverterFromBaSyx.java b/src/test/java/org/eclipse/basyx/testsuite/regression/aas/factory/aasx/TestAASXToMetamodelConverterFromBaSyx.java new file mode 100644 index 00000000..67c5c26d --- /dev/null +++ b/src/test/java/org/eclipse/basyx/testsuite/regression/aas/factory/aasx/TestAASXToMetamodelConverterFromBaSyx.java @@ -0,0 +1,401 @@ +/******************************************************************************* + * Copyright (C) 2021 the Eclipse BaSyx Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + ******************************************************************************/ + +package org.eclipse.basyx.testsuite.regression.aas.factory.aasx; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.transform.TransformerException; + +import org.apache.commons.io.FileUtils; +import org.apache.poi.openxml4j.exceptions.InvalidFormatException; +import org.eclipse.basyx.aas.bundle.AASBundle; +import org.eclipse.basyx.aas.factory.aasx.AASXToMetamodelConverter; +import org.eclipse.basyx.aas.factory.aasx.InMemoryFile; +import org.eclipse.basyx.aas.factory.aasx.MetamodelToAASXConverter; +import org.eclipse.basyx.aas.metamodel.api.IAssetAdministrationShell; +import org.eclipse.basyx.aas.metamodel.api.parts.asset.AssetKind; +import org.eclipse.basyx.aas.metamodel.api.parts.asset.IAsset; +import org.eclipse.basyx.aas.metamodel.map.AssetAdministrationShell; +import org.eclipse.basyx.aas.metamodel.map.descriptor.ModelUrn; +import org.eclipse.basyx.aas.metamodel.map.parts.Asset; +import org.eclipse.basyx.submodel.metamodel.api.ISubmodel; +import org.eclipse.basyx.submodel.metamodel.api.parts.IConceptDescription; +import org.eclipse.basyx.submodel.metamodel.map.Submodel; +import org.eclipse.basyx.submodel.metamodel.map.reference.Reference; +import org.eclipse.basyx.submodel.metamodel.map.submodelelement.SubmodelElementCollection; +import org.eclipse.basyx.submodel.metamodel.map.submodelelement.dataelement.File; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.Test; +import org.xml.sax.SAXException; + +/** + * J-Unit tests AASX-Files created by the BaSyx middleware itself. + * + * @author zhangzai, conradi, fischer + * + */ +public class TestAASXToMetamodelConverterFromBaSyx { + private static final String CREATED_AASX_FILE_PATH = "test.aasx"; + + private static final String AAS_IDSHORT = "assIdShort"; + private static final String AAS_IDENTIFICATION = "assIdentification"; + private static final String ASSET_IDSHORT = "assetIdShort"; + private static final String ASSET_IDENTIFICATION = "assetIdentification"; + private static final String SUBMODEL_IDSHORT = "submodelIdShort"; + private static final String SUBMODEL_IDENTIFICATION = "submodelIdentification"; + private static final String SUBMODEL_COLLECTION_IDSHORT = "submodelCollectionIdShort"; + + private static final String IMAGE_PATH = "/icon.png"; + private static final String IMAGE_MIMETYPE = "image/png"; + private static final String IMAGE_IDSHORT = "image"; + private static final String PDF_PATH = "/aasx/Document/docu.pdf"; + private static final String PDF_MIMETYPE = "application/pdf"; + private static final String PDF_IDSHORT = "pdf"; + private static final String TARGET_PATH = "target/files"; // gets set by BaSyx + private static final String[] EXPECTED_UNZIPPED_FILES = { TARGET_PATH + PDF_PATH, TARGET_PATH + IMAGE_PATH }; + + private static final String REL_PATH = "_rels/.rels"; + private static final String ORIGIN_REL_PATH = "aasx/_rels/aasx-origin.rels"; + + private static final String ID_REGEX_START = "Id=.*"; + // only letters or underscore as start of id allowed + // https://www.w3.org/TR/1999/REC-xml-names-19990114/#ns-qualnames + private static final String ID_REGEX_FULL = "Id=\"([A-z]|_).*"; + + private static final String TARGET_PATH_REGEX_START = "Target=.*"; + private static final String TARGET_PATH_REGEX_FULL = "Target=\"/.*"; + private static final String DOCPROPS_PATH_REGEX = "Target=\"docProps.*"; + + private int bundleSize; + private int submodelSize; + private int submodelElementsSize; + + private AASXToMetamodelConverter packageManager; + + @Before + public void setup() throws IOException, TransformerException, ParserConfigurationException { + createAASXFile(CREATED_AASX_FILE_PATH); + + packageManager = new AASXToMetamodelConverter(CREATED_AASX_FILE_PATH); + } + + /** + * Tests the AAS and its submodels of the parsed AASX file + * + * @throws SAXException + * @throws ParserConfigurationException + * @throws IOException + * @throws InvalidFormatException + */ + @Test + public void testLoadGeneratedAASX() throws InvalidFormatException, IOException, ParserConfigurationException, SAXException { + Set bundles = packageManager.retrieveAASBundles(); + + assertEquals(bundleSize, bundles.size()); + AASBundle bundle = bundles.stream().findFirst().get(); + + IAssetAdministrationShell parsedAAS = bundle.getAAS(); + assertEquals(AAS_IDSHORT, parsedAAS.getIdShort()); + assertEquals(AAS_IDENTIFICATION, parsedAAS.getIdentification().getId()); + + assertEquals(submodelSize, bundle.getSubmodels().size()); + + ISubmodel parsedSubmodel = bundle.getSubmodels().stream().findFirst().get(); + assertEquals(SUBMODEL_IDSHORT, parsedSubmodel.getIdShort()); + assertEquals(SUBMODEL_IDENTIFICATION, parsedSubmodel.getIdentification().getId()); + assertEquals(submodelElementsSize, parsedSubmodel.getSubmodelElements().size()); + } + + /** + * Tests the connected files of the parsed AASX file. + * + * @throws InvalidFormatException + * @throws IOException + * @throws ParserConfigurationException + * @throws SAXException + * @throws URISyntaxException + */ + @Test + public void testFilesOfGeneratedAASX() throws InvalidFormatException, IOException, ParserConfigurationException, SAXException, URISyntaxException { + // Unzip files from the .aasx + packageManager.unzipRelatedFiles(); + + // Check if all expected files are present + for (String path : EXPECTED_UNZIPPED_FILES) { + assertTrue(new java.io.File(path).exists()); + } + } + + /** + * Tests if the ids start with a letter or underscore and if the paths are + * absolute. + * + * @throws IOException + * @throws ParserConfigurationException + * @throws SAXException + */ + @Test + public void testRelationshipsOfGeneratedAASX() throws IOException { + ZipInputStream in = unzipFromPath(CREATED_AASX_FILE_PATH); + + String relString = getFileContentFromZipInputStream(REL_PATH, in); + String originRelString = getFileContentFromZipInputStream(ORIGIN_REL_PATH, in); + + List relStringIds = findIdCandidates(relString); + List originRelStringIds = findIdCandidates(originRelString); + + assertIdsBeginWithLetterOrUnderscore(relStringIds); + assertIdsBeginWithLetterOrUnderscore(originRelStringIds); + + // this step is necessary for compatibility reasons with AASXPackageExplorer + List relStringPaths = findPathCandidates(relString); + List originRelStringPaths = findPathCandidates(originRelString); + + assertPathsAreAbsolute(relStringPaths); + assertPathsAreAbsolute(originRelStringPaths); + } + + /** + * Check if elements of the given path list adhere to the target path regEx. + * This step is necessary for compatibility reasons with AASXPackageExplorer. + * + * @param pathStringList + */ + private void assertPathsAreAbsolute(List pathStringList) { + assertStringListRegExCheck(pathStringList, TARGET_PATH_REGEX_FULL); + } + + /** + * Split the given string at every whitespace and returns all elements that + * start with "Target=". This step is necessary for compatibility reasons with + * AASXPackageExplorer. + * + * @param stringToCheck + * @return List + */ + private List findPathCandidates(String stringToCheck) { + List potentialPaths = findRegExParts(stringToCheck, TARGET_PATH_REGEX_START); + + // remove docProps path, because docProps is not relevant for + // AASXPackageExplorer + return removeDocPropsPath(potentialPaths); + } + + /** + * Remove every target path that matches with the docProps path, because + * docProps is not relevant for AASXPackageExplorer. + * + * @param givenPathList + * @return List + */ + private List removeDocPropsPath(List givenPathList) { + for (int i = 0; i < givenPathList.size(); i++) { + if (givenPathList.get(i).matches(DOCPROPS_PATH_REGEX)) { + givenPathList.remove(i); + } + } + + return givenPathList; + } + + /** + * Check if elements of the given id list adhere to the id regEx. + * + * @param idStringList + */ + private void assertIdsBeginWithLetterOrUnderscore(List idStringList) { + assertStringListRegExCheck(idStringList, ID_REGEX_FULL); + } + + /** + * Split the given string at every whitespace and returns all elements that + * start with "Id=" + * + * @param stringToCheck + * @return List + */ + private List findIdCandidates(String stringToCheck) { + return findRegExParts(stringToCheck, ID_REGEX_START); + } + + /** + * Check if elements of the given list adhere to the given regEx. + * + * @param stringToCheck + * @param regEx + */ + private void assertStringListRegExCheck(List stringToCheck, String regEx) { + for (String part : stringToCheck) { + assertTrue(part.matches(regEx)); + } + } + + /** + * Split the given string at every whitespace and returns a list of all + * remaining elements that adhere to the given regEx. + * + * @param stringToCheck + * @param regEx + * @return List + */ + private List findRegExParts(String stringToCheck, String regEx) { + String[] stringParts = stringToCheck.split(" "); + List regExParts = new ArrayList(); + + for (String part : stringParts) { + if (part.matches(regEx)) { + regExParts.add(part); + } + } + + return regExParts; + } + + /** + * Get the content of a file with a given path from a ZipInputStream as String + * + * @param filePath + * @param in + * @return String of the file contents + * @throws IOException + */ + private String getFileContentFromZipInputStream(String filePath, ZipInputStream in) throws IOException { + ZipEntry zipEntry = null; + String contentString = ""; + + while ((zipEntry = in.getNextEntry()) != null) { + if (zipEntry.getName().equals(filePath)) { + byte[] buf = new byte[1]; + + while (in.read(buf) > 0) { + contentString += new String(buf); + } + } + } + return contentString; + } + + /** + * Unzip a file from path using a ByteArrayInputStream + * + * @param path + * @return ZipInputStream + * @throws IOException + */ + private ZipInputStream unzipFromPath(String path) throws IOException { + return new ZipInputStream(new ByteArrayInputStream(FileUtils.readFileToByteArray(new java.io.File(path)))); + } + + /** + * Create an AASX file with default values. + * + * @param filePath + * @throws IOException + * @throws TransformerException + * @throws ParserConfigurationException + */ + private void createAASXFile(String filePath) throws IOException, TransformerException, ParserConfigurationException { + List aasList = new ArrayList<>(); + List submodelList = new ArrayList<>(); + List assetList = new ArrayList<>(); + List conceptDescriptionList = new ArrayList<>(); + + List fileList = new ArrayList<>(); + + Asset asset = new Asset(ASSET_IDSHORT, new ModelUrn(ASSET_IDENTIFICATION), AssetKind.INSTANCE); + AssetAdministrationShell aas = new AssetAdministrationShell(AAS_IDSHORT, new ModelUrn(AAS_IDENTIFICATION), asset); + aas.setAssetReference((Reference) asset.getReference()); + + Submodel sm = new Submodel(SUBMODEL_IDSHORT, new ModelUrn(SUBMODEL_IDENTIFICATION)); + + // Create SubmodelElements + SubmodelElementCollection collection = new SubmodelElementCollection(SUBMODEL_COLLECTION_IDSHORT); + collection.addSubmodelElement(createBaSyxFile(IMAGE_PATH, IMAGE_MIMETYPE, IMAGE_IDSHORT)); + + sm.addSubmodelElement(collection); + sm.addSubmodelElement(createBaSyxFile(PDF_PATH, PDF_MIMETYPE, PDF_IDSHORT)); + aas.addSubmodel(sm); + + // Add all AASs to the list that will be converted and set the size for the test + // comparison + aasList.add(aas); + bundleSize = 1; + + // Add all Submodels to the list that will be converted and set the size for the + // test comparison + submodelList.add(sm); + submodelSize = 1; + + assetList.add(asset); + + // Build InMemoryFiles, add them to the list that will be converted and set the + // size for the test comparison + fileList.add(createInMemoryFile(IMAGE_PATH)); + fileList.add(createInMemoryFile(PDF_PATH)); + submodelElementsSize = 2; + + try (FileOutputStream out = new FileOutputStream(filePath)) { + MetamodelToAASXConverter.buildAASX(aasList, assetList, conceptDescriptionList, submodelList, fileList, out); + } + } + + /** + * Delete created files + */ + @AfterClass + public static void cleanUp() { + for (String path : EXPECTED_UNZIPPED_FILES) { + new java.io.File(path).delete(); + } + new java.io.File(CREATED_AASX_FILE_PATH).delete(); + } + + /** + * Create a BaSyx File for given path, mimeType and idShort. + * + * @param filePath + * @param fileMimeType + * @param fileIdShort + * @return BaSyx File + */ + private File createBaSyxFile(String filePath, String fileMimeType, String fileIdShort) { + File file = new File(filePath, fileMimeType); + file.setIdShort(fileIdShort); + + return file; + } + + /** + * Create an inMemoryFile with default content for a given path. + * + * @param filePath + * @return InMemoryFile + */ + private InMemoryFile createInMemoryFile(String filePath) { + byte[] content = { 1, 2, 3, 4, 5 }; + InMemoryFile file = new InMemoryFile(content, filePath); + + return file; + } +} diff --git a/src/test/java/org/eclipse/basyx/testsuite/regression/aas/factory/aasx/TestAASXToMetamodelConverterFromFile.java b/src/test/java/org/eclipse/basyx/testsuite/regression/aas/factory/aasx/TestAASXToMetamodelConverterFromFile.java new file mode 100644 index 00000000..23edfe83 --- /dev/null +++ b/src/test/java/org/eclipse/basyx/testsuite/regression/aas/factory/aasx/TestAASXToMetamodelConverterFromFile.java @@ -0,0 +1,353 @@ +/******************************************************************************* + * Copyright (C) 2021 the Eclipse BaSyx Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + ******************************************************************************/ + +package org.eclipse.basyx.testsuite.regression.aas.factory.aasx; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import javax.xml.parsers.ParserConfigurationException; + +import org.apache.poi.openxml4j.exceptions.InvalidFormatException; +import org.eclipse.basyx.aas.bundle.AASBundle; +import org.eclipse.basyx.aas.factory.aasx.AASXToMetamodelConverter; +import org.eclipse.basyx.aas.metamodel.api.IAssetAdministrationShell; +import org.eclipse.basyx.submodel.metamodel.api.ISubmodel; +import org.eclipse.basyx.submodel.metamodel.api.identifier.IdentifierType; +import org.eclipse.basyx.submodel.metamodel.api.reference.IKey; +import org.eclipse.basyx.submodel.metamodel.api.reference.IReference; +import org.eclipse.basyx.submodel.metamodel.api.submodelelement.ISubmodelElement; +import org.eclipse.basyx.submodel.metamodel.map.submodelelement.SubmodelElementCollection; +import org.eclipse.basyx.submodel.metamodel.map.submodelelement.dataelement.property.Property; +import org.eclipse.basyx.submodel.metamodel.map.submodelelement.dataelement.property.valuetype.ValueType; +import org.junit.BeforeClass; +import org.junit.Test; +import org.xml.sax.SAXException; + +/** + * J-Unit tests for AASXPackageExplorer. This test checks the parsing of aas, + * submodels, assets and concept-descriptions. It also checks whether the AAS + * has correct references to the assets and submodels. + * + * @author zhangzai, conradi, fischer + * + */ +public class TestAASXToMetamodelConverterFromFile { + /** + * Define constants and variables for test + */ + private static final String GIVEN_AASX_FILE_PATH = "src/test/resources/aas/factory/aasx/01_Festo.aasx"; + + private static final int EXPECTED_AASBUNDLE_SIZE = 2; + private static final String EXPECTED_AAS_IDSHORT = "Festo_3S7PM0CP4BD"; + private static final String EXPECTED_AAS_CATEGORY = "CONSTANT"; + private static final String EXPECTED_AAS_IDENTIFICATION_ID = "smart.festo.com/demo/aas/1/1/454576463545648365874"; + private static final IdentifierType EXPECTED_AAS_IDENTIFICATION_ID_TYPE = IdentifierType.IRI; + + private static final int EXPECTED_SUBMODEL_REFERENCE_SIZE = 5; + private static final String EXPECTED_SUBMODEL_REFERENCE_ID_TYPE = "IRI"; + private static final String EXPECTED_SUBMODEL_REFERENCE_MODEL_TYPE = "SUBMODEL"; + private static final boolean EXPECTED_SUBMODEL_REFERENCE_LOCAL = true; + private static final String[] EXPECTED_SUBMODEL_REFERENCE_IDS = { "www.company.com/ids/sm/6053_5072_7091_5102", "smart.festo.com/demo/sm/instance/1/1/13B7CCD9BF7A3F24", "www.company.com/ids/sm/4343_5072_7091_3242", + "www.company.com/ids/sm/2543_5072_7091_2660", "www.company.com/ids/sm/6563_5072_7091_4267" }; + + private static final int EXPECTED_SUBMODEL_SIZE = 5; + private static final String EXPECTED_SUBMODEL_IDSHORT = "Nameplate"; + private static final String EXPECTED_SUBMODEL_IDENTIFICATION_ID = "www.company.com/ids/sm/4343_5072_7091_3242"; + private static final IdentifierType EXPECTED_SUBMODEL_IDENTIFICATION_ID_TYPE = IdentifierType.IRI; + private static final String EXPECTED_SUBMODEL_MODELING_KIND = "Instance"; + + private static final String EXPECTED_SUBMODELELEMENT_CATEGORY = "PARAMETER"; + private static final String EXPECTED_SUBMODELELEMENT_MODELING_KIND = "Instance"; + private static final ValueType EXPECTED_SUBMODELELEMENT_PROPERTY_VALUE_TYPE = ValueType.String; + private static final String EXPECTED_SUBMODELELEMENT_SEMANTIC_KEY_TYPE = "ConceptDescription"; + private static final boolean EXPECTED_SUBMODELELEMENT_SEMANTIC_KEY_LOCAL = true; + + private static final String EXPECTED_MANUFACTURER_NAME_SUBMODELELEMENT_IDSHORT = "ManufacturerName"; + private static final String EXPECTED_MANUFACTURER_NAME_SUBMODELELEMENT_PROPERTY_VALUE = "Festo AG & Co. KG"; + private static final String EXPECTED_MANUFACTURER_NAME_SUBMODELELEMENT_SEMANTIC_KEY_ID_TYPE = "IRDI"; + private static final String EXPECTED_MANUFACTURER_NAME_SUBMODELELEMENT_SEMANTIC_KEY_VALUE = "0173-1#02-AAO677#002"; + + private static final String EXPECTED_MANUFACTURER_PRODUCT_DESIGNATION_SUBMODELELEMENT_IDSHORT = "ManufacturerProductDesignation"; + private static final String EXPECTED_MANUFACTURER_PRODUCT_DESIGNATION_SUBMODELELEMENT_PROPERTY_VALUE = "OVEL Vacuum generator"; + private static final String EXPECTED_MANUFACTURER_PRODUCT_DESIGNATION_SUBMODELELEMENT_SEMANTIC_KEY_ID_TYPE = "IRDI"; + private static final String EXPECTED_MANUFACTURER_PRODUCT_DESIGNATION_SUBMODELELEMENT_SEMANTIC_KEY_VALUE = "0173-1#02-AAW338#001"; + + private static final String EXPECTED_PHYSICAL_ADDRESS_SUBMODELELEMENT_IDSHORT = "PhysicalAddress"; + private static final String EXPECTED_PHYSICAL_ADDRESS_SUBMODELELEMENT_SEMANTIC_KEY_ID_TYPE = "IRI"; + private static final String EXPECTED_PHYSICAL_ADDRESS_SUBMODELELEMENT_SEMANTIC_KEY_VALUE = "https://www.hsu-hh.de/aut/aas/physicaladdress"; + private static final String EXPECTED_PHYSICAL_ADDRESS_SUBMODELELEMENT_MODEL_TYPE = "SubmodelElementCollection"; + private static final String EXPECTED_PHYSICAL_ADDRESS_SUBMODELELEMENT_PROP_1_IDSHORT = "CountryCode"; + private static final String EXPECTED_PHYSICAL_ADDRESS_SUBMODELELEMENT_PROP_1_VALUE = "DE"; + private static final String EXPECTED_PHYSICAL_ADDRESS_SUBMODELELEMENT_PROP_2_IDSHORT = "Street"; + private static final String EXPECTED_PHYSICAL_ADDRESS_SUBMODELELEMENT_PROP_2_VALUE = "Ruiter Straße 82"; + + private static AASXToMetamodelConverter packageConverter; + private static Set aasBundlesFromConverter; + private static Optional specificAASBundleOptional; + private static IAssetAdministrationShell assFromConverter; + + private static Set submodelsFromConverter; + private static Optional specificSubmodelOptional; + private static ISubmodel specificSubmodel; + private static Map specificSubmodelElements; + + @BeforeClass + public static void setup() throws InvalidFormatException, IOException, ParserConfigurationException, SAXException { + // Initialize the aasx package converter with the path to the aasx package + packageConverter = new AASXToMetamodelConverter(GIVEN_AASX_FILE_PATH); + + // retrieve all AASBundles of the given file + aasBundlesFromConverter = packageConverter.retrieveAASBundles(); + + // get the Optional of the selected AAS and the AAS itself + specificAASBundleOptional = getSpecificAASBundleAsOptional(aasBundlesFromConverter, EXPECTED_AAS_IDSHORT); + assFromConverter = getAASFromBundleOptional(specificAASBundleOptional); + + // get the Optional of the selected submodel and the submodel itself + // as well as its submodelElements + submodelsFromConverter = getFirstSubmodel(aasBundlesFromConverter, EXPECTED_AAS_IDSHORT); + specificSubmodelOptional = getSpecificSubmodelAsOptional(submodelsFromConverter, EXPECTED_SUBMODEL_IDSHORT); + specificSubmodel = specificSubmodelOptional.get(); + specificSubmodelElements = specificSubmodel.getSubmodelElements(); + } + + /** + * Test the converted AAS with expected information. + */ + @Test + public void testAASsOfConvertedAASX() { + // check bundle size + assertEquals(EXPECTED_AASBUNDLE_SIZE, aasBundlesFromConverter.size()); + + // check if Optional is present + assertTrue(specificAASBundleOptional.isPresent()); + + // check AAS specific attributes + assertEquals(EXPECTED_AAS_IDSHORT, assFromConverter.getIdShort()); + assertEquals(EXPECTED_AAS_CATEGORY, assFromConverter.getCategory()); + assertEquals(EXPECTED_AAS_IDENTIFICATION_ID, assFromConverter.getIdentification().getId()); + assertEquals(EXPECTED_AAS_IDENTIFICATION_ID_TYPE, assFromConverter.getIdentification().getIdType()); + } + + /** + * Test the converted submodel references with expected information. + */ + @Test + public void testSubmodelReferencesOfConvertedAASX() { + Collection submodelReferences = assFromConverter.getSubmodelReferences(); + assertEquals(EXPECTED_SUBMODEL_REFERENCE_SIZE, submodelReferences.size()); + + List referencelist = new ArrayList<>(); + referencelist.addAll(submodelReferences); + sortReferencelist(referencelist); + for (int i = 0; i < referencelist.size(); i++) { + IReference ref = referencelist.get(i); + List refKeys = ref.getKeys(); + + assertEquals(EXPECTED_SUBMODEL_REFERENCE_IDS[i], refKeys.get(0).getValue()); + assertEquals(EXPECTED_SUBMODEL_REFERENCE_ID_TYPE, refKeys.get(0).getIdType().name()); + assertEquals(EXPECTED_SUBMODEL_REFERENCE_MODEL_TYPE, refKeys.get(0).getType().name()); + assertEquals(EXPECTED_SUBMODEL_REFERENCE_LOCAL, refKeys.get(0).isLocal()); + } + } + + /** + * Test parsed submodels with expected information about them. + */ + @Test + public void testSubmodelsOfConvertedAASX() { + // check submodel size + assertEquals(EXPECTED_SUBMODEL_SIZE, submodelsFromConverter.size()); + + // check if Optional is present + assertTrue(specificSubmodelOptional.isPresent()); + + // check specific submodel + assertEquals(EXPECTED_SUBMODEL_IDSHORT, specificSubmodel.getIdShort()); + assertEquals(EXPECTED_SUBMODEL_IDENTIFICATION_ID, specificSubmodel.getIdentification().getId()); + assertEquals(EXPECTED_SUBMODEL_IDENTIFICATION_ID_TYPE, specificSubmodel.getIdentification().getIdType()); + assertEquals(EXPECTED_SUBMODEL_MODELING_KIND, specificSubmodel.getModelingKind().toString()); + } + + /** + * Test the manufacturer name submodel element. + */ + @Test + public void testManufacturerNameSubmodelElementOfConvertedAASX() { + // get submodelElement + ISubmodelElement submodelElement = specificSubmodelElements.get(EXPECTED_MANUFACTURER_NAME_SUBMODELELEMENT_IDSHORT); + + // check if the idShort matches + assertEquals(EXPECTED_MANUFACTURER_NAME_SUBMODELELEMENT_IDSHORT, submodelElement.getIdShort()); + + // check if the category and modelingKind matches + assertEquals(EXPECTED_SUBMODELELEMENT_CATEGORY, submodelElement.getCategory()); + assertTrue(submodelElement.getModelingKind().name().equalsIgnoreCase(EXPECTED_SUBMODELELEMENT_MODELING_KIND)); + + // check if the property matches + Property prop = (Property) submodelElement; + assertEquals(EXPECTED_MANUFACTURER_NAME_SUBMODELELEMENT_PROPERTY_VALUE, prop.getValue()); + assertEquals(EXPECTED_SUBMODELELEMENT_PROPERTY_VALUE_TYPE, prop.getValueType()); + + // check if the semantic matches + IReference semantic = submodelElement.getSemanticId(); + IKey semanticKey = semantic.getKeys().get(0); + assertTrue(semanticKey.getType().name().equalsIgnoreCase(EXPECTED_SUBMODELELEMENT_SEMANTIC_KEY_TYPE)); + assertEquals(EXPECTED_MANUFACTURER_NAME_SUBMODELELEMENT_SEMANTIC_KEY_ID_TYPE, semanticKey.getIdType().name()); + assertEquals(EXPECTED_MANUFACTURER_NAME_SUBMODELELEMENT_SEMANTIC_KEY_VALUE, semanticKey.getValue()); + assertEquals(EXPECTED_SUBMODELELEMENT_SEMANTIC_KEY_LOCAL, semanticKey.isLocal()); + } + + /** + * test the manufacturer product designation submodel element. + */ + @Test + public void testManufacturerProductDesignationSubmodelElementOfConvertedAASX() { + // get submodelElement + ISubmodelElement submodelElement = specificSubmodelElements.get(EXPECTED_MANUFACTURER_PRODUCT_DESIGNATION_SUBMODELELEMENT_IDSHORT); + + // check if the idShort matches + assertEquals(EXPECTED_MANUFACTURER_PRODUCT_DESIGNATION_SUBMODELELEMENT_IDSHORT, submodelElement.getIdShort()); + + // check if the category and modelingKind matches + assertEquals(EXPECTED_SUBMODELELEMENT_CATEGORY, submodelElement.getCategory()); + assertTrue(submodelElement.getModelingKind().name().equalsIgnoreCase(EXPECTED_SUBMODELELEMENT_MODELING_KIND)); + + // check if the property matches + Property prop = (Property) submodelElement; + assertEquals(EXPECTED_MANUFACTURER_PRODUCT_DESIGNATION_SUBMODELELEMENT_PROPERTY_VALUE, prop.getValue()); + assertEquals(EXPECTED_SUBMODELELEMENT_PROPERTY_VALUE_TYPE, prop.getValueType()); + + // check if the semantic matches + IReference semantic = submodelElement.getSemanticId(); + IKey semanticKey = semantic.getKeys().get(0); + assertTrue(semanticKey.getType().name().equalsIgnoreCase(EXPECTED_SUBMODELELEMENT_SEMANTIC_KEY_TYPE)); + assertEquals(EXPECTED_MANUFACTURER_PRODUCT_DESIGNATION_SUBMODELELEMENT_SEMANTIC_KEY_ID_TYPE, semanticKey.getIdType().name()); + assertEquals(EXPECTED_MANUFACTURER_PRODUCT_DESIGNATION_SUBMODELELEMENT_SEMANTIC_KEY_VALUE, semanticKey.getValue()); + assertEquals(EXPECTED_SUBMODELELEMENT_SEMANTIC_KEY_LOCAL, semanticKey.isLocal()); + } + + /** + * test the physical address submodel element collection. + */ + @Test + public void testPhysicalAddressSubmodelElementOfConvertedAASX() { + // get submodelElement + ISubmodelElement submodelElement = specificSubmodelElements.get(EXPECTED_PHYSICAL_ADDRESS_SUBMODELELEMENT_IDSHORT); + + // check if the idShort matches + assertEquals(EXPECTED_PHYSICAL_ADDRESS_SUBMODELELEMENT_IDSHORT, submodelElement.getIdShort()); + + // check if the category and modelingKind matches + assertEquals(EXPECTED_SUBMODELELEMENT_CATEGORY, submodelElement.getCategory()); + assertTrue(submodelElement.getModelingKind().name().equalsIgnoreCase(EXPECTED_SUBMODELELEMENT_MODELING_KIND)); + + // check if the semantic matches + IReference semantic = submodelElement.getSemanticId(); + IKey semanticKey = semantic.getKeys().get(0); + assertTrue(semanticKey.getType().name().equalsIgnoreCase(EXPECTED_SUBMODELELEMENT_SEMANTIC_KEY_TYPE)); + assertEquals(EXPECTED_PHYSICAL_ADDRESS_SUBMODELELEMENT_SEMANTIC_KEY_ID_TYPE, semanticKey.getIdType().name()); + assertEquals(EXPECTED_PHYSICAL_ADDRESS_SUBMODELELEMENT_SEMANTIC_KEY_VALUE, semanticKey.getValue()); + assertEquals(EXPECTED_SUBMODELELEMENT_SEMANTIC_KEY_LOCAL, semanticKey.isLocal()); + + // check submodelElementColleciton + assertTrue(submodelElement.getModelType().equalsIgnoreCase(EXPECTED_PHYSICAL_ADDRESS_SUBMODELELEMENT_MODEL_TYPE)); + SubmodelElementCollection collection = (SubmodelElementCollection) submodelElement; + Map submodelElementCollectionMap = collection.getSubmodelElements(); + + // check if the properties match + assertEquals(5, submodelElementCollectionMap.size()); + Property prop1 = (Property) submodelElementCollectionMap.get(EXPECTED_PHYSICAL_ADDRESS_SUBMODELELEMENT_PROP_1_IDSHORT); + assertEquals(EXPECTED_PHYSICAL_ADDRESS_SUBMODELELEMENT_PROP_1_IDSHORT, prop1.getIdShort()); + assertEquals(EXPECTED_PHYSICAL_ADDRESS_SUBMODELELEMENT_PROP_1_VALUE, prop1.getValue()); + assertEquals(EXPECTED_SUBMODELELEMENT_PROPERTY_VALUE_TYPE, prop1.getValueType()); + + Property prop2 = (Property) submodelElementCollectionMap.get(EXPECTED_PHYSICAL_ADDRESS_SUBMODELELEMENT_PROP_2_IDSHORT); + assertEquals(EXPECTED_PHYSICAL_ADDRESS_SUBMODELELEMENT_PROP_2_IDSHORT, prop2.getIdShort()); + assertEquals(EXPECTED_PHYSICAL_ADDRESS_SUBMODELELEMENT_PROP_2_VALUE, prop2.getValue()); + assertEquals(EXPECTED_SUBMODELELEMENT_PROPERTY_VALUE_TYPE, prop2.getValueType()); + } + + /** + * Sort a given referenceList by the last two numbers of their ids. + * + * @param referencelist + */ + private void sortReferencelist(List referencelist) { + referencelist.sort((x, y) -> { + String idx = x.getKeys().get(0).getValue(); + String idy = y.getKeys().get(0).getValue(); + + String idx_end = idx.substring(idx.length() - 2); + int idxint = Integer.parseInt(idx_end); + String idy_end = idy.substring(idy.length() - 2); + int idyint = Integer.parseInt(idy_end); + + return idxint - idyint; + }); + } + + /** + * Get the first submodel set from given set of AASBundles. + * + * @param givenBundles + * @param specificSubmodelIdShort + * @return Set + */ + private static Set getFirstSubmodel(Set givenBundles, String specificSubmodelIdShort) { + return givenBundles.stream().filter(b -> b.getAAS().getIdShort().equals(specificSubmodelIdShort)).findFirst().get().getSubmodels(); + } + + /** + * Get the Optional with a given specificAASIdShort from a set of + * AASBundles. + * + * @param aasBundles + * @param specificAASIdShort + * @return Optional + */ + private static Optional getSpecificAASBundleAsOptional(Set aasBundles, String specificAASIdShort) { + return aasBundles.stream().filter(b -> b.getAAS().getIdShort().equals(specificAASIdShort)).findFirst(); + } + + /** + * Get the Optional with a given specificSubmodelIdShort from a set + * of submodels. + * + * @param submodels + * @param specificSubmodelIdShort + * @return Optional + */ + private static Optional getSpecificSubmodelAsOptional(Set submodels, String specificSubmodelIdShort) { + return submodels.stream().filter(s -> s.getIdShort().equals(specificSubmodelIdShort)).findFirst(); + } + + /** + * Get the AAS of an optional AASBundle. + * + * @param optionalBundle + * @return IAssetAdministrationShell + */ + private static IAssetAdministrationShell getAASFromBundleOptional(Optional optionalBundle) { + AASBundle testAASBundle = optionalBundle.get(); + IAssetAdministrationShell aas = testAASBundle.getAAS(); + + return aas; + } +} diff --git a/src/test/java/org/eclipse/basyx/testsuite/regression/aas/factory/aasx/TestMetamodelToAASXConverter.java b/src/test/java/org/eclipse/basyx/testsuite/regression/aas/factory/aasx/TestMetamodelToAASXConverter.java new file mode 100644 index 00000000..5677e4b1 --- /dev/null +++ b/src/test/java/org/eclipse/basyx/testsuite/regression/aas/factory/aasx/TestMetamodelToAASXConverter.java @@ -0,0 +1,224 @@ +/******************************************************************************* + * Copyright (C) 2021 the Eclipse BaSyx Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + ******************************************************************************/ +package org.eclipse.basyx.testsuite.regression.aas.factory.aasx; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.transform.TransformerException; + +import org.apache.poi.openxml4j.exceptions.InvalidFormatException; +import org.eclipse.basyx.aas.bundle.AASBundle; +import org.eclipse.basyx.aas.factory.aasx.AASXToMetamodelConverter; +import org.eclipse.basyx.aas.factory.aasx.InMemoryFile; +import org.eclipse.basyx.aas.factory.aasx.MetamodelToAASXConverter; +import org.eclipse.basyx.aas.metamodel.api.IAssetAdministrationShell; +import org.eclipse.basyx.aas.metamodel.api.parts.asset.AssetKind; +import org.eclipse.basyx.aas.metamodel.api.parts.asset.IAsset; +import org.eclipse.basyx.aas.metamodel.map.AssetAdministrationShell; +import org.eclipse.basyx.aas.metamodel.map.descriptor.ModelUrn; +import org.eclipse.basyx.aas.metamodel.map.parts.Asset; +import org.eclipse.basyx.submodel.metamodel.api.ISubmodel; +import org.eclipse.basyx.submodel.metamodel.api.identifier.IIdentifier; +import org.eclipse.basyx.submodel.metamodel.api.parts.IConceptDescription; +import org.eclipse.basyx.submodel.metamodel.api.submodelelement.ISubmodelElementCollection; +import org.eclipse.basyx.submodel.metamodel.api.submodelelement.dataelement.IFile; +import org.eclipse.basyx.submodel.metamodel.map.Submodel; +import org.eclipse.basyx.submodel.metamodel.map.submodelelement.SubmodelElementCollection; +import org.eclipse.basyx.submodel.metamodel.map.submodelelement.dataelement.File; +import org.eclipse.basyx.vab.modelprovider.VABPathTools; +import org.junit.Before; +import org.junit.Test; +import org.xml.sax.SAXException; + +/** + * Test for the AASXFactory + * + * @author conradi + * + */ +public class TestMetamodelToAASXConverter { + + private static final String XML_PATH = "aasx/xml/content.xml"; + private static final String ORIGIN_PATH = "aasx/aasx-origin"; + private static final String EXTERNAL_FILE_URL = "http://localhost:8080/image.png"; + private static final String INTERNAL_FILE_PATH_1 = "aasx/Document/docu.pdf"; + private static final String INTERNAL_FILE_PATH_2 = "/aasx/Document/docu2.pdf"; + + private static final String FILE_ID_SHORT_1 = "file1"; + private static final String FILE_ID_SHORT_2 = "file2"; + private static final String FILE_ID_SHORT_3 = "file3"; + private static final String COLLECTION_ID_SHORT = "collection"; + + private AssetAdministrationShell aas; + private Submodel sm1; + private Submodel sm2; + + private List aasList = new ArrayList<>(); + private List submodelList = new ArrayList<>(); + private List assetList = new ArrayList<>(); + private List conceptDescriptionList = new ArrayList<>(); + + private List fileList = new ArrayList<>(); + + @Before + public void setup() throws IOException { + Asset asset = new Asset("asset-id", new ModelUrn("ASSET_IDENTIFICATION"), AssetKind.TYPE); + aas = new AssetAdministrationShell("aas-id", new ModelUrn("AAS_IDENTIFICATION"), asset); + + sm1 = new Submodel("sm1", new ModelUrn("SM1_ID")); + sm2 = new Submodel("sm2", new ModelUrn("SM2_ID")); + + File file1 = new File(EXTERNAL_FILE_URL, "image/png"); + file1.setIdShort(FILE_ID_SHORT_1); + File file2 = new File(INTERNAL_FILE_PATH_1, "application/pdf"); + file2.setIdShort(FILE_ID_SHORT_2); + File file3 = new File(INTERNAL_FILE_PATH_2, "application/pdf"); + file3.setIdShort(FILE_ID_SHORT_3); + + SubmodelElementCollection collection = new SubmodelElementCollection(COLLECTION_ID_SHORT); + collection.addSubmodelElement(file2); + + sm1.addSubmodelElement(file1); + sm1.addSubmodelElement(collection); + sm2.addSubmodelElement(file3); + + aas.addSubmodel(sm1); + aas.addSubmodel(sm2); + + aasList.add(aas); + submodelList.add(sm1); + submodelList.add(sm2); + assetList.add(asset); + + byte[] content1 = { 5, 6, 7, 8, 9 }; + InMemoryFile file = new InMemoryFile(content1, "/aasx/Document/docu.pdf"); + fileList.add(file); + + byte[] content2 = { 10, 11, 12, 13, 14 }; + file = new InMemoryFile(content2, "aasx/Document/docu2.pdf"); + fileList.add(file); + } + + @Test + public void testBuildAASX() throws IOException, TransformerException, ParserConfigurationException, + InvalidFormatException, SAXException { + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + MetamodelToAASXConverter.buildAASX(aasList, assetList, conceptDescriptionList, submodelList, fileList, out); + validateAASX(out); + } + + @Test + public void testFilePathsAreCorrectlyChanged() throws IOException, TransformerException, + ParserConfigurationException, InvalidFormatException, SAXException { + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + MetamodelToAASXConverter.buildAASX(aasList, assetList, conceptDescriptionList, submodelList, fileList, out); + + Set aasBundle = deserializeAASX(out); + assertFilepathsAreCorrect(aasBundle); + } + + private void validateAASX(ByteArrayOutputStream byteStream) throws IOException { + ZipInputStream in = new ZipInputStream(new ByteArrayInputStream(byteStream.toByteArray())); + ZipEntry zipEntry = null; + + ArrayList filePaths = new ArrayList<>(); + + while ((zipEntry = in.getNextEntry()) != null) { + if (isExpectedXMLPath(zipEntry)) { + assertIsXML(in); + } + filePaths.add(zipEntry.getName()); + } + + assertTrue(filePaths.contains(XML_PATH)); + assertTrue(filePaths.contains(ORIGIN_PATH)); + assertExpectedFileElementsArePresent(filePaths); + } + + private void assertExpectedFileElementsArePresent(List filePaths) { + fileList.stream().forEach(file -> assertFilePathsContainFile(filePaths, file)); + assertFilePathsDoNotContainExternalFileURL(filePaths); + } + + private void assertFilePathsDoNotContainExternalFileURL(List filePaths) { + String strippedExternalFileURL = VABPathTools.stripSlashes(EXTERNAL_FILE_URL); + assertFalse(filePaths.contains(strippedExternalFileURL)); + } + + private void assertFilePathsContainFile(List filePaths, InMemoryFile file) { + String strippedPath = VABPathTools.stripSlashes(file.getPath()); + assertTrue(filePaths.contains(strippedPath)); + } + + private boolean isExpectedXMLPath(ZipEntry zipEntry) { + return zipEntry.getName().equals(XML_PATH); + } + + private void assertIsXML(ZipInputStream in) throws IOException { + byte[] buf = new byte[5]; + in.read(buf); + assertEquals(" deserializeAASX(ByteArrayOutputStream byteStream) + throws IOException, InvalidFormatException, ParserConfigurationException, SAXException { + InputStream in = new ByteArrayInputStream(byteStream.toByteArray()); + + AASXToMetamodelConverter aasxDeserializer = new AASXToMetamodelConverter(in); + return aasxDeserializer.retrieveAASBundles(); + } + + private void assertFilepathsAreCorrect(Set aasBundles) { + AASBundle aasBundle = extractedAASBundleFromAASBundleSet(aasBundles, aas.getIdentification()); + Set deserializedSubmodels = aasBundle.getSubmodels(); + + ISubmodel deserializedSm1 = extractSubmodelFromSubmodelSet(deserializedSubmodels, sm1.getIdentification()); + ISubmodel deserializedSm2 = extractSubmodelFromSubmodelSet(deserializedSubmodels, sm2.getIdentification()); + + ISubmodelElementCollection deserializedCollection = (ISubmodelElementCollection) deserializedSm1 + .getSubmodelElement(COLLECTION_ID_SHORT); + + IFile deserializedFile1 = (IFile) deserializedSm1.getSubmodelElement(FILE_ID_SHORT_1); + IFile deserializedFile2 = (IFile) deserializedCollection.getSubmodelElement(FILE_ID_SHORT_2); + IFile deserializedFile3 = (IFile) deserializedSm2.getSubmodelElement(FILE_ID_SHORT_3); + + assertEquals(EXTERNAL_FILE_URL, deserializedFile1.getValue()); + assertEquals(harmonizePrefixSlash(INTERNAL_FILE_PATH_1), deserializedFile2.getValue()); + assertEquals(harmonizePrefixSlash(INTERNAL_FILE_PATH_2), deserializedFile3.getValue()); + } + + private AASBundle extractedAASBundleFromAASBundleSet(Set aasBundles, IIdentifier identifier) { + return aasBundles.stream().filter(aasB -> aasB.getAAS().getIdentification().equals(identifier)).findAny().get(); + } + + private Object harmonizePrefixSlash(String path) { + return path.startsWith("/") ? path : "/" + path; + } + + private ISubmodel extractSubmodelFromSubmodelSet(Set submodelSet, IIdentifier identifier) { + return submodelSet.stream().filter(sm -> sm.getIdentification().equals(identifier)).findAny().get(); + } + +} diff --git a/src/test/java/org/eclipse/basyx/testsuite/regression/aas/factory/aasx/TestSubmodelFileEndpointerLoader.java b/src/test/java/org/eclipse/basyx/testsuite/regression/aas/factory/aasx/TestSubmodelFileEndpointerLoader.java new file mode 100644 index 00000000..73c9a991 --- /dev/null +++ b/src/test/java/org/eclipse/basyx/testsuite/regression/aas/factory/aasx/TestSubmodelFileEndpointerLoader.java @@ -0,0 +1,100 @@ +/******************************************************************************* + * Copyright (C) 2021 the Eclipse BaSyx Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + ******************************************************************************/ + + +package org.eclipse.basyx.testsuite.regression.aas.factory.aasx; + +import static org.junit.Assert.assertEquals; + +import java.util.Map; + +import org.eclipse.basyx.aas.factory.aasx.SubmodelFileEndpointLoader; +import org.eclipse.basyx.submodel.metamodel.api.identifier.IdentifierType; +import org.eclipse.basyx.submodel.metamodel.api.submodelelement.ISubmodelElement; +import org.eclipse.basyx.submodel.metamodel.api.submodelelement.dataelement.IFile; +import org.eclipse.basyx.submodel.metamodel.map.Submodel; +import org.eclipse.basyx.submodel.metamodel.map.identifier.Identifier; +import org.eclipse.basyx.submodel.metamodel.map.submodelelement.SubmodelElementCollection; +import org.eclipse.basyx.submodel.metamodel.map.submodelelement.dataelement.File; +import org.junit.Before; +import org.junit.Test; + +/** + * Tests the SubmodelFileEndpointLoader + * + * @author espen + * + */ +public class TestSubmodelFileEndpointerLoader { + private Submodel submodel; + private static final String RELATIVE_PATH = "/file/root/text.txt"; + private static final String ABOSLUTE_PATH = "http://localhost:1234/file/root/text.txt"; + private static final String RELATIVE_TARGET_PATH = "http://localhost:4321/new/file/root/text.txt"; + + @Before + public void setup() { + File fRel = new File(RELATIVE_PATH, "application/json"); + fRel.setIdShort("fRel"); + File fAbs = new File(ABOSLUTE_PATH, "application/json"); + fAbs.setIdShort("fAbs"); + SubmodelElementCollection col = new SubmodelElementCollection(); + col.setIdShort("fileCollection"); + File fCol = new File(RELATIVE_PATH, "application/json"); + fCol.setIdShort("fInside"); + col.addSubmodelElement(fCol); + submodel = new Submodel("FileTestSubmodel", new Identifier(IdentifierType.IRDI, "FileTestSubmodel")); + submodel.addSubmodelElement(fRel); + submodel.addSubmodelElement(fAbs); + submodel.addSubmodelElement(col); + } + + /** + * Tests setting a static string endpoint (relative to the given path in the + * existing value) + */ + @Test + public void testRelativePaths1() { + SubmodelFileEndpointLoader.setRelativeFileEndpoints(submodel, "http://localhost:4321/new"); + checkRelativeTargetPaths(); + } + + /** + * Tests setting a endpoint via host, port and root path (relative to the given + * path in the existing value) + */ + @Test + public void testRelativePaths2() { + SubmodelFileEndpointLoader.setRelativeFileEndpoints(submodel, "localhost", 4321, "/new"); + checkRelativeTargetPaths(); + } + + /** + * Tests elements inside of collections + */ + @Test + public void testCollections() { + SubmodelFileEndpointLoader.setRelativeFileEndpoints(submodel, "localhost", 4321, "/new"); + + Map elements = submodel.getSubmodelElements(); + SubmodelElementCollection col = (SubmodelElementCollection) elements.get("fileCollection"); + IFile file = (IFile) col.getSubmodelElements().get("fInside"); + assertEquals(RELATIVE_TARGET_PATH, file.getValue()); + } + + private void checkRelativeTargetPaths() { + Map elements = submodel.getSubmodelElements(); + + String fromRelative = ((IFile) elements.get("fRel")).getValue(); + assertEquals(RELATIVE_TARGET_PATH, fromRelative); + + String fromAbsolute = ((IFile) elements.get("fAbs")).getValue(); + assertEquals(RELATIVE_TARGET_PATH, fromAbsolute); + } +} diff --git a/src/test/java/org/eclipse/basyx/testsuite/regression/aas/factory/xml/TestAASXPackageExplorerCompatibility.java b/src/test/java/org/eclipse/basyx/testsuite/regression/aas/factory/xml/TestAASXPackageExplorerCompatibility.java new file mode 100644 index 00000000..c38d0cba --- /dev/null +++ b/src/test/java/org/eclipse/basyx/testsuite/regression/aas/factory/xml/TestAASXPackageExplorerCompatibility.java @@ -0,0 +1,64 @@ +/******************************************************************************* +* Copyright (C) 2021 the Eclipse BaSyx Authors +* +* This program and the accompanying materials are made +* available under the terms of the Eclipse Public License 2.0 +* which is available at https://www.eclipse.org/legal/epl-2.0/ +* +* SPDX-License-Identifier: EPL-2.0 +******************************************************************************/ +package org.eclipse.basyx.testsuite.regression.aas.factory.xml; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.List; + +import org.eclipse.basyx.aas.factory.xml.XMLToMetamodelConverter; +import org.eclipse.basyx.aas.metamodel.api.parts.asset.AssetKind; +import org.eclipse.basyx.aas.metamodel.api.parts.asset.IAsset; +import org.eclipse.basyx.submodel.metamodel.api.ISubmodel; +import org.eclipse.basyx.submodel.metamodel.map.submodelelement.operation.Operation; +import org.junit.Test; + +/** + * This tests the workarounds implemented in XML deserialization + * to handle .xml files generated by the AASXPackageExplorer + * + * @author conradi + * + */ +public class TestAASXPackageExplorerCompatibility { + + private String xmlInWorkaroundsPath = "src/test/resources/aas/factory/xml/inWorkarounds.xml"; + + @Test + public void testAASXPackageExplorerWorkarounds() throws Exception { + String xml = new String(Files.readAllBytes(Paths.get(xmlInWorkaroundsPath))); + XMLToMetamodelConverter converter = new XMLToMetamodelConverter(xml); + List assetList = converter.parseAssets(); + List submodelList = converter.parseSubmodels(); + + assertEquals(1, assetList.size()); + IAsset asset = assetList.iterator().next(); + checkAsset(asset); + + assertEquals(1, submodelList.size()); + ISubmodel sm = submodelList.iterator().next(); + checkOperation(sm); + } + + private void checkOperation(ISubmodel sm) { + Operation op = (Operation) sm.getSubmodelElement("operation_ID"); + assertNotNull(op); + assertEquals(2, op.getInputVariables().size()); + assertEquals(1, op.getOutputVariables().size()); + } + + private void checkAsset(IAsset asset) { + assertEquals(asset.getAssetKind(), AssetKind.TYPE); + } + +} diff --git a/src/test/java/org/eclipse/basyx/testsuite/regression/aas/manager/TestAASHTTP.java b/src/test/java/org/eclipse/basyx/testsuite/regression/aas/manager/TestAASHTTP.java index a4c467ca..ae757d95 100644 --- a/src/test/java/org/eclipse/basyx/testsuite/regression/aas/manager/TestAASHTTP.java +++ b/src/test/java/org/eclipse/basyx/testsuite/regression/aas/manager/TestAASHTTP.java @@ -11,7 +11,9 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import java.util.List; import java.util.Map; import org.eclipse.basyx.aas.manager.ConnectedAssetAdministrationShellManager; @@ -24,7 +26,10 @@ import org.eclipse.basyx.submodel.metamodel.api.submodelelement.dataelement.IProperty; import org.eclipse.basyx.submodel.metamodel.api.submodelelement.operation.IOperation; import org.eclipse.basyx.testsuite.regression.aas.restapi.StubAASServlet; +import org.eclipse.basyx.testsuite.regression.submodel.restapi.SimpleAASSubmodel; import org.eclipse.basyx.testsuite.regression.vab.protocol.http.AASHTTPServerResource; +import org.eclipse.basyx.vab.coder.json.metaprotocol.Message; +import org.eclipse.basyx.vab.exception.provider.ProviderException; import org.eclipse.basyx.vab.protocol.http.connector.HTTPConnectorFactory; import org.eclipse.basyx.vab.protocol.http.server.BaSyxContext; import org.eclipse.basyx.vab.registry.memory.VABInMemoryRegistry; @@ -129,7 +134,29 @@ public void testSubmodel() throws Exception { assertEquals(4, operations.size()); IOperation op = operations.get("complex"); - assertEquals(1, op.invoke(2, 1)); + assertEquals(1, op.invokeSimple(2, 1)); + + op = operations.get("exception1"); + try { + op.invokeSimple(); + fail(); + } catch (ProviderException e) { + List msg = e.getMessages(); + assertEquals(2, msg.size()); + String msgText = msg.get(1).getText(); + assertTrue(msgText.contains("ProviderException: " + NullPointerException.class.getName())); + } + + op = operations.get("exception2"); + try { + op.invokeSimple(); + fail(); + } catch (ProviderException e) { + List msg = e.getMessages(); + assertEquals(2, msg.size()); + String msgText = msg.get(1).getText(); + assertTrue(msgText.contains("ProviderException: " + SimpleAASSubmodel.EXCEPTION_MESSAGE)); + } Map elements = sm.getSubmodelElements(); // 2 properties, 4 operations, 1 collection diff --git a/src/test/java/org/eclipse/basyx/testsuite/regression/aas/manager/TestConnectedAssetAdministrationShellManager.java b/src/test/java/org/eclipse/basyx/testsuite/regression/aas/manager/TestConnectedAssetAdministrationShellManager.java index 5b9d9bfa..815fc6ff 100644 --- a/src/test/java/org/eclipse/basyx/testsuite/regression/aas/manager/TestConnectedAssetAdministrationShellManager.java +++ b/src/test/java/org/eclipse/basyx/testsuite/regression/aas/manager/TestConnectedAssetAdministrationShellManager.java @@ -37,6 +37,7 @@ import org.eclipse.basyx.testsuite.regression.vab.gateway.ConnectorProviderStub; import org.eclipse.basyx.vab.exception.provider.ResourceNotFoundException; import org.eclipse.basyx.vab.modelprovider.VABElementProxy; +import org.eclipse.basyx.vab.modelprovider.VABPathTools; import org.eclipse.basyx.vab.modelprovider.api.IModelProvider; import org.junit.Before; import org.junit.Test; @@ -45,7 +46,7 @@ * Tests ConnectedAssetAdministrationShellManager class * * @author schnicke - * + * */ public class TestConnectedAssetAdministrationShellManager { ConnectedAssetAdministrationShellManager manager; @@ -79,11 +80,11 @@ public void testCreateAAS() throws Exception { // Create an AAS containing a reference to the created Submodel AssetAdministrationShell aas = createTestAAS(aasId, aasIdShort); - manager.createAAS(aas, "/shells"); + manager.createAAS(aas, ""); // Check descriptor for correct endpoint String endpoint = registry.lookupAAS(aasId).getFirstEndpoint(); - assertEquals(AASAggregatorProvider.PREFIX + "/" + aasId.getId() + "/aas", endpoint); + assertEquals(AASAggregatorProvider.PREFIX + "/" + aasId.getId() + "/aas", VABPathTools.stripSlashes(endpoint)); // Retrieve it ConnectedAssetAdministrationShell connectedAAS = manager.retrieveAAS(aasId); @@ -148,7 +149,7 @@ public void testDeleteSubmodel() { prepareConnectorProvider(provider); AssetAdministrationShell aas = createTestAAS(aasId, aasIdShort); - manager.createAAS(aas, "/shells"); + manager.createAAS(aas, ""); Submodel sm = new Submodel(smIdShort, smId); manager.createSubmodel(aasId, sm); @@ -172,7 +173,7 @@ public void testDeleteAAS() { prepareConnectorProvider(provider); AssetAdministrationShell aas = createTestAAS(aasId, aasIdShort); - manager.createAAS(aas, "/shells"); + manager.createAAS(aas, ""); manager.deleteAAS(aas.getIdentification()); try { manager.retrieveAAS(aas.getIdentification()); diff --git a/src/test/java/org/eclipse/basyx/testsuite/regression/aas/metamodel/map/descriptor/ModelDescriptorTestSuite.java b/src/test/java/org/eclipse/basyx/testsuite/regression/aas/metamodel/map/descriptor/ModelDescriptorTestSuite.java new file mode 100644 index 00000000..03d2f7aa --- /dev/null +++ b/src/test/java/org/eclipse/basyx/testsuite/regression/aas/metamodel/map/descriptor/ModelDescriptorTestSuite.java @@ -0,0 +1,57 @@ +/******************************************************************************* +* Copyright (C) 2021 the Eclipse BaSyx Authors +* +* This program and the accompanying materials are made +* available under the terms of the Eclipse Public License 2.0 +* which is available at https://www.eclipse.org/legal/epl-2.0/ + +* +* SPDX-License-Identifier: EPL-2.0 +******************************************************************************/ + +package org.eclipse.basyx.testsuite.regression.aas.metamodel.map.descriptor; + +import static org.junit.Assert.assertTrue; + +import java.util.Collection; +import java.util.Map; + +import org.eclipse.basyx.aas.metamodel.map.AssetAdministrationShell; +import org.eclipse.basyx.aas.metamodel.map.descriptor.ModelDescriptor; +import org.junit.Test; + +/** + * Test suite for Model Descriptor common method testing + * + * @author haque + * + */ +public abstract class ModelDescriptorTestSuite { + private static final String TESTENDPOINT = "dummy.com"; + private static final String TESTENDPOINT2 = "dummy2.com"; + private ModelDescriptor descriptor; + + public abstract ModelDescriptor retrieveModelDescriptor(); + + @Test + public void testAddEndpoint() { + addEndpoints(); + Collection> endpoints = descriptor.getEndpoints(); + assertTrue(endpoints.stream().anyMatch(x -> x.get(AssetAdministrationShell.ADDRESS) != null && x.get(AssetAdministrationShell.ADDRESS).equals(TESTENDPOINT))); + assertTrue(endpoints.stream().anyMatch(x -> x.get(AssetAdministrationShell.ADDRESS) != null && x.get(AssetAdministrationShell.ADDRESS).equals(TESTENDPOINT2))); + } + + @Test + public void testRemoveEndpoint() { + addEndpoints(); + descriptor.removeEndpoint(TESTENDPOINT); + Collection> endpoints = descriptor.getEndpoints(); + assertTrue(endpoints.stream().noneMatch(x -> x.get(AssetAdministrationShell.ADDRESS) != null && x.get(AssetAdministrationShell.ADDRESS).equals(TESTENDPOINT))); + } + + private void addEndpoints() { + descriptor = retrieveModelDescriptor(); + descriptor.addEndpoint(TESTENDPOINT); + descriptor.addEndpoint(TESTENDPOINT2); + } +} diff --git a/src/test/java/org/eclipse/basyx/testsuite/regression/aas/metamodel/map/descriptor/TestAASDescriptor.java b/src/test/java/org/eclipse/basyx/testsuite/regression/aas/metamodel/map/descriptor/TestAASDescriptor.java index 25782cc6..bdfc0a3d 100644 --- a/src/test/java/org/eclipse/basyx/testsuite/regression/aas/metamodel/map/descriptor/TestAASDescriptor.java +++ b/src/test/java/org/eclipse/basyx/testsuite/regression/aas/metamodel/map/descriptor/TestAASDescriptor.java @@ -35,7 +35,7 @@ * @author schnicke * */ -public class TestAASDescriptor { +public class TestAASDescriptor extends ModelDescriptorTestSuite { private Map map; @@ -139,4 +139,9 @@ public void testValidateWrongSubmodels() { map.put(AssetAdministrationShell.SUBMODELS, "testSubmodel"); new AASDescriptor(map).getSubmodelDescriptors(); } + + @Override + public ModelDescriptor retrieveModelDescriptor() { + return new AASDescriptor(map); + } } diff --git a/src/test/java/org/eclipse/basyx/testsuite/regression/aas/metamodel/map/descriptor/TestSubmodelDescriptor.java b/src/test/java/org/eclipse/basyx/testsuite/regression/aas/metamodel/map/descriptor/TestSubmodelDescriptor.java index e62df536..b477674f 100644 --- a/src/test/java/org/eclipse/basyx/testsuite/regression/aas/metamodel/map/descriptor/TestSubmodelDescriptor.java +++ b/src/test/java/org/eclipse/basyx/testsuite/regression/aas/metamodel/map/descriptor/TestSubmodelDescriptor.java @@ -14,6 +14,7 @@ import java.util.ArrayList; import java.util.Collections; +import org.eclipse.basyx.aas.metamodel.map.descriptor.ModelDescriptor; import org.eclipse.basyx.aas.metamodel.map.descriptor.SubmodelDescriptor; import org.eclipse.basyx.submodel.metamodel.api.identifier.IdentifierType; import org.eclipse.basyx.submodel.metamodel.api.qualifier.haskind.ModelingKind; @@ -38,7 +39,7 @@ * @author haque * */ -public class TestSubmodelDescriptor { +public class TestSubmodelDescriptor extends ModelDescriptorTestSuite { private static final IdentifierType ID_TYPE = IdentifierType.CUSTOM; private static final String HTTP_ENDPOINT = "testEnd/submodel"; private static final String ID_SHORT_STRING = "testIdShort"; @@ -75,4 +76,9 @@ public void testConstructor2() { assertEquals(ID_SHORT_STRING, descriptor.getIdShort()); assertEquals(IDENTIFIER, descriptor.getIdentifier()); } + + @Override + public ModelDescriptor retrieveModelDescriptor() { + return new SubmodelDescriptor(ID_SHORT_STRING, IDENTIFIER, HTTP_ENDPOINT); + } } diff --git a/src/test/java/org/eclipse/basyx/testsuite/regression/aas/registration/observing/ObservableAASRegistryServiceTest.java b/src/test/java/org/eclipse/basyx/testsuite/regression/aas/registration/observing/ObservableAASRegistryServiceTest.java new file mode 100644 index 00000000..8257d6b7 --- /dev/null +++ b/src/test/java/org/eclipse/basyx/testsuite/regression/aas/registration/observing/ObservableAASRegistryServiceTest.java @@ -0,0 +1,168 @@ +/******************************************************************************* +* Copyright (C) 2021 the Eclipse BaSyx Authors +* +* This program and the accompanying materials are made +* available under the terms of the Eclipse Public License 2.0 +* which is available at https://www.eclipse.org/legal/epl-2.0/ + +* +* SPDX-License-Identifier: EPL-2.0 +******************************************************************************/ + +package org.eclipse.basyx.testsuite.regression.aas.registration.observing; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.eclipse.basyx.aas.metamodel.api.parts.asset.AssetKind; +import org.eclipse.basyx.aas.metamodel.map.AssetAdministrationShell; +import org.eclipse.basyx.aas.metamodel.map.descriptor.AASDescriptor; +import org.eclipse.basyx.aas.metamodel.map.descriptor.SubmodelDescriptor; +import org.eclipse.basyx.aas.metamodel.map.parts.Asset; +import org.eclipse.basyx.aas.registration.api.IAASRegistry; +import org.eclipse.basyx.aas.registration.memory.InMemoryRegistry; +import org.eclipse.basyx.aas.registration.observing.IAASRegistryServiceObserver; +import org.eclipse.basyx.aas.registration.observing.ObservableAASRegistryService; +import org.eclipse.basyx.submodel.metamodel.api.identifier.IIdentifier; +import org.eclipse.basyx.submodel.metamodel.api.identifier.IdentifierType; +import org.eclipse.basyx.submodel.metamodel.map.Submodel; +import org.eclipse.basyx.submodel.metamodel.map.identifier.Identifier; +import org.junit.Before; +import org.junit.Test; + +public class ObservableAASRegistryServiceTest { + + private static final String AASID = "aasid1"; + private static final String SUBMODELID = "submodelid1"; + private static final String AASENDPOINT = "http://localhost:8080/aasList/" + AASID + "/aas"; + private static final Identifier AASIDENTIFIER = new Identifier(IdentifierType.IRI, AASID); + private static final Identifier SUBMODELIDENTIFIER = new Identifier(IdentifierType.IRI, SUBMODELID); + + private ObservableAASRegistryService observedRegistry; + private MockObserver observer; + + @Before + public void setup() { + // Create underlying registry service + IAASRegistry registryService = new InMemoryRegistry(); + + AssetAdministrationShell shell = new AssetAdministrationShell(AASID, AASIDENTIFIER, new Asset("assetid1", new Identifier(IdentifierType.IRI, "assetid1"), AssetKind.INSTANCE)); + AASDescriptor aasDescriptor = new AASDescriptor(shell, AASENDPOINT); + registryService.register(aasDescriptor); + + Submodel submodel = new Submodel(SUBMODELID, SUBMODELIDENTIFIER); + String submodelEndpoint = AASENDPOINT + "/submodels/" + SUBMODELID + "/submodel"; + SubmodelDescriptor submodelDescriptor = new SubmodelDescriptor(submodel, submodelEndpoint); + registryService.register(AASIDENTIFIER, submodelDescriptor); + + observedRegistry = new ObservableAASRegistryService(registryService); + + // Create an Observer + observer = new MockObserver(); + + // Register the observer at the API + observedRegistry.addObserver(observer); + } + + @Test + public void testRegisterAAS() { + String newAASId = "aasid2"; + Identifier newIdentifier = new Identifier(IdentifierType.IRI, newAASId); + AssetAdministrationShell shell = new AssetAdministrationShell(newAASId, newIdentifier, new Asset("assetid1", new Identifier(IdentifierType.IRI, "assetid2"), AssetKind.INSTANCE)); + String aasEndpoint = "http://localhost:8080/aasList/" + newAASId + "/aas"; + + AASDescriptor aasDescriptor = new AASDescriptor(shell, aasEndpoint); + observedRegistry.register(aasDescriptor); + + assertEquals(newAASId, observer.aasId); + assertTrue(observer.registerAASNotified); + } + + @Test + public void testRegisterSubmodel() { + String submodelid = "submodelid2"; + Identifier newSubmodelIdentifier = new Identifier(IdentifierType.IRI, submodelid); + Submodel submodel = new Submodel(submodelid, newSubmodelIdentifier); + String submodelEndpoint = AASENDPOINT + "/submodels/" + submodelid + "/submodel"; + SubmodelDescriptor submodelDescriptor = new SubmodelDescriptor(submodel, submodelEndpoint); + + observedRegistry.register(AASIDENTIFIER, submodelDescriptor); + + + assertTrue(observer.registerSubmodelNotified); + assertEquals(AASID, observer.aasId); + assertEquals(submodelid, observer.smId); + } + + @Test + public void testDeleteAAS() { + observedRegistry.delete(AASIDENTIFIER); + assertTrue(observer.deleteAASNotified); + assertEquals(AASID, observer.aasId); + } + + @Test + public void testDeleteSubmodel() { + observedRegistry.delete(AASIDENTIFIER, SUBMODELIDENTIFIER); + assertTrue(observer.deleteSubmodelNotified); + assertEquals(AASID, observer.aasId); + assertEquals(SUBMODELID, observer.smId); + } + + @Test + public void testRemoveObserver() { + assertTrue(observedRegistry.removeObserver(observer)); + observedRegistry.delete(AASIDENTIFIER); + assertFalse(observer.deleteAASNotified); + } + + private class MockObserver implements IAASRegistryServiceObserver { + + public boolean registerAASNotified = false; + public boolean registerSubmodelNotified = false; + public boolean deleteAASNotified = false; + public boolean deleteSubmodelNotified = false; + + public String aasId = ""; + public String smId = ""; + + @Override + public void aasRegistered(String aasId) { + this.registerAASNotified = true; + this.registerSubmodelNotified = false; + this.deleteAASNotified = false; + this.deleteSubmodelNotified = false; + this.aasId = aasId; + } + + @Override + public void submodelRegistered(IIdentifier aasId, IIdentifier smId) { + this.registerAASNotified = false; + this.registerSubmodelNotified = true; + this.deleteAASNotified = false; + this.deleteSubmodelNotified = false; + this.aasId = aasId.getId(); + this.smId = smId.getId(); + } + + @Override + public void aasDeleted(String aasId) { + this.registerAASNotified = false; + this.registerSubmodelNotified = false; + this.deleteAASNotified = true; + this.deleteSubmodelNotified = false; + this.aasId = aasId; + } + + @Override + public void submodelDeleted(IIdentifier aasId, IIdentifier smId) { + this.registerAASNotified = false; + this.registerSubmodelNotified = false; + this.deleteAASNotified = false; + this.deleteSubmodelNotified = true; + this.aasId = aasId.getId(); + this.smId = smId.getId(); + } + } +} diff --git a/src/test/java/org/eclipse/basyx/testsuite/regression/extensions/aas/aggregator/aasxupload/TestAASAggregatorAASXUpload.java b/src/test/java/org/eclipse/basyx/testsuite/regression/extensions/aas/aggregator/aasxupload/TestAASAggregatorAASXUpload.java new file mode 100644 index 00000000..61b14e78 --- /dev/null +++ b/src/test/java/org/eclipse/basyx/testsuite/regression/extensions/aas/aggregator/aasxupload/TestAASAggregatorAASXUpload.java @@ -0,0 +1,28 @@ +/******************************************************************************* +* Copyright (C) 2021 the Eclipse BaSyx Authors +* +* This program and the accompanying materials are made +* available under the terms of the Eclipse Public License 2.0 +* which is available at https://www.eclipse.org/legal/epl-2.0/ + +* +* SPDX-License-Identifier: EPL-2.0 +******************************************************************************/ + +package org.eclipse.basyx.testsuite.regression.extensions.aas.aggregator.aasxupload; + +import org.eclipse.basyx.aas.aggregator.AASAggregator; +import org.eclipse.basyx.aas.aggregator.api.IAASAggregator; +import org.eclipse.basyx.extensions.aas.aggregator.aasxupload.AASAggregatorAASXUpload; + +/** + * Tests AAS Aggragator with AASX upload functionality + * @author haque + * + */ +public class TestAASAggregatorAASXUpload extends TestAASAggregatorAASXUploadSuite{ + @Override + protected IAASAggregator getAggregator() { + return new AASAggregatorAASXUpload(new AASAggregator()); + } +} diff --git a/src/test/java/org/eclipse/basyx/testsuite/regression/extensions/aas/aggregator/aasxupload/TestAASAggregatorAASXUploadSuite.java b/src/test/java/org/eclipse/basyx/testsuite/regression/extensions/aas/aggregator/aasxupload/TestAASAggregatorAASXUploadSuite.java new file mode 100644 index 00000000..2842499d --- /dev/null +++ b/src/test/java/org/eclipse/basyx/testsuite/regression/extensions/aas/aggregator/aasxupload/TestAASAggregatorAASXUploadSuite.java @@ -0,0 +1,74 @@ +/******************************************************************************* +* Copyright (C) 2021 the Eclipse BaSyx Authors +* +* This program and the accompanying materials are made +* available under the terms of the Eclipse Public License 2.0 +* which is available at https://www.eclipse.org/legal/epl-2.0/ + +* +* SPDX-License-Identifier: EPL-2.0 +******************************************************************************/ + +package org.eclipse.basyx.testsuite.regression.extensions.aas.aggregator.aasxupload; + +import static org.junit.Assert.assertEquals; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.nio.file.Paths; +import java.util.Collection; +import java.util.Iterator; + +import org.apache.http.client.ClientProtocolException; +import org.eclipse.basyx.aas.metamodel.api.IAssetAdministrationShell; +import org.eclipse.basyx.extensions.aas.aggregator.aasxupload.api.IAASAggregatorAASXUpload; +import org.eclipse.basyx.submodel.metamodel.api.reference.IReference; +import org.eclipse.basyx.testsuite.regression.aas.aggregator.AASAggregatorSuite; +import org.junit.Test; + +/** + * Test suite for testing AAS Aggregator along with AASX upload + * @author haque + * + */ +public abstract class TestAASAggregatorAASXUploadSuite extends AASAggregatorSuite { + public static final String AASX_PATH = "src/test/resources/aas/factory/aasx/01_Festo.aasx"; + + @Test + public void testUploadAASX() throws ClientProtocolException, IOException { + File file = Paths.get(AASX_PATH).toFile(); + IAASAggregatorAASXUpload aggregator = (IAASAggregatorAASXUpload) getAggregator(); + aggregator.uploadAASX(new FileInputStream(file)); + checkAASX(aggregator.getAASList()); + } + + public static void checkAASX(Collection shells) { + assertEquals(2, shells.size()); + + Iterator iterator = shells.iterator(); + IAssetAdministrationShell shell1 = iterator.next(); + assertEquals("www.admin-shell.io/aas-sample/1/1", shell1.getIdentification().getId()); + assertEquals("test_asset_aas", shell1.getIdShort()); + + Iterator smIteratorShell1 = shell1.getSubmodelReferences().iterator(); + IReference shell1Sm1 = smIteratorShell1.next(); + assertEquals("de.iese.com/ids/sm/0000_000_000_001", shell1Sm1.getKeys().get(0).getValue()); + + IAssetAdministrationShell shell2 = iterator.next(); + assertEquals("smart.festo.com/demo/aas/1/1/454576463545648365874", shell2.getIdentification().getId()); + assertEquals("Festo_3S7PM0CP4BD", shell2.getIdShort()); + + Iterator smIteratorShell2 = shell2.getSubmodelReferences().iterator(); + IReference shell2Sm1 = smIteratorShell2.next(); + assertEquals("www.company.com/ids/sm/4343_5072_7091_3242", shell2Sm1.getKeys().get(0).getValue()); + IReference shell2Sm2 = smIteratorShell2.next(); + assertEquals("www.company.com/ids/sm/2543_5072_7091_2660", shell2Sm2.getKeys().get(0).getValue()); + IReference shell2Sm3 = smIteratorShell2.next(); + assertEquals("smart.festo.com/demo/sm/instance/1/1/13B7CCD9BF7A3F24", shell2Sm3.getKeys().get(0).getValue()); + IReference shell2Sm4 = smIteratorShell2.next(); + assertEquals("www.company.com/ids/sm/6053_5072_7091_5102", shell2Sm4.getKeys().get(0).getValue()); + IReference shell2Sm5 = smIteratorShell2.next(); + assertEquals("www.company.com/ids/sm/6563_5072_7091_4267", shell2Sm5.getKeys().get(0).getValue()); + } +} diff --git a/src/test/java/org/eclipse/basyx/testsuite/regression/extensions/aas/aggregator/aasxupload/TestAASAggregatorProxyWithAASXProvider.java b/src/test/java/org/eclipse/basyx/testsuite/regression/extensions/aas/aggregator/aasxupload/TestAASAggregatorProxyWithAASXProvider.java new file mode 100644 index 00000000..ea56962e --- /dev/null +++ b/src/test/java/org/eclipse/basyx/testsuite/regression/extensions/aas/aggregator/aasxupload/TestAASAggregatorProxyWithAASXProvider.java @@ -0,0 +1,65 @@ +/******************************************************************************* +* Copyright (C) 2021 the Eclipse BaSyx Authors +* +* This program and the accompanying materials are made +* available under the terms of the Eclipse Public License 2.0 +* which is available at https://www.eclipse.org/legal/epl-2.0/ + +* +* SPDX-License-Identifier: EPL-2.0 +******************************************************************************/ + +package org.eclipse.basyx.testsuite.regression.extensions.aas.aggregator.aasxupload; + +import java.io.FileInputStream; +import java.io.IOException; +import java.nio.file.Paths; +import java.util.Collection; + +import org.apache.http.client.ClientProtocolException; +import org.eclipse.basyx.aas.aggregator.AASAggregator; +import org.eclipse.basyx.aas.aggregator.api.IAASAggregator; +import org.eclipse.basyx.aas.metamodel.api.IAssetAdministrationShell; +import org.eclipse.basyx.extensions.aas.aggregator.aasxupload.AASAggregatorAASXUpload; +import org.eclipse.basyx.extensions.aas.aggregator.aasxupload.proxy.AASAggregatorAASXUploadProxy; +import org.eclipse.basyx.extensions.aas.aggregator.aasxupload.restapi.AASAggregatorAASXUploadProvider; +import org.eclipse.basyx.testsuite.regression.aas.aggregator.TestAASAggregatorProxy; +import org.eclipse.basyx.testsuite.regression.vab.protocol.http.AASHTTPServerResource; +import org.eclipse.basyx.vab.modelprovider.api.IModelProvider; +import org.eclipse.basyx.vab.protocol.http.server.BaSyxContext; +import org.eclipse.basyx.vab.protocol.http.server.VABHTTPInterface; +import org.junit.Rule; +import org.junit.Test; + +/** + * Test for the {@link AASAggregatorAASXUploadProxy} + * + * @author haque + * + */ +public class TestAASAggregatorProxyWithAASXProvider extends TestAASAggregatorProxy { + private static final String SERVER = "localhost"; + private static final int PORT = 4000; + private static final String CONTEXT_PATH = "aggregator"; + private static final String API_URL = "http://" + SERVER + ":" + PORT + "/" + CONTEXT_PATH + "/shells"; + private AASAggregatorAASXUploadProvider provider = new AASAggregatorAASXUploadProvider(new AASAggregatorAASXUpload(new AASAggregator())); + + @Rule + public AASHTTPServerResource res = new AASHTTPServerResource( + new BaSyxContext("/" + CONTEXT_PATH, "", SERVER, PORT) + .addServletMapping("/*", new VABHTTPInterface(provider))); + + @Override + protected IAASAggregator getAggregator() { + return new AASAggregatorAASXUploadProxy(API_URL); + } + + @Test + public void testClientUpload() throws ClientProtocolException, IOException { + AASAggregatorAASXUploadProxy proxy = new AASAggregatorAASXUploadProxy(API_URL); + proxy.uploadAASX(new FileInputStream(Paths.get(TestAASAggregatorAASXUploadSuite.AASX_PATH).toFile())); + + Collection uploadedShells = proxy.getAASList(); + TestAASAggregatorAASXUploadSuite.checkAASX(uploadedShells); + } +} \ No newline at end of file diff --git a/src/test/java/org/eclipse/basyx/testsuite/regression/extensions/aas/aggregator/mqtt/TestMqttAASAggregatorObserver.java b/src/test/java/org/eclipse/basyx/testsuite/regression/extensions/aas/aggregator/mqtt/TestMqttAASAggregatorObserver.java new file mode 100644 index 00000000..5fe1e1a8 --- /dev/null +++ b/src/test/java/org/eclipse/basyx/testsuite/regression/extensions/aas/aggregator/mqtt/TestMqttAASAggregatorObserver.java @@ -0,0 +1,122 @@ +/******************************************************************************* + * Copyright (C) 2021 the Eclipse BaSyx Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + ******************************************************************************/ +package org.eclipse.basyx.testsuite.regression.extensions.aas.aggregator.mqtt; + +import org.eclipse.basyx.aas.metamodel.map.AssetAdministrationShell; + +import static org.junit.Assert.assertEquals; + +import java.io.IOException; + +import org.eclipse.basyx.aas.aggregator.AASAggregator; +import org.eclipse.basyx.aas.aggregator.api.IAASAggregator; +import org.eclipse.basyx.aas.aggregator.observing.ObservableAASAggregator; +import org.eclipse.basyx.aas.metamodel.api.parts.asset.AssetKind; +import org.eclipse.basyx.aas.metamodel.map.parts.Asset; +import org.eclipse.basyx.extensions.aas.aggregator.mqtt.MqttAASAggregatorObserver; +import org.eclipse.basyx.submodel.metamodel.api.identifier.IdentifierType; +import org.eclipse.basyx.submodel.metamodel.map.identifier.Identifier; +import org.eclipse.basyx.testsuite.regression.extensions.shared.mqtt.MqttTestListener; +import org.eclipse.paho.client.mqttv3.MqttException; +import org.junit.After; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; + +import io.moquette.broker.Server; +import io.moquette.broker.config.ClasspathResourceLoader; +import io.moquette.broker.config.IConfig; +import io.moquette.broker.config.IResourceLoader; +import io.moquette.broker.config.ResourceLoaderConfig; + +/** + * Tests events emitting with the MqttAASAggregatorObserver + * + * @author haque + * + */ +public class TestMqttAASAggregatorObserver { + protected AssetAdministrationShell shell; + private static final String AASID = "aasid1"; + private static final Identifier AASIDENTIFIER = new Identifier(IdentifierType.IRI, AASID); + + private static Server mqttBroker; + private static ObservableAASAggregator observedAPI; + private static MqttAASAggregatorObserver mqttObserver; + private MqttTestListener listener; + + /** + * Sets up the MQTT broker and ObservableAASAggregator for tests + */ + @BeforeClass + public static void setUpClass() throws MqttException, IOException { + // Start MQTT broker + mqttBroker = new Server(); + IResourceLoader classpathLoader = new ClasspathResourceLoader(); + final IConfig classPathConfig = new ResourceLoaderConfig(classpathLoader); + mqttBroker.startServer(classPathConfig); + + // Create underlying aas aggregator + IAASAggregator aggregator = new AASAggregator(); + observedAPI = new ObservableAASAggregator(aggregator); + + // Create mqtt as an observer + mqttObserver = new MqttAASAggregatorObserver("tcp://localhost:1884", "testClient"); + observedAPI.addObserver(mqttObserver); + } + + @AfterClass + public static void tearDownClass() { + mqttBroker.stopServer(); + } + + @Before + public void setUp() { + shell = new AssetAdministrationShell(AASID, AASIDENTIFIER, new Asset("assetid1", new Identifier(IdentifierType.IRI, "assetid1"), AssetKind.INSTANCE)); + observedAPI.createAAS(shell); + + listener = new MqttTestListener(); + mqttBroker.addInterceptHandler(listener); + } + + @After + public void tearDown() { + mqttBroker.removeInterceptHandler(listener); + } + + @Test + public void testCreateAAS() { + String aasId2 = "aas2"; + Identifier identifier2 = new Identifier(IdentifierType.IRDI, aasId2); + AssetAdministrationShell shell2 = new AssetAdministrationShell(aasId2, identifier2, new Asset("assetid2", new Identifier(IdentifierType.IRI, "assetid2"), AssetKind.INSTANCE)); + observedAPI.createAAS(shell2); + + assertEquals(aasId2, listener.lastPayload); + assertEquals(MqttAASAggregatorObserver.TOPIC_CREATEAAS, listener.lastTopic); + } + + @Test + public void testUpdateAAS() { + shell.setCategory("newCategory"); + observedAPI.updateAAS(shell); + + assertEquals(AASID, listener.lastPayload); + assertEquals(MqttAASAggregatorObserver.TOPIC_UPDATEAAS, listener.lastTopic); + } + + @Test + public void testDeleteAAS() { + observedAPI.deleteAAS(AASIDENTIFIER); + + assertEquals(AASID, listener.lastPayload); + assertEquals(MqttAASAggregatorObserver.TOPIC_DELETEAAS, listener.lastTopic); + } +} diff --git a/src/test/java/org/eclipse/basyx/testsuite/regression/extensions/aas/registration/mqtt/TestMqttAASRegistryServiceObserver.java b/src/test/java/org/eclipse/basyx/testsuite/regression/extensions/aas/registration/mqtt/TestMqttAASRegistryServiceObserver.java new file mode 100644 index 00000000..7692625e --- /dev/null +++ b/src/test/java/org/eclipse/basyx/testsuite/regression/extensions/aas/registration/mqtt/TestMqttAASRegistryServiceObserver.java @@ -0,0 +1,148 @@ +/******************************************************************************* + * Copyright (C) 2021 the Eclipse BaSyx Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + ******************************************************************************/ +package org.eclipse.basyx.testsuite.regression.extensions.aas.registration.mqtt; + +import static org.junit.Assert.assertEquals; + +import java.io.IOException; + +import org.eclipse.basyx.aas.metamodel.api.parts.asset.AssetKind; +import org.eclipse.basyx.aas.metamodel.map.AssetAdministrationShell; +import org.eclipse.basyx.aas.metamodel.map.descriptor.AASDescriptor; +import org.eclipse.basyx.aas.metamodel.map.descriptor.SubmodelDescriptor; +import org.eclipse.basyx.aas.metamodel.map.parts.Asset; +import org.eclipse.basyx.aas.registration.api.IAASRegistry; +import org.eclipse.basyx.aas.registration.memory.InMemoryRegistry; +import org.eclipse.basyx.aas.registration.observing.ObservableAASRegistryService; +import org.eclipse.basyx.extensions.aas.registration.mqtt.MqttAASRegistryServiceObserver; +import org.eclipse.basyx.submodel.metamodel.api.identifier.IdentifierType; +import org.eclipse.basyx.submodel.metamodel.map.Submodel; +import org.eclipse.basyx.submodel.metamodel.map.identifier.Identifier; +import org.eclipse.basyx.testsuite.regression.extensions.shared.mqtt.MqttTestListener; +import org.eclipse.paho.client.mqttv3.MqttException; +import org.junit.After; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; + +import io.moquette.broker.Server; +import io.moquette.broker.config.ClasspathResourceLoader; +import io.moquette.broker.config.IConfig; +import io.moquette.broker.config.IResourceLoader; +import io.moquette.broker.config.ResourceLoaderConfig; + +/** + * Tests events emitting with the MqttAASRegistryServiceObserver + * + * @author haque + * + */ +public class TestMqttAASRegistryServiceObserver { + + private static final String AASID = "aasid1"; + private static final String SUBMODELID = "submodelid1"; + private static final String AASENDPOINT = "http://localhost:8080/aasList/" + AASID + "/aas"; + private static final Identifier AASIDENTIFIER = new Identifier(IdentifierType.IRI, AASID); + private static final Identifier SUBMODELIDENTIFIER = new Identifier(IdentifierType.IRI, SUBMODELID); + + private static Server mqttBroker; + private static ObservableAASRegistryService observedAPI; + private static MqttAASRegistryServiceObserver mqttObserver; + private MqttTestListener listener; + + /** + * Sets up the MQTT broker and AASRegistryService for tests + */ + @BeforeClass + public static void setUpClass() throws MqttException, IOException { + // Start MQTT broker + mqttBroker = new Server(); + IResourceLoader classpathLoader = new ClasspathResourceLoader(); + final IConfig classPathConfig = new ResourceLoaderConfig(classpathLoader); + mqttBroker.startServer(classPathConfig); + + // Create underlying registry service + IAASRegistry registryService = new InMemoryRegistry(); + observedAPI = new ObservableAASRegistryService(registryService); + + mqttObserver = new MqttAASRegistryServiceObserver("tcp://localhost:1884", "testClient"); + observedAPI.addObserver(mqttObserver); + } + + @AfterClass + public static void tearDownClass() { + mqttBroker.stopServer(); + } + + @Before + public void setUp() { + AssetAdministrationShell shell = new AssetAdministrationShell(AASID, AASIDENTIFIER, new Asset("assetid1", new Identifier(IdentifierType.IRI, "assetid1"), AssetKind.INSTANCE)); + AASDescriptor aasDescriptor = new AASDescriptor(shell, AASENDPOINT); + observedAPI.register(aasDescriptor); + + Submodel submodel = new Submodel(SUBMODELID, SUBMODELIDENTIFIER); + String submodelEndpoint = AASENDPOINT + "/submodels/" + SUBMODELID + "/submodel"; + SubmodelDescriptor submodelDescriptor = new SubmodelDescriptor(submodel, submodelEndpoint); + observedAPI.register(AASIDENTIFIER, submodelDescriptor); + + listener = new MqttTestListener(); + mqttBroker.addInterceptHandler(listener); + } + + @After + public void tearDown() { + mqttBroker.removeInterceptHandler(listener); + } + + @Test + public void testRegisterAAS() { + String newAASId = "aasid2"; + Identifier newIdentifier = new Identifier(IdentifierType.IRI, newAASId); + AssetAdministrationShell shell = new AssetAdministrationShell(newAASId, newIdentifier, new Asset("assetid1", new Identifier(IdentifierType.IRI, "assetid2"), AssetKind.INSTANCE)); + String aasEndpoint = "http://localhost:8080/aasList/" + newAASId + "/aas"; + + AASDescriptor aasDescriptor = new AASDescriptor(shell, aasEndpoint); + observedAPI.register(aasDescriptor); + + assertEquals(newAASId, listener.lastPayload); + assertEquals(MqttAASRegistryServiceObserver.TOPIC_REGISTERAAS, listener.lastTopic); + } + + @Test + public void testRegisterSubmodel() { + String submodelid = "submodelid2"; + Identifier newSubmodelIdentifier = new Identifier(IdentifierType.IRI, submodelid); + Submodel submodel = new Submodel(submodelid, newSubmodelIdentifier); + String submodelEndpoint = AASENDPOINT + "/submodels/" + submodelid + "/submodel"; + SubmodelDescriptor submodelDescriptor = new SubmodelDescriptor(submodel, submodelEndpoint); + + observedAPI.register(AASIDENTIFIER, submodelDescriptor); + + assertEquals(MqttAASRegistryServiceObserver.concatAasSmId(AASIDENTIFIER, newSubmodelIdentifier), listener.lastPayload); + assertEquals(MqttAASRegistryServiceObserver.TOPIC_REGISTERSUBMODEL, listener.lastTopic); + } + + @Test + public void testDeleteAAS() { + observedAPI.delete(AASIDENTIFIER); + + assertEquals(AASID, listener.lastPayload); + assertEquals(MqttAASRegistryServiceObserver.TOPIC_DELETEAAS, listener.lastTopic); + } + + @Test + public void testDeleteSubmodel() { + observedAPI.delete(AASIDENTIFIER, SUBMODELIDENTIFIER); + + assertEquals(MqttAASRegistryServiceObserver.concatAasSmId(AASIDENTIFIER, SUBMODELIDENTIFIER), listener.lastPayload); + assertEquals(MqttAASRegistryServiceObserver.TOPIC_DELETESUBMODEL, listener.lastTopic); + } +} diff --git a/src/test/java/org/eclipse/basyx/testsuite/regression/extensions/submodel/mqtt/MqttSubmodelAPIObserverTest.java b/src/test/java/org/eclipse/basyx/testsuite/regression/extensions/submodel/mqtt/MqttSubmodelAPIObserverTest.java new file mode 100644 index 00000000..29e27689 --- /dev/null +++ b/src/test/java/org/eclipse/basyx/testsuite/regression/extensions/submodel/mqtt/MqttSubmodelAPIObserverTest.java @@ -0,0 +1,145 @@ +/******************************************************************************* + * Copyright (C) 2021 the Eclipse BaSyx Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + ******************************************************************************/ +package org.eclipse.basyx.testsuite.regression.extensions.submodel.mqtt; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; + +import java.io.IOException; + +import org.eclipse.basyx.extensions.submodel.mqtt.MqttSubmodelAPIObserver; +import org.eclipse.basyx.submodel.metamodel.api.identifier.IdentifierType; +import org.eclipse.basyx.submodel.metamodel.api.reference.enums.KeyElements; +import org.eclipse.basyx.submodel.metamodel.map.Submodel; +import org.eclipse.basyx.submodel.metamodel.map.identifier.Identifier; +import org.eclipse.basyx.submodel.metamodel.map.reference.Key; +import org.eclipse.basyx.submodel.metamodel.map.reference.Reference; +import org.eclipse.basyx.submodel.metamodel.map.submodelelement.SubmodelElementCollection; +import org.eclipse.basyx.submodel.metamodel.map.submodelelement.dataelement.property.Property; +import org.eclipse.basyx.submodel.restapi.observing.ObservableSubmodelAPI; +import org.eclipse.basyx.submodel.restapi.vab.VABSubmodelAPI; +import org.eclipse.basyx.testsuite.regression.extensions.shared.mqtt.MqttTestListener; +import org.eclipse.basyx.vab.modelprovider.map.VABMapProvider; +import org.eclipse.paho.client.mqttv3.MqttException; +import org.junit.After; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; + +import io.moquette.broker.Server; +import io.moquette.broker.config.ClasspathResourceLoader; +import io.moquette.broker.config.IConfig; +import io.moquette.broker.config.IResourceLoader; +import io.moquette.broker.config.ResourceLoaderConfig; + + +/** + * Test for MqttSubmodelAPIObserver + * + * @author espen, conradi + * + */ +public class MqttSubmodelAPIObserverTest { + private static final String AASID = "testaasid"; + private static final String SUBMODELID = "testsubmodelid"; + + private static Server mqttBroker; + private static ObservableSubmodelAPI observableAPI; + private MqttTestListener listener; + + /** + * Sets up the MQTT broker and submodelAPI for tests + */ + @BeforeClass + public static void setUpClass() throws MqttException, IOException { + // Start MQTT broker + mqttBroker = new Server(); + IResourceLoader classpathLoader = new ClasspathResourceLoader(); + final IConfig classPathConfig = new ResourceLoaderConfig(classpathLoader); + mqttBroker.startServer(classPathConfig); + + // Create submodel + Submodel sm = new Submodel(SUBMODELID, new Identifier(IdentifierType.CUSTOM, SUBMODELID)); + Reference parentRef = new Reference(new Key(KeyElements.ASSETADMINISTRATIONSHELL, true, AASID, IdentifierType.IRDI)); + sm.setParent(parentRef); + + VABSubmodelAPI vabAPI = new VABSubmodelAPI(new VABMapProvider(sm)); + observableAPI = new ObservableSubmodelAPI(vabAPI); + new MqttSubmodelAPIObserver(observableAPI, "tcp://localhost:1884", "testClient"); + } + + @AfterClass + public static void tearDownClass() { + mqttBroker.stopServer(); + } + + @Before + public void setUp() { + listener = new MqttTestListener(); + mqttBroker.addInterceptHandler(listener); + } + + @After + public void tearDown() { + mqttBroker.removeInterceptHandler(listener); + } + + @Test + public void testAddSubmodelElement() throws InterruptedException { + String elemIdShort = "testAddProp"; + Property prop = new Property(true); + prop.setIdShort(elemIdShort); + observableAPI.addSubmodelElement(prop); + + assertEquals(MqttSubmodelAPIObserver.getCombinedMessage(AASID, SUBMODELID, elemIdShort), listener.lastPayload); + assertEquals(MqttSubmodelAPIObserver.TOPIC_ADDELEMENT, listener.lastTopic); + } + + @Test + public void testAddNestedSubmodelElement() { + String idShortPath = "/testColl/testAddProp/"; + SubmodelElementCollection coll = new SubmodelElementCollection(); + coll.setIdShort("testColl"); + observableAPI.addSubmodelElement(coll); + + Property prop = new Property(true); + prop.setIdShort("testAddProp"); + observableAPI.addSubmodelElement(idShortPath, prop); + + assertEquals(MqttSubmodelAPIObserver.getCombinedMessage(AASID, SUBMODELID, idShortPath), listener.lastPayload); + assertEquals(MqttSubmodelAPIObserver.TOPIC_ADDELEMENT, listener.lastTopic); + } + + @Test + public void testDeleteSubmodelElement() { + String idShortPath = "/testDeleteProp"; + Property prop = new Property(true); + prop.setIdShort("testDeleteProp"); + observableAPI.addSubmodelElement(prop); + observableAPI.deleteSubmodelElement(idShortPath); + + assertEquals(MqttSubmodelAPIObserver.getCombinedMessage(AASID, SUBMODELID, idShortPath), listener.lastPayload); + assertEquals(MqttSubmodelAPIObserver.TOPIC_DELETEELEMENT, listener.lastTopic); + } + + @Test + public void testUpdateSubmodelElement() { + String idShortPath = "testUpdateProp"; + Property prop = new Property(true); + prop.setIdShort(idShortPath); + observableAPI.addSubmodelElement(prop); + observableAPI.updateSubmodelElement(idShortPath, false); + + assertFalse((boolean) observableAPI.getSubmodelElementValue(idShortPath)); + assertEquals(MqttSubmodelAPIObserver.getCombinedMessage(AASID, SUBMODELID, idShortPath), listener.lastPayload); + assertEquals(MqttSubmodelAPIObserver.TOPIC_UPDATEELEMENT, listener.lastTopic); + } +} diff --git a/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/metamodel/connected/TestConnectedSubmodel.java b/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/metamodel/connected/TestConnectedSubmodel.java index fa79a2ff..53d2d743 100644 --- a/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/metamodel/connected/TestConnectedSubmodel.java +++ b/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/metamodel/connected/TestConnectedSubmodel.java @@ -56,7 +56,6 @@ public class TestConnectedSubmodel extends TestSubmodelSuite { public void build() { Submodel reference = getReferenceSubmodel(); - // Create an operation Operation op = new Operation((Function & Serializable) obj -> { return (int) obj[0] + (int) obj[1]; }); @@ -95,7 +94,7 @@ public void operationsTest() throws Exception { // Check the operation itself IOperation op = ops.get(OP); - assertEquals(5, op.invoke(2, 3)); + assertEquals(5, op.invokeSimple(2, 3)); } @Test diff --git a/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/metamodel/connected/TestConnectedSubmodelElementCollection.java b/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/metamodel/connected/TestConnectedSubmodelElementCollection.java index 9dfc2d66..57b88286 100644 --- a/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/metamodel/connected/TestConnectedSubmodelElementCollection.java +++ b/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/metamodel/connected/TestConnectedSubmodelElementCollection.java @@ -10,6 +10,7 @@ package org.eclipse.basyx.testsuite.regression.submodel.metamodel.connected; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; import java.util.ArrayList; import java.util.Arrays; @@ -26,6 +27,7 @@ import org.eclipse.basyx.submodel.metamodel.map.Submodel; import org.eclipse.basyx.submodel.metamodel.map.submodelelement.SubmodelElementCollection; import org.eclipse.basyx.submodel.metamodel.map.submodelelement.dataelement.property.Property; +import org.eclipse.basyx.submodel.metamodel.map.submodelelement.dataelement.property.valuetype.ValueType; import org.eclipse.basyx.submodel.metamodel.map.submodelelement.operation.Operation; import org.eclipse.basyx.submodel.metamodel.map.submodelelement.operation.OperationVariable; import org.eclipse.basyx.submodel.restapi.SubmodelProvider; @@ -126,7 +128,7 @@ public void testOperation() throws Exception { IOperation sum = ops.get(OPERATION); // Check operation invocation - assertEquals(5, sum.invoke(2, 3)); + assertEquals(5, sum.invokeSimple(2, 3)); } @Test @@ -167,4 +169,24 @@ public void testAddSubmodelElement() { ISubmodelElement element = prop.getSubmodelElement(newId); assertEquals(newId, element.getIdShort()); } + + @Test + public void testGetValues() { + Map values = prop.getValues(); + assertEquals(1, values.size()); + assertTrue(values.containsKey(PROP)); + assertEquals(4, values.get(PROP)); + + String newKey = "newKey"; + String newValue = "newValue"; + + Property newProp = new Property(newKey, newValue); + newProp.setValueType(ValueType.String); + prop.addSubmodelElement(newProp); + + values = prop.getValues(); + assertEquals(2, values.size()); + assertTrue(values.containsKey(newKey)); + assertEquals(newValue, values.get(newKey)); + } } diff --git a/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/metamodel/connected/submodelelement/operation/TestConnectedOperationInput.java b/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/metamodel/connected/submodelelement/operation/TestConnectedOperationInput.java new file mode 100644 index 00000000..23323b7c --- /dev/null +++ b/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/metamodel/connected/submodelelement/operation/TestConnectedOperationInput.java @@ -0,0 +1,43 @@ +/******************************************************************************* + * Copyright (C) 2021 the Eclipse BaSyx Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + ******************************************************************************/ + +package org.eclipse.basyx.testsuite.regression.submodel.metamodel.connected.submodelelement.operation; + +import java.util.Map; + +import org.eclipse.basyx.submodel.metamodel.api.submodelelement.operation.IOperation; +import org.eclipse.basyx.submodel.metamodel.connected.submodelelement.operation.ConnectedOperation; +import org.eclipse.basyx.submodel.metamodel.map.submodelelement.operation.Operation; +import org.eclipse.basyx.submodel.restapi.OperationProvider; +import org.eclipse.basyx.testsuite.regression.submodel.metamodel.map.submodelelement.operation.TestOperationInputSuite; +import org.eclipse.basyx.testsuite.regression.vab.manager.VABConnectionManagerStub; +import org.eclipse.basyx.vab.manager.VABConnectionManager; +import org.eclipse.basyx.vab.modelprovider.map.VABMapProvider; +import org.eclipse.basyx.vab.support.TypeDestroyer; + +/** + * Tests inputs of {@link ConnectedOperation} for their correctness + * + * + * @author espen, fischer + * + */ +public class TestConnectedOperationInput extends TestOperationInputSuite { + + @Override + protected IOperation prepareOperation(Operation operation) { + Map destroyType = TypeDestroyer.destroyType(operation); + // Create a dummy connection manager containing the created Operation map + VABConnectionManager manager = new VABConnectionManagerStub(new OperationProvider(new VABMapProvider(destroyType))); + + // Create the ConnectedOperation based on the manager stub + return new ConnectedOperation(manager.connectToVABElement("")); + } +} diff --git a/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/metamodel/connected/submodelelement/operation/TestConnectedOperationParameter.java b/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/metamodel/connected/submodelelement/operation/TestConnectedOperationParameter.java new file mode 100644 index 00000000..22ac93d4 --- /dev/null +++ b/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/metamodel/connected/submodelelement/operation/TestConnectedOperationParameter.java @@ -0,0 +1,44 @@ +/******************************************************************************* + * Copyright (C) 2021 the Eclipse BaSyx Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + ******************************************************************************/ + +package org.eclipse.basyx.testsuite.regression.submodel.metamodel.connected.submodelelement.operation; + +import java.util.Map; + +import org.eclipse.basyx.submodel.metamodel.api.submodelelement.operation.IOperation; +import org.eclipse.basyx.submodel.metamodel.connected.submodelelement.operation.ConnectedOperation; +import org.eclipse.basyx.submodel.metamodel.map.submodelelement.operation.Operation; +import org.eclipse.basyx.submodel.restapi.OperationProvider; +import org.eclipse.basyx.testsuite.regression.submodel.metamodel.map.submodelelement.operation.TestOperationParameterSuite; +import org.eclipse.basyx.testsuite.regression.vab.manager.VABConnectionManagerStub; +import org.eclipse.basyx.vab.manager.VABConnectionManager; +import org.eclipse.basyx.vab.modelprovider.map.VABMapProvider; +import org.eclipse.basyx.vab.support.TypeDestroyer; + +/** + * Tests if a ConnectedOperation can be created and used correctly with all + * parameters + * + * + * @author espen, fischer + * + */ +public class TestConnectedOperationParameter extends TestOperationParameterSuite { + + @Override + protected IOperation prepareOperation(Operation operation) { + Map destroyType = TypeDestroyer.destroyType(operation); + // Create a dummy connection manager containing the created Operation map + VABConnectionManager manager = new VABConnectionManagerStub(new OperationProvider(new VABMapProvider(destroyType))); + + // Create the ConnectedOperation based on the manager stub + return new ConnectedOperation(manager.connectToVABElement("")); + } +} diff --git a/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/metamodel/map/qualifier/TestLangStrings.java b/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/metamodel/map/qualifier/TestLangStrings.java index 5a6fd7ec..bc75ce2a 100644 --- a/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/metamodel/map/qualifier/TestLangStrings.java +++ b/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/metamodel/map/qualifier/TestLangStrings.java @@ -100,4 +100,22 @@ public void testIsLangStrings() { assertFalse(LangStrings.isLangStrings(langStrings)); } + @Test + public void testFromStringPairs() { + LangStrings langStrings = LangStrings.fromStringPairs(LANGUAGE1, TEXT1, LANGUAGE2, TEXT2); + assertEquals(2, langStrings.getLanguages().size()); + assertEquals(TEXT1, langStrings.get(LANGUAGE1)); + assertEquals(TEXT2, langStrings.get(LANGUAGE2)); + } + + @Test + public void testFromStringPairsWithEmptyInput() { + LangStrings langStrings = LangStrings.fromStringPairs(); + assertEquals(0, langStrings.getLanguages().size()); + } + + @Test(expected = IllegalArgumentException.class) + public void testFromStringPairsWithOddNumber() { + LangStrings.fromStringPairs(LANGUAGE1); + } } diff --git a/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/metamodel/map/submodelelement/TestSubmodelElementCollection.java b/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/metamodel/map/submodelelement/TestSubmodelElementCollection.java index 27212433..ffb56771 100644 --- a/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/metamodel/map/submodelelement/TestSubmodelElementCollection.java +++ b/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/metamodel/map/submodelelement/TestSubmodelElementCollection.java @@ -13,12 +13,13 @@ import static org.junit.Assert.assertTrue; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; - +import java.util.stream.Collectors; import org.eclipse.basyx.submodel.metamodel.api.identifier.IdentifierType; import org.eclipse.basyx.submodel.metamodel.api.qualifier.haskind.ModelingKind; import org.eclipse.basyx.submodel.metamodel.api.reference.IReference; @@ -36,6 +37,7 @@ import org.eclipse.basyx.submodel.metamodel.map.reference.Reference; import org.eclipse.basyx.submodel.metamodel.map.submodelelement.SubmodelElementCollection; import org.eclipse.basyx.submodel.metamodel.map.submodelelement.dataelement.property.Property; +import org.eclipse.basyx.submodel.metamodel.map.submodelelement.dataelement.property.valuetype.ValueType; import org.eclipse.basyx.submodel.metamodel.map.submodelelement.operation.Operation; import org.eclipse.basyx.submodel.metamodel.map.submodelelement.operation.OperationVariable; import org.eclipse.basyx.vab.exception.provider.ResourceNotFoundException; @@ -123,6 +125,19 @@ public void testSetAllowDuplicates() { assertTrue(!elementCollection.isAllowDuplicates()); } + @Test + public void testKeepsOrderWhenOrdered() { + SubmodelElementCollection sec1 = new SubmodelElementCollection("sec1"); + sec1.setOrdered(true); + sec1.addSubmodelElement(new Property("id1", "blub1")); + sec1.addSubmodelElement(new Property("id2", "blub2")); + sec1.addSubmodelElement(new Property("id3", "blub3")); + sec1.addSubmodelElement(new Property("id4", "blub4")); + + List idShortsInOrder = sec1.getValue().stream().map(e -> e.getIdShort()).collect(Collectors.toList()); + assertEquals(Arrays.asList("id1","id2","id3","id4"), idShortsInOrder); + } + @Test public void testSetElements() { String idShort = "testIdShort"; @@ -196,6 +211,29 @@ public void testDeleteSubmodelElementNotExist() { SubmodelElementCollection collection = new SubmodelElementCollection(elements1, false, false); collection.deleteSubmodelElement("Id_Which_Does_Not_Exist"); } + + @Test + public void testGetValues() { + SubmodelElementCollection collection = new SubmodelElementCollection(elements1, false, false); + collection.setIdShort("smColl"); + Map elements = collection.getValues(); + Property property = getProperty(); + assertEquals(1, elements.size()); + assertTrue(elements.containsKey(PROPERTY_ID)); + assertEquals(property.getValue(), elements.get(PROPERTY_ID)); + + String newKey = "newKey"; + String newValue = "newValue"; + + Property property2 = new Property(newKey, newValue); + property2.setValueType(ValueType.String); + collection.addSubmodelElement(property2); + + elements = collection.getValues(); + assertEquals(2, elements.size()); + assertTrue(elements.containsKey(newKey)); + assertEquals(newValue, elements.get(newKey)); + } /** * Get a dummy property diff --git a/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/metamodel/map/submodelelement/operation/TestOperation.java b/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/metamodel/map/submodelelement/operation/TestOperation.java index de18ee71..bd15b061 100644 --- a/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/metamodel/map/submodelelement/operation/TestOperation.java +++ b/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/metamodel/map/submodelelement/operation/TestOperation.java @@ -10,7 +10,6 @@ package org.eclipse.basyx.testsuite.regression.submodel.metamodel.map.submodelelement.operation; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.fail; import java.util.ArrayList; import java.util.Collection; @@ -23,7 +22,6 @@ import org.eclipse.basyx.submodel.metamodel.api.submodelelement.operation.IOperation; import org.eclipse.basyx.submodel.metamodel.map.reference.Key; import org.eclipse.basyx.submodel.metamodel.map.reference.Reference; -import org.eclipse.basyx.submodel.metamodel.map.submodelelement.dataelement.property.Property; import org.eclipse.basyx.submodel.metamodel.map.submodelelement.operation.Operation; import org.junit.Test; @@ -35,59 +33,64 @@ * */ public class TestOperation extends TestOperationSuite { - + private static final String KEY_VALUE = "testKeyValue"; - + + private int setValueForTestSetNoInNoOutFunctionAsInvokable; + @Override protected IOperation prepareOperation(Operation operation) { return operation; } - + @Test public void testOptionalElements() throws Exception { - operation = new Operation(null, null, null, FUNC); - assertEquals(0, operation.getInputVariables().size()); - assertEquals(0, operation.getOutputVariables().size()); - assertEquals(0, operation.getInOutputVariables().size()); + simpleOperation = new Operation(null, null, null, SIMPLE_FUNC); + assertEquals(0, simpleOperation.getInputVariables().size()); + assertEquals(0, simpleOperation.getOutputVariables().size()); + assertEquals(0, simpleOperation.getInOutputVariables().size()); } - - @Test - public void testSetInvocable() throws Exception { - Operation operation = new Operation(IN, OUT, INOUT, FUNC); + + @Test + public void testSetFunctionAsInvokable() throws Exception { + Operation operation = new Operation(TWO_IN, OUT, INOUT, SIMPLE_FUNC); + operation.setIdShort("function"); assertEquals(5, operation.invoke(3, 2)); - + Function newFunction = (Function) v -> { - return (int)v[0] - (int)v[1]; + return (int) v[0] - (int) v[1]; }; operation.setInvokable(newFunction); - - assertEquals(1, operation.invoke(3,2)); + + assertEquals(1, operation.invoke(3, 2)); } - @Override @Test - public void testInvokeWithSubmodelElements() { - Property param1 = new Property("testIn1", 1); - Property param2 = new Property("testIn2", 1); - try { - operation.invoke(param1, param2); - // Only unwrapped invokation is supported for local operations - fail(); - } catch (UnsupportedOperationException e) { - } + public void testSetNoInNoOutFunctionAsInvokable() throws Exception { + Operation operation = new Operation("noInNoOutFunction"); + setValueForTestSetNoInNoOutFunctionAsInvokable = 0; + int expected = 10; + + operation.setInvokable(() -> { + setValueForTestSetNoInNoOutFunctionAsInvokable = expected; + }); + + operation.invokeSimple(); + + assertEquals(expected, setValueForTestSetNoInNoOutFunctionAsInvokable); } @Test public void testSetDataSpecificationReferences() { - Operation operation = new Operation(IN, OUT, INOUT, FUNC); + Operation operation = new Operation(TWO_IN, OUT, INOUT, SIMPLE_FUNC); Collection references = Collections.singleton(new Reference(new Key(KeyElements.ASSET, true, KEY_VALUE, IdentifierType.IRI))); operation.setDataSpecificationReferences(references); - + Collection newReferences = operation.getDataSpecificationReferences(); assertEquals(1, newReferences.size()); - + IReference newReference = new ArrayList<>(newReferences).get(0); - + assertEquals(KEY_VALUE, newReference.getKeys().get(0).getValue()); } } diff --git a/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/metamodel/map/submodelelement/operation/TestOperationInput.java b/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/metamodel/map/submodelelement/operation/TestOperationInput.java new file mode 100644 index 00000000..e2346db1 --- /dev/null +++ b/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/metamodel/map/submodelelement/operation/TestOperationInput.java @@ -0,0 +1,28 @@ +/******************************************************************************* + * Copyright (C) 2021 the Eclipse BaSyx Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + ******************************************************************************/ + +package org.eclipse.basyx.testsuite.regression.submodel.metamodel.map.submodelelement.operation; + +import org.eclipse.basyx.submodel.metamodel.api.submodelelement.operation.IOperation; +import org.eclipse.basyx.submodel.metamodel.map.submodelelement.operation.Operation; + +/** + * Tests inputs of {@link Operation} for their correctness + * + * @author espen, fischer + * + */ +public class TestOperationInput extends TestOperationInputSuite { + + @Override + protected IOperation prepareOperation(Operation operation) { + return operation; + } +} diff --git a/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/metamodel/map/submodelelement/operation/TestOperationInputSuite.java b/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/metamodel/map/submodelelement/operation/TestOperationInputSuite.java new file mode 100644 index 00000000..8921176d --- /dev/null +++ b/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/metamodel/map/submodelelement/operation/TestOperationInputSuite.java @@ -0,0 +1,217 @@ +/******************************************************************************* + * Copyright (C) 2021 the Eclipse BaSyx Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + ******************************************************************************/ + +package org.eclipse.basyx.testsuite.regression.submodel.metamodel.map.submodelelement.operation; + +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Map; +import java.util.function.Function; + +import org.eclipse.basyx.submodel.metamodel.api.qualifier.haskind.ModelingKind; +import org.eclipse.basyx.submodel.metamodel.api.reference.enums.KeyElements; +import org.eclipse.basyx.submodel.metamodel.api.reference.enums.KeyType; +import org.eclipse.basyx.submodel.metamodel.api.submodelelement.operation.IOperation; +import org.eclipse.basyx.submodel.metamodel.map.reference.Key; +import org.eclipse.basyx.submodel.metamodel.map.reference.Reference; +import org.eclipse.basyx.submodel.metamodel.map.submodelelement.SubmodelElement; +import org.eclipse.basyx.submodel.metamodel.map.submodelelement.dataelement.property.Property; +import org.eclipse.basyx.submodel.metamodel.map.submodelelement.dataelement.property.valuetype.ValueType; +import org.eclipse.basyx.submodel.metamodel.map.submodelelement.operation.Operation; +import org.eclipse.basyx.submodel.metamodel.map.submodelelement.operation.OperationVariable; +import org.eclipse.basyx.submodel.metamodel.map.submodelelement.relationship.RelationshipElement; +import org.eclipse.basyx.vab.exception.provider.MalformedRequestException; +import org.junit.Before; +import org.junit.Test; + +/** + * Tests for different parameters in operations with submodelElements. + * + * @author espen, fischer + * + */ +public abstract class TestOperationInputSuite { + protected static final String IN_VALUE = "inValue"; + protected static final String IN_IDSHORT1 = "testIn1"; + protected static final String IN_IDSHORT2 = "testIn2"; + protected static final String OUT_VALUE = "outValue"; + protected static final String OUT_IDSHORT = "testOut"; + protected static final String INOUT_VALUE = "inOutValue"; + protected static final String INOUT_IDSHORT = "testInOut"; + protected static final String RELATIONSHIP_ID_SHORT = "relationshipIdShort"; + protected static final String RELATIONSHIP_FIRST_REFERENCE_ID_SHORT = "firstId"; + protected static final String RELATIONSHIP_SECOND_REFERENCE_ID_SHORT = "secondId"; + protected static final String OPERATION_ID_SHORT = "operationIdShort"; + protected static final String OPERATION_PROPERTY_ID_SHORT = "propertyId"; + + protected static Collection TWO_IN; + protected static Collection OUT; + protected static Collection INOUT; + + protected static final Function, SubmodelElement[]> OPERATION_FUNC = (Function, SubmodelElement[]>) inputMap -> { + return new SubmodelElement[] { inputMap.get(OPERATION_ID_SHORT) }; + }; + + protected static final Function SIMPLE_FUNC = (Function) v -> { + return (int) v[0] + (int) v[1]; + }; + + protected IOperation simpleOperation; + protected IOperation operationOperation; + + /** + * Converts an Operation into the IOperation to be tested + */ + protected abstract IOperation prepareOperation(Operation operation); + + @Before + public void setup() { + TWO_IN = createTwoInputVariables(); + OUT = createOutputVariables(); + INOUT = createInOutVariables(); + + operationOperation = createOperationOperation(); + simpleOperation = createAddOperation(); + } + + private IOperation createAddOperation() { + Operation op = new Operation(TWO_IN, OUT, INOUT, SIMPLE_FUNC); + op.setIdShort("SimpleOperation"); + return prepareOperation(op); + } + + private IOperation createOperationOperation() { + Operation op = new Operation("operation"); + op.setInputVariables(createOperationOperationVariable()); + op.setOutputVariables(createOperationOperationVariable()); + op.setWrappedInvokable(OPERATION_FUNC); + + return prepareOperation(op); + } + + private Collection createOperationOperationVariable() { + Operation operation = createOperationForOperationInputVariables(); + + OperationVariable operationVariable = new OperationVariable(operation); + + return Arrays.asList(operationVariable); + } + + private Operation createOperationForOperationInputVariables() { + Property p1 = new Property(OPERATION_PROPERTY_ID_SHORT, ""); + OperationVariable opV = new OperationVariable(p1); + + Operation operation = new Operation("inputOperation"); + operation.setInputVariables(Arrays.asList(opV)); + operation.setIdShort(OPERATION_ID_SHORT); + operation.setModelingKind(ModelingKind.TEMPLATE); + + return operation; + } + + private RelationshipElement createRelationshipElementAsInput(String idShort) { + Reference firstReference = new Reference(new Key(KeyElements.RELATIONSHIPELEMENT, false, RELATIONSHIP_FIRST_REFERENCE_ID_SHORT, KeyType.IDSHORT)); + Reference secondReference = new Reference(new Key(KeyElements.RELATIONSHIPELEMENT, false, RELATIONSHIP_SECOND_REFERENCE_ID_SHORT, KeyType.IDSHORT)); + RelationshipElement relationshipElement = new RelationshipElement(idShort, firstReference, secondReference); + + return relationshipElement; + } + + private Collection createInOutVariables() { + Property inOutProp = new Property(INOUT_IDSHORT, INOUT_VALUE); + inOutProp.setValueType(ValueType.Integer); + inOutProp.setModelingKind(ModelingKind.TEMPLATE); + return Arrays.asList(new OperationVariable(inOutProp)); + } + + private Collection createOutputVariables() { + Property outProp = new Property(OUT_IDSHORT, OUT_VALUE); + outProp.setValueType(ValueType.Integer); + outProp.setModelingKind(ModelingKind.TEMPLATE); + + return Arrays.asList(new OperationVariable(outProp)); + } + + private Collection createTwoInputVariables() { + Property inProp1 = new Property(IN_IDSHORT1, IN_VALUE); + inProp1.setModelingKind(ModelingKind.TEMPLATE); + + Property inProp2 = new Property(IN_IDSHORT2, IN_VALUE); + inProp2.setModelingKind(ModelingKind.TEMPLATE); + + return Arrays.asList(new OperationVariable(inProp1), new OperationVariable(inProp2)); + } + + @Test + public void testInvokeWithInvalidIdShortAndModelType() { + RelationshipElement input = createRelationshipElementAsInput(RELATIONSHIP_ID_SHORT); + + try { + operationOperation.invoke(input); + fail(); + } catch (Exception e) { + // Exceptions from ConnectedOperation are wrapped in ProviderException + assertTrue(e instanceof MalformedRequestException || e.getCause() instanceof MalformedRequestException); + } + } + + @Test + public void testInvokeWithInvalidModelType() { + RelationshipElement input = createRelationshipElementAsInput(OPERATION_ID_SHORT); + + try { + operationOperation.invoke(input); + fail(); + } catch (Exception e) { + // Exceptions from ConnectedOperation are wrapped in ProviderException + assertTrue(e instanceof MalformedRequestException || e.getCause() instanceof MalformedRequestException); + } + } + + @Test + public void testInvokeWithInvalidSubmodelElementType() { + Property invalidInput = new Property(OPERATION_ID_SHORT, 2); + + try { + operationOperation.invoke(invalidInput); + fail(); + } catch (Exception e) { + // Exceptions from ConnectedOperation are wrapped in ProviderException + assertTrue(e instanceof MalformedRequestException || e.getCause() instanceof MalformedRequestException); + } + } + + @Test + public void testSimpleInvokeWithInvalidSubmodelElementType() { + try { + operationOperation.invokeSimple("10"); + fail(); + } catch (Exception e) { + // Exceptions from ConnectedOperation are wrapped in ProviderException + assertTrue(e instanceof MalformedRequestException || e.getCause() instanceof MalformedRequestException); + } + } + + @Test + public void testInvokeSimpleOperationWithInvalidSubmodelElementType() { + RelationshipElement input = createRelationshipElementAsInput(RELATIONSHIP_ID_SHORT); + + try { + simpleOperation.invoke(input, input); + fail(); + } catch (Exception e) { + // Exceptions from ConnectedOperation are wrapped in ProviderException + assertTrue(e instanceof MalformedRequestException || e.getCause() instanceof MalformedRequestException); + } + } +} diff --git a/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/metamodel/map/submodelelement/operation/TestOperationParameter.java b/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/metamodel/map/submodelelement/operation/TestOperationParameter.java new file mode 100644 index 00000000..e36c7006 --- /dev/null +++ b/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/metamodel/map/submodelelement/operation/TestOperationParameter.java @@ -0,0 +1,29 @@ +/******************************************************************************* + * Copyright (C) 2021 the Eclipse BaSyx Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + ******************************************************************************/ + +package org.eclipse.basyx.testsuite.regression.submodel.metamodel.map.submodelelement.operation; + +import org.eclipse.basyx.submodel.metamodel.api.submodelelement.operation.IOperation; +import org.eclipse.basyx.submodel.metamodel.map.submodelelement.operation.Operation; + +/** + * Tests constructor, getter and setter of {@link Operation} for their + * correctness + * + * @author espen, fischer + * + */ +public class TestOperationParameter extends TestOperationParameterSuite { + + @Override + protected IOperation prepareOperation(Operation operation) { + return operation; + } +} diff --git a/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/metamodel/map/submodelelement/operation/TestOperationParameterSuite.java b/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/metamodel/map/submodelelement/operation/TestOperationParameterSuite.java new file mode 100644 index 00000000..3175cfc5 --- /dev/null +++ b/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/metamodel/map/submodelelement/operation/TestOperationParameterSuite.java @@ -0,0 +1,175 @@ +/******************************************************************************* + * Copyright (C) 2021 the Eclipse BaSyx Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + ******************************************************************************/ + +package org.eclipse.basyx.testsuite.regression.submodel.metamodel.map.submodelelement.operation; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import org.eclipse.basyx.submodel.metamodel.api.qualifier.haskind.ModelingKind; +import org.eclipse.basyx.submodel.metamodel.api.reference.IKey; +import org.eclipse.basyx.submodel.metamodel.api.reference.enums.KeyElements; +import org.eclipse.basyx.submodel.metamodel.api.reference.enums.KeyType; +import org.eclipse.basyx.submodel.metamodel.api.submodelelement.operation.IOperation; +import org.eclipse.basyx.submodel.metamodel.map.reference.Key; +import org.eclipse.basyx.submodel.metamodel.map.reference.Reference; +import org.eclipse.basyx.submodel.metamodel.map.submodelelement.SubmodelElement; +import org.eclipse.basyx.submodel.metamodel.map.submodelelement.dataelement.property.Property; +import org.eclipse.basyx.submodel.metamodel.map.submodelelement.operation.Operation; +import org.eclipse.basyx.submodel.metamodel.map.submodelelement.operation.OperationVariable; +import org.eclipse.basyx.submodel.metamodel.map.submodelelement.relationship.RelationshipElement; +import org.junit.Before; +import org.junit.Test; + +/** + * Tests for different parameters in operations with submodelElements. + * + * @author espen, fischer + * + */ +public abstract class TestOperationParameterSuite { + protected static final String RELATIONSHIP_ID_SHORT = "relationshipIdShort"; + protected static final String RELATIONSHIP_FIRST_REFERENCE_ID_SHORT = "firstId"; + protected static final String RELATIONSHIP_SECOND_REFERENCE_ID_SHORT = "secondId"; + protected static final String OPERATION_ID_SHORT = "operationIdShort"; + protected static final String OPERATION_PROPERTY_ID_SHORT = "propertyId"; + + protected static final Function, SubmodelElement[]> RELATIONSHIP_FUNC = (Function, SubmodelElement[]>) inputMap -> { + return new SubmodelElement[] { inputMap.get(RELATIONSHIP_ID_SHORT) }; + }; + + protected static final Function, SubmodelElement[]> OPERATION_FUNC = (Function, SubmodelElement[]>) inputMap -> { + return new SubmodelElement[] { inputMap.get(OPERATION_ID_SHORT) }; + }; + + protected IOperation relationshipOperation; + protected IOperation operationOperation; + + /** + * Converts an Operation into the IOperation to be tested + */ + protected abstract IOperation prepareOperation(Operation operation); + + @Before + public void setup() { + relationshipOperation = createRelationshipOperation(); + operationOperation = createOperationOperation(); + } + + protected IOperation createOperationOperation() { + Operation op = new Operation("operation"); + op.setInputVariables(createOperationOperationVariable()); + op.setOutputVariables(createOperationOperationVariable()); + op.setWrappedInvokable(OPERATION_FUNC); + + return prepareOperation(op); + } + + protected IOperation createRelationshipOperation() { + Operation op = new Operation("relationship"); + op.setInputVariables(createRelationshipOperationVariable()); + op.setOutputVariables(createRelationshipOperationVariable()); + op.setWrappedInvokable(RELATIONSHIP_FUNC); + + return prepareOperation(op); + } + + private Collection createRelationshipOperationVariable() { + RelationshipElement relationshipElementAsOperationVariable = createRelationshipElementForOperationInputVariables(); + + OperationVariable relationshipElementVariable = new OperationVariable(relationshipElementAsOperationVariable); + + return Arrays.asList(relationshipElementVariable); + } + + private RelationshipElement createRelationshipElementForOperationInputVariables() { + Reference firstReference = new Reference(new Key(KeyElements.RELATIONSHIPELEMENT, false, "", KeyType.IDSHORT)); + Reference secondReference = new Reference(new Key(KeyElements.RELATIONSHIPELEMENT, false, "", KeyType.IDSHORT)); + RelationshipElement relationshipElementAsOperationVariable = new RelationshipElement(RELATIONSHIP_ID_SHORT, firstReference, secondReference); + + relationshipElementAsOperationVariable.setModelingKind(ModelingKind.TEMPLATE); + + return relationshipElementAsOperationVariable; + } + + private Collection createOperationOperationVariable() { + Operation operation = createOperationForOperationInputVariables(); + + OperationVariable operationVariable = new OperationVariable(operation); + + return Arrays.asList(operationVariable); + } + + private Operation createOperationForOperationInputVariables() { + Property p1 = new Property(OPERATION_PROPERTY_ID_SHORT, ""); + OperationVariable opV = new OperationVariable(p1); + + Operation operation = new Operation("inputOperation"); + operation.setInputVariables(Arrays.asList(opV)); + operation.setIdShort(OPERATION_ID_SHORT); + operation.setModelingKind(ModelingKind.TEMPLATE); + + return operation; + } + + @Test + public void testInvokeSubmodelElementWithValues() { + RelationshipElement input = createRelationshipElementAsInput(); + SubmodelElement[] responseArray = relationshipOperation.invoke(input); + RelationshipElement responseRelationship = (RelationshipElement) responseArray[0]; + checkRelationshipElementOutput(responseRelationship); + } + + private RelationshipElement createRelationshipElementAsInput() { + Reference firstReference = new Reference(new Key(KeyElements.RELATIONSHIPELEMENT, false, RELATIONSHIP_FIRST_REFERENCE_ID_SHORT, KeyType.IDSHORT)); + Reference secondReference = new Reference(new Key(KeyElements.RELATIONSHIPELEMENT, false, RELATIONSHIP_SECOND_REFERENCE_ID_SHORT, KeyType.IDSHORT)); + RelationshipElement relationshipElement = new RelationshipElement(RELATIONSHIP_ID_SHORT, firstReference, secondReference); + + return relationshipElement; + } + + private void checkRelationshipElementOutput(RelationshipElement responseRelationship) { + List firstReferenceKeys = responseRelationship.getFirst().getKeys(); + List secondReferenceKeys = responseRelationship.getSecond().getKeys(); + + IKey lastKeyFirstReference = firstReferenceKeys.get(firstReferenceKeys.size() - 1); + IKey lastKeySecondReference = secondReferenceKeys.get(firstReferenceKeys.size() - 1); + + assertEquals(RELATIONSHIP_ID_SHORT, responseRelationship.getIdShort()); + assertEquals(RELATIONSHIP_FIRST_REFERENCE_ID_SHORT, lastKeyFirstReference.getValue()); + assertEquals(RELATIONSHIP_SECOND_REFERENCE_ID_SHORT, lastKeySecondReference.getValue()); + } + + @Test + public void testInvokeSubmodelElementWithoutValues() { + Operation input = createOperationAsInput(); + SubmodelElement[] responseArray = operationOperation.invoke(input); + Operation responseOperation = (Operation) responseArray[0]; + checkOperationOutput(responseOperation); + } + + private Operation createOperationAsInput() { + Operation operation = new Operation("inputOperation"); + operation.setIdShort(OPERATION_ID_SHORT); + + return operation; + } + + private void checkOperationOutput(Operation responseOperation) { + assertEquals(OPERATION_ID_SHORT, responseOperation.getIdShort()); + assertTrue(responseOperation.getInputVariables().isEmpty()); + } +} diff --git a/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/metamodel/map/submodelelement/operation/TestOperationSuite.java b/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/metamodel/map/submodelelement/operation/TestOperationSuite.java index 6eeb59dc..72059f8c 100644 --- a/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/metamodel/map/submodelelement/operation/TestOperationSuite.java +++ b/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/metamodel/map/submodelelement/operation/TestOperationSuite.java @@ -1,10 +1,10 @@ /******************************************************************************* * Copyright (C) 2021 the Eclipse BaSyx Authors - * + * * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 * which is available at https://www.eclipse.org/legal/epl-2.0/ - * + * * SPDX-License-Identifier: EPL-2.0 ******************************************************************************/ package org.eclipse.basyx.testsuite.regression.submodel.metamodel.map.submodelelement.operation; @@ -15,8 +15,12 @@ import static org.junit.Assert.fail; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; +import java.util.Map; +import java.util.function.Consumer; import java.util.function.Function; +import java.util.function.Supplier; import org.eclipse.basyx.submodel.metamodel.api.qualifier.haskind.ModelingKind; import org.eclipse.basyx.submodel.metamodel.api.submodelelement.operation.IAsyncInvocation; @@ -34,100 +38,318 @@ /** * Tests for IOperation - * - * @author conradi + * + * @author conradi, fischer * */ public abstract class TestOperationSuite { - protected static final String IN_VALUE = "inValue"; + protected static final String IN_IDSHORT1 = "testIn1"; + protected static final String IN_IDSHORT2 = "testIn2"; protected static final String OUT_VALUE = "outValue"; + protected static final String OUT_IDSHORT = "testOut"; protected static final String INOUT_VALUE = "inOutValue"; - protected static Collection IN; + protected static final String INOUT_IDSHORT = "testInOut"; + protected static final String SUPPLIER_RETURN_VALUE = "10"; + protected static final boolean RUNNABLE_FLAG = true; + + protected static Collection ONE_IN; + protected static Collection TWO_IN; protected static Collection OUT; protected static Collection INOUT; - - protected static final Function FUNC = (Function) v -> { - return (int)v[0] + (int)v[1]; + protected static int expectedResultForSimpleConsumerTest; + protected static int expectedResultForSimpleConsumerWithPropertiesTest; + protected static int expectedResultForPropertyConsumerTest; + protected static int expectedResultForPropertyConsumerWithPropertiesTest; + + protected static boolean expectedResultForSimpleRunnableTest; + protected static boolean expectedResultForSimpleRunnableWithPropertiesTest; + protected static SubmodelElement[] expectedResultForPropertyRunnableTest; + + protected static final Function, SubmodelElement[]> PROPERTY_FUNC = (Function, SubmodelElement[]>) inputMap -> { + Property p1 = (Property) inputMap.get(IN_IDSHORT1); + Property p2 = (Property) inputMap.get(IN_IDSHORT2); + int value1 = (int) p1.getValue(); + int value2 = (int) p2.getValue(); + + int resultValue = value2 - value1; + return new SubmodelElement[] { new Property(OUT_IDSHORT, resultValue) }; + }; + + protected static final Function SIMPLE_FUNC = (Function) v -> { + return (int) v[0] + (int) v[1]; }; - + protected static final Function EXCEPTION_FUNC = (Function) v -> { throw new NullPointerException(); }; - - protected IOperation operation; - protected IOperation operationException; + + protected static final Function, SubmodelElement[]> PROPERTY_EXCEPTION_FUNC = (Function, SubmodelElement[]>) inputMap -> { + throw new NullPointerException(); + }; + + protected static final Supplier SIMPLE_SUPPLIER_FUNC = (Supplier) () -> SUPPLIER_RETURN_VALUE; + + protected static final Supplier PROPERTY_SUPPLIER_FUNC = (Supplier) () -> { + return new SubmodelElement[] { new Property(OUT_IDSHORT, SUPPLIER_RETURN_VALUE) }; + }; + + protected static final Consumer SIMPLE_CONSUMER_FUNC = (Consumer) (simpleInput) -> { + expectedResultForSimpleConsumerTest = (Integer) simpleInput[0]; + }; + + protected static final Consumer SIMPLE_CONSUMER_WITH_PROPERTIES_FUNC = (Consumer) (simpleInput) -> { + expectedResultForSimpleConsumerWithPropertiesTest = (Integer) simpleInput[0]; + }; + + protected static final Consumer> PROPERTY_CONSUMER_FUNC = (Consumer>) inputMap -> { + expectedResultForPropertyConsumerTest = (Integer) inputMap.get(IN_IDSHORT1).getValue(); + }; + + protected static final Consumer> PROPERTY_CONSUMER_WITH_PROPERTIES_FUNC = (Consumer>) inputMap -> { + expectedResultForPropertyConsumerWithPropertiesTest = (Integer) inputMap.get(IN_IDSHORT1).getValue(); + }; + + protected static final Runnable SIMPLE_RUNNABLE_FUNC = () -> { + expectedResultForSimpleRunnableTest = RUNNABLE_FLAG; + }; + + protected static final Runnable SIMPLE_RUNNABLE_WITH_PROPERTIES_FUNC = () -> { + expectedResultForSimpleRunnableWithPropertiesTest = RUNNABLE_FLAG; + }; + + protected IOperation simpleOperation; + protected IOperation propertyOperation; + protected IOperation simpleOperationException; + protected IOperation propertyOperationException; + protected IOperation simpleSupplierOperation; + protected IOperation propertySupplierOperation; + protected IOperation simpleConsumerOperation; + protected IOperation simpleConsumerOperationWithProperties; + protected IOperation propertyConsumerOperation; + protected IOperation propertyConsumerOperationWithProperties; + protected IOperation simpleRunnableOperation; + protected IOperation simpleRunnableOperationWithProperties; /** * Converts an Operation into the IOperation to be tested */ protected abstract IOperation prepareOperation(Operation operation); - + @Before public void setup() { - IN = new ArrayList(); - OUT = new ArrayList(); - INOUT = new ArrayList(); - Property inProp1 = new Property("testIn1", IN_VALUE); + ONE_IN = createOneInputVariables(); + TWO_IN = createTwoInputVariables(); + OUT = createOutputVariables(); + INOUT = createInOutVariables(); + + propertyOperation = createPropertyOperation(); + propertyOperationException = createPropertyExceptionOperation(); + simpleOperation = createAddOperation(); + simpleOperationException = createSimpleExceptionOperation(); + simpleSupplierOperation = createSimpleSupplierOperation(); + propertySupplierOperation = createPropertySupplierOperation(); + simpleConsumerOperation = createSimpleConsumerOperation(); + simpleConsumerOperationWithProperties = createSimpleConsumerOperationWithProperties(); + propertyConsumerOperation = createPropertyConsumerOperation(); + propertyConsumerOperationWithProperties = createPropertyConsumerOperationWithProperties(); + simpleRunnableOperation = createSimpleRunnableOperation(); + simpleRunnableOperationWithProperties = createSimpleRunnableOperationWithProperties(); + } + + private IOperation createPropertyOperation() { + Operation op = new Operation(TWO_IN, OUT, INOUT); + op.setWrappedInvokable(PROPERTY_FUNC); + op.setIdShort("PropertyOperation"); + return prepareOperation(op); + } + + private IOperation createPropertyExceptionOperation() { + Operation op = new Operation(TWO_IN, OUT, INOUT); + op.setWrappedInvokable(PROPERTY_EXCEPTION_FUNC); + op.setIdShort("PropertyExceptionOperation"); + return prepareOperation(op); + } + + private IOperation createAddOperation() { + Operation op = new Operation(TWO_IN, OUT, INOUT, SIMPLE_FUNC); + op.setIdShort("SimpleOperation"); + return prepareOperation(op); + } + + private IOperation createSimpleExceptionOperation() { + Operation op = new Operation(TWO_IN, OUT, INOUT, EXCEPTION_FUNC); + op.setIdShort("SimpleExceptionOperation"); + return prepareOperation(op); + } + + private IOperation createSimpleSupplierOperation() { + Operation op = new Operation("SimpleSupplier"); + op.setOutputVariables(OUT); + op.setInvokable(SIMPLE_SUPPLIER_FUNC); + + return prepareOperation(op); + } + + private IOperation createPropertySupplierOperation() { + Operation op = new Operation("PropertySupplier"); + op.setOutputVariables(OUT); + op.setWrappedInvokable(PROPERTY_SUPPLIER_FUNC); + + return prepareOperation(op); + } + + private IOperation createSimpleConsumerOperation() { + Operation op = new Operation("consumer"); + op.setInputVariables(ONE_IN); + op.setInvokable(SIMPLE_CONSUMER_FUNC); + + return prepareOperation(op); + } + + private IOperation createSimpleConsumerOperationWithProperties() { + Operation op = new Operation("SimpleConsumerWithProperties"); + op.setInputVariables(ONE_IN); + op.setInvokable(SIMPLE_CONSUMER_WITH_PROPERTIES_FUNC); + + return prepareOperation(op); + } + + private IOperation createPropertyConsumerOperation() { + Operation op = new Operation("PropertyConsumer"); + op.setInputVariables(ONE_IN); + op.setWrappedInvokable(PROPERTY_CONSUMER_FUNC); + + return prepareOperation(op); + } + + private IOperation createPropertyConsumerOperationWithProperties() { + Operation op = new Operation("PropertyConsumerWithProperties"); + op.setInputVariables(ONE_IN); + op.setWrappedInvokable(PROPERTY_CONSUMER_WITH_PROPERTIES_FUNC); + + return prepareOperation(op); + } + + private IOperation createSimpleRunnableOperation() { + Operation op = new Operation("SimpleRunnable"); + op.setInvokable(SIMPLE_RUNNABLE_FUNC); + + return prepareOperation(op); + } + + private IOperation createSimpleRunnableOperationWithProperties() { + Operation op = new Operation("SimpleRunnableWithProperties"); + op.setInvokable(SIMPLE_RUNNABLE_WITH_PROPERTIES_FUNC); + + return prepareOperation(op); + } + + private Collection createInOutVariables() { + Property inOutProp = new Property(INOUT_IDSHORT, INOUT_VALUE); + inOutProp.setModelingKind(ModelingKind.TEMPLATE); + return Arrays.asList(new OperationVariable(inOutProp)); + } + + private Collection createOutputVariables() { + Property outProp = new Property(OUT_IDSHORT, OUT_VALUE); + outProp.setModelingKind(ModelingKind.TEMPLATE); + return Arrays.asList(new OperationVariable(outProp)); + } + + private Collection createOneInputVariables() { + Property inProp1 = new Property(IN_IDSHORT1, IN_VALUE); inProp1.setModelingKind(ModelingKind.TEMPLATE); - Property inProp2 = new Property("testIn2", IN_VALUE); + + return Arrays.asList(new OperationVariable(inProp1)); + } + + private Collection createTwoInputVariables() { + Property inProp1 = new Property(IN_IDSHORT1, IN_VALUE); + inProp1.setModelingKind(ModelingKind.TEMPLATE); + + Property inProp2 = new Property(IN_IDSHORT2, IN_VALUE); inProp2.setModelingKind(ModelingKind.TEMPLATE); - Property outProp = new Property("testId2", OUT_VALUE); - outProp.setModelingKind(ModelingKind.TEMPLATE); - Property inOutProp = new Property("testId3", INOUT_VALUE); - inOutProp.setModelingKind(ModelingKind.TEMPLATE); - IN.add(new OperationVariable(inProp1)); - IN.add(new OperationVariable(inProp2)); - OUT.add(new OperationVariable(outProp)); - INOUT.add(new OperationVariable(inOutProp)); - - Operation op1 = new Operation(IN, OUT, INOUT, FUNC); - op1.setIdShort("op1"); - operation = prepareOperation(op1); - Operation op2 = new Operation(IN, OUT, INOUT, EXCEPTION_FUNC); - op2.setIdShort("op2"); - operationException = prepareOperation(op2); + return Arrays.asList(new OperationVariable(inProp1), new OperationVariable(inProp2)); + } + + @Test + public void testInvokeSimpleOperation() throws Exception { + assertEquals(5, simpleOperation.invokeSimple(2, 3)); } - + @Test - public void testInvoke() throws Exception { - assertEquals(5, operation.invoke(2, 3)); + public void testInvokeSimpleOperationWithProperties() throws Exception { + Property p1 = new Property(IN_IDSHORT1, 2); + Property p2 = new Property(IN_IDSHORT2, 3); + SubmodelElement[] directResult = simpleOperation.invoke(p1, p2); + Property propertyResult = (Property) directResult[0]; + + assertEquals(OUT_IDSHORT, propertyResult.getIdShort()); + assertEquals(5, propertyResult.getValue()); } - + @Test - public void testInvokeException() throws Exception { + public void testInvokePropertyOperation() throws Exception { + assertEquals(1, propertyOperation.invokeSimple(2, 3)); + } + + @Test + public void testInvokePropertyOperationWithProperties() { + Property p1 = new Property(IN_IDSHORT1, 2); + Property p2 = new Property(IN_IDSHORT2, 3); + SubmodelElement[] directResult = propertyOperation.invoke(p1, p2); + Property propertyResult = (Property) directResult[0]; + + assertEquals(OUT_IDSHORT, propertyResult.getIdShort()); + assertEquals(1, propertyResult.getValue()); + } + + @Test + public void testInvokeSimpleOperationException() { try { - // Ensure the operation is invoked directly - operationException.invoke(1, 2); + simpleOperationException.invokeSimple(1, 2); fail(); } catch (Exception e) { // Exceptions from ConnectedOperation are wrapped in ProviderException - assertTrue(e instanceof NullPointerException - || e.getCause() instanceof NullPointerException); + assertTrue(e instanceof NullPointerException || e.getCause() instanceof NullPointerException); } } - + @Test - public void testInvokeWithSubmodelElements() { - Property param1 = new Property("testIn1", 2); - param1.setModelingKind(ModelingKind.TEMPLATE); - Property param2 = new Property("testIn2", 4); - param2.setModelingKind(ModelingKind.TEMPLATE); - SubmodelElement[] result = operation.invoke(param1, param2); - assertEquals(1, result.length); - assertEquals(6, result[0].getValue()); + public void testInvokeSimpleOperationExceptionWithProperties() { + try { + Property param1 = new Property(IN_IDSHORT1, 1); + Property param2 = new Property(IN_IDSHORT2, 1); + + simpleOperationException.invoke(param1, param2); + fail(); + } catch (Exception e) { + assertTrue(e instanceof NullPointerException || e.getCause() instanceof NullPointerException); + } } @Test - public void testInvokeParametersException() throws Exception { + public void testInvokePropertyOperationExceptionWithProperties() { try { - operation.invoke(1); + Property p1 = new Property(IN_IDSHORT1, 2); + Property p2 = new Property(IN_IDSHORT2, 3); + propertyOperationException.invoke(p1, p2); fail(); } catch (Exception e) { // Exceptions from ConnectedOperation are wrapped in ProviderException - assertTrue(e instanceof WrongNumberOfParametersException - || e.getCause() instanceof WrongNumberOfParametersException); + assertTrue(e instanceof NullPointerException || e.getCause() instanceof NullPointerException); + } + } + + @Test + public void testInvokeSimpleOperationParameterException() { + try { + simpleOperation.invokeSimple(1); + fail(); + } catch (Exception e) { + assertTrue(e instanceof WrongNumberOfParametersException || e.getCause() instanceof WrongNumberOfParametersException); } } @@ -137,15 +359,15 @@ public void testInvokeAsync() throws Exception { IOperation operation = prepareOperation(helper.getAsyncOperation()); IAsyncInvocation invocation = operation.invokeAsync(3, 2); - + assertFalse(invocation.isFinished()); - + helper.releaseWaitingOperation(); assertTrue(invocation.isFinished()); assertEquals(5, invocation.getResult()); } - + @Test public void testInvokeMultipleAsync() throws Exception { AsyncOperationHelper helper = new AsyncOperationHelper(); @@ -165,12 +387,11 @@ public void testInvokeMultipleAsync() throws Exception { assertEquals(8, invocation2.getResult()); } - @Test + @Test(expected = OperationExecutionTimeoutException.class) public void testInvokeAsyncTimeout() throws Exception { AsyncOperationHelper helper = new AsyncOperationHelper(); IOperation operation = prepareOperation(helper.getAsyncOperation()); - // timeout of 1ms IAsyncInvocation invocation = operation.invokeAsyncWithTimeout(1, 3, 2); // Should be more than enough to trigger the timeout exception @@ -178,33 +399,104 @@ public void testInvokeAsyncTimeout() throws Exception { helper.releaseWaitingOperation(); assertTrue(invocation.isFinished()); - try { - invocation.getResult(); - fail(); - } catch (OperationExecutionTimeoutException e) { - } + invocation.getResult(); } - @Test - public void testInvokeExceptionAsync() throws Exception { + @Test(expected = OperationExecutionErrorException.class) + public void testInvokeAsyncException() throws Exception { AsyncOperationHelper helper = new AsyncOperationHelper(); IOperation operationException = prepareOperation(helper.getAsyncExceptionOperation()); IAsyncInvocation invocation = operationException.invokeAsync(); assertFalse(invocation.isFinished()); - + helper.releaseWaitingOperation(); - - try { - invocation.getResult(); - fail(); - } catch (OperationExecutionErrorException e) { - } + invocation.getResult(); } - + + @Test + public void testInvokeSimpleSupplier() { + assertEquals(SUPPLIER_RETURN_VALUE, simpleSupplierOperation.invokeSimple()); + } + + @Test + public void testInvokeSimpleSupplierWithProperties() { + Property propertyResult = (Property) simpleSupplierOperation.invoke()[0]; + + assertEquals(OUT_IDSHORT, propertyResult.getIdShort()); + assertEquals(SUPPLIER_RETURN_VALUE, propertyResult.getValue()); + } + + @Test + public void testInvokePropertySupplier() { + String operationResult = (String) propertySupplierOperation.invokeSimple(); + + assertEquals(SUPPLIER_RETURN_VALUE, operationResult); + } + + @Test + public void testInvokePropertySupplierWithProperties() { + Property propertyResult = (Property) propertySupplierOperation.invoke()[0]; + + assertEquals(OUT_IDSHORT, propertyResult.getIdShort()); + assertEquals(SUPPLIER_RETURN_VALUE, propertyResult.getValue()); + } + + @Test + public void testInvokeSimpleConsumer() { + int expected = 5; + + simpleConsumerOperation.invokeSimple(expected); + + assertEquals(expected, expectedResultForSimpleConsumerTest); + } + + @Test + public void testInvokeSimpleConsumerWithProperties() { + int expected = 15; + Property p1 = new Property(IN_IDSHORT1, expected); + + simpleConsumerOperationWithProperties.invoke(p1); + + assertEquals(expected, expectedResultForSimpleConsumerWithPropertiesTest); + } + + @Test + public void testInvokePropertyConsumer() { + int expected = 23; + + propertyConsumerOperation.invokeSimple(expected); + + assertEquals(expected, expectedResultForPropertyConsumerTest); + } + + @Test + public void testInvokePropertyConsumerWithProperties() { + int expected = 2; + Property p1 = new Property(IN_IDSHORT1, expected); + + propertyConsumerOperationWithProperties.invoke(p1); + + assertEquals(expected, expectedResultForPropertyConsumerWithPropertiesTest); + } + + @Test + public void testInvokeSimpleRunnable() { + simpleRunnableOperation.invokeSimple(); + + assertEquals(RUNNABLE_FLAG, expectedResultForSimpleRunnableTest); + } + + @Test + public void testInvokeSimpleRunnableWithProperties() { + simpleRunnableOperationWithProperties.invoke(); + + assertEquals(RUNNABLE_FLAG, expectedResultForSimpleRunnableWithPropertiesTest); + } + @Test public void testInputVariables() { - Collection inputVariables = operation.getInputVariables(); + Collection inputVariables = simpleOperation.getInputVariables(); assertEquals(2, inputVariables.size()); Object value = getValueFromOpVariable(inputVariables); assertEquals(IN_VALUE, value); @@ -212,26 +504,24 @@ public void testInputVariables() { @Test public void testOutputVariables() { - Collection outputVariables = operation.getOutputVariables(); - assertEquals(1, outputVariables.size()); + Collection outputVariables = simpleOperation.getOutputVariables(); + Object value = getValueFromOpVariable(outputVariables); assertEquals(OUT_VALUE, value); + assertEquals(1, outputVariables.size()); } @Test public void testInOutputVariables() { - Collection inoutVariables = operation.getInOutputVariables(); + Collection inoutVariables = simpleOperation.getInOutputVariables(); assertEquals(1, inoutVariables.size()); Object value = getValueFromOpVariable(inoutVariables); assertEquals(INOUT_VALUE, value); } - - /** - * Gets the Value from the OperationVariable in a collection - */ + private Object getValueFromOpVariable(Collection vars) { IOperationVariable var = new ArrayList<>(vars).get(0); return var.getValue().getValue(); } - + } diff --git a/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/restapi/OperationProviderTest.java b/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/restapi/OperationProviderTest.java new file mode 100644 index 00000000..6bc351c7 --- /dev/null +++ b/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/restapi/OperationProviderTest.java @@ -0,0 +1,300 @@ +/******************************************************************************* +* Copyright (C) 2021 the Eclipse BaSyx Authors +* +* This program and the accompanying materials are made +* available under the terms of the Eclipse Public License 2.0 +* which is available at https://www.eclipse.org/legal/epl-2.0/ +* +* SPDX-License-Identifier: EPL-2.0 +******************************************************************************/ +package org.eclipse.basyx.testsuite.regression.submodel.restapi; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.eclipse.basyx.submodel.metamodel.api.qualifier.haskind.ModelingKind; +import org.eclipse.basyx.submodel.metamodel.api.submodelelement.operation.IOperationVariable; +import org.eclipse.basyx.submodel.metamodel.map.qualifier.qualifiable.Qualifier; +import org.eclipse.basyx.submodel.metamodel.map.submodelelement.dataelement.property.Property; +import org.eclipse.basyx.submodel.metamodel.map.submodelelement.operation.Operation; +import org.eclipse.basyx.submodel.metamodel.map.submodelelement.operation.OperationVariable; +import org.eclipse.basyx.submodel.restapi.OperationProvider; +import org.eclipse.basyx.submodel.restapi.operation.CallbackResponse; +import org.eclipse.basyx.submodel.restapi.operation.DelegatedInvocationHelper; +import org.eclipse.basyx.submodel.restapi.operation.InvocationRequest; +import org.eclipse.basyx.submodel.restapi.operation.InvocationResponse; +import org.eclipse.basyx.vab.exception.provider.MalformedRequestException; +import org.eclipse.basyx.vab.modelprovider.api.IModelProvider; +import org.eclipse.basyx.vab.modelprovider.lambda.VABLambdaProvider; +import org.eclipse.basyx.vab.protocol.http.server.BaSyxContext; +import org.eclipse.basyx.vab.protocol.http.server.BaSyxHTTPServer; +import org.eclipse.basyx.vab.protocol.http.server.VABHTTPInterface; +import org.junit.BeforeClass; +import org.junit.Test; + +/** + * Tests for the OperationProvider + * + * @author conradi + * + */ +public class OperationProviderTest { + private static final String SERVER = "localhost"; + private static final int PORT = 4000; + private static final String CONTEXT_PATH = "operation"; + + private static final String OPID_IN = "opIn"; + private static final String OPID_OUT = "opOut"; + + private static final String API_INVOKE_URL = "http://" + SERVER + ":" + PORT + "/" + CONTEXT_PATH + "/" + OPID_OUT + "/invoke"; + + private static Integer requestId = 0; + + private static final Function NULL_RETURN_FUNC = (Function) v -> { + // Do nothing, just return + // This is a function with return type "void" + return null; + }; + + private static final Function SUB_RETURN_FUNC = (Function) v -> { + return (Integer) v[0] - (Integer) v[1]; + }; + + private static OperationProvider opProviderIn; + private static OperationProvider opProviderOut; + + + @BeforeClass + public static void setup() { + + Collection in = getInVariables(); + Collection out = getOutVariables(); + + Operation inOperation = createOperation(OPID_IN, in, new ArrayList<>(), NULL_RETURN_FUNC); + opProviderIn = new OperationProvider(new VABLambdaProvider(inOperation)); + + + Operation outOperation = createOperation(OPID_OUT, in, out, SUB_RETURN_FUNC); + opProviderOut = new OperationProvider(new VABLambdaProvider(outOperation)); + + } + + + private static Operation createOperation(String id, Collection in, + Collection out, Function func) { + Operation operation = new Operation(id); + operation.setInputVariables(in); + operation.setOutputVariables(out); + operation.setInvokable(func); + return operation; + } + + + private static Collection getInVariables() { + Collection in = new ArrayList<>(); + + Property inProp1 = new Property("testIn1", 0); + inProp1.setModelingKind(ModelingKind.TEMPLATE); + Property inProp2 = new Property("testIn2", 0); + inProp1.setModelingKind(ModelingKind.TEMPLATE); + + in.add(new OperationVariable(inProp1)); + in.add(new OperationVariable(inProp2)); + + return in; + } + + private static Collection getOutVariables() { + Collection out = new ArrayList<>(); + + Property outProp = new Property("testOut", 0); + outProp.setModelingKind(ModelingKind.TEMPLATE); + + out.add(new OperationVariable(outProp)); + + return out; + } + + + /** + * Tests an Operation call with non wrapped parameters + * Operation has no return value + */ + @Test + public void testNonWrappedInputWithoutOutput() { + opProviderIn.invokeOperation("invoke", 1, 2); + } + + /** + * Tests an Operation call with an InvocationRequest + * Operation has no return value + */ + @Test + public void testInvocationRequestInputWithoutOutput() { + Property inProp1 = new Property("testIn1", 10); + Property inProp2 = new Property("testIn2", 6); + InvocationRequest request = getInvocationRequest(inProp1, inProp2); + invokeSync(opProviderIn, request); + } + + /** + * Tests an Operation call with non wrapped parameters + * Operation returns the given parameter + */ + @Test + public void testNonWrappedInputWithOutput() { + assertEquals(4, opProviderOut.invokeOperation("invoke", 10, 6)); + } + + /** + * Tests an Operation call with an InvocationRequest + * Operation returns the given parameter + */ + @Test + public void testInvocationRequestInputWithOutput() throws Exception { + + Property inProp1 = new Property("testIn1", 10); + Property inProp2 = new Property("testIn2", 6); + InvocationRequest request = getInvocationRequest(inProp1, inProp2); + + Collection outResponseSync = invokeSync(opProviderOut, request); + Collection outResponseAsync = invokeAsync(opProviderOut, request); + assertEquals(1, outResponseSync.size()); + assertEquals(1, outResponseAsync.size()); + + Property propSync = (Property) outResponseSync.iterator().next().getValue(); + Property propAsync = (Property) outResponseAsync.iterator().next().getValue(); + + assertEquals(4, propSync.getValue()); + assertEquals(4, propAsync.getValue()); + } + + /** + * Swap the parameters in request to check if they are sorted by id + */ + @Test + public void testInvocationRequestWithSwappedParameters() throws Exception { + + Property inProp1 = new Property("testIn1", 10); + Property inProp2 = new Property("testIn2", 6); + InvocationRequest request = getInvocationRequest(inProp2, inProp1); + + + Collection outResponseSync = invokeSync(opProviderOut, request); + Collection outResponseAsync = invokeAsync(opProviderOut, request); + assertEquals(1, outResponseSync.size()); + assertEquals(1, outResponseAsync.size()); + + Property propSync = (Property) outResponseSync.iterator().next().getValue(); + Property propAsync = (Property) outResponseAsync.iterator().next().getValue(); + + assertEquals(4, propSync.getValue()); + assertEquals(4, propAsync.getValue()); + } + + /** + * Tests to call an Operation expecting 2 parameters with only 1 + */ + @Test + public void testInvocationRequestWithTooFewParameters() throws Exception { + Property inProp1 = new Property("testIn1", 5); + + InvocationRequest request = getInvocationRequest(inProp1); + + try { + invokeSync(opProviderOut, request); + fail(); + } catch(MalformedRequestException e) { + } + + try { + invokeAsync(opProviderOut, request); + fail(); + } catch(MalformedRequestException e) { + } + } + + /** + * Tests to call Operation with right number of parameters but a wrong id + */ + @Test + public void testInvocationRequestWithWrongParamId() throws Exception { + Property inProp1 = new Property("testIn1", 10); + Property inProp2 = new Property("testIn3", 6); + + InvocationRequest request = getInvocationRequest(inProp1, inProp2); + + try { + invokeSync(opProviderOut, request); + fail(); + } catch(MalformedRequestException e) { + } + + try { + invokeAsync(opProviderOut, request); + fail(); + } catch(MalformedRequestException e) { + } + } + + @Test + public void testInvocationDelegation() { + // Start an http server with an operation + BaSyxContext context = new BaSyxContext("/" + CONTEXT_PATH, "", SERVER, PORT); + context.addServletMapping("/" + OPID_OUT + "/*", new VABHTTPInterface(opProviderOut)); + BaSyxHTTPServer server = new BaSyxHTTPServer(context); + server.start(); + + Operation delegatedOperation = createOperation("delegatedOperation", null, null, null); + + // Create a delegated qualifier and add to the operation + Qualifier qualifier = new Qualifier(DelegatedInvocationHelper.DELEGATION_TYPE, API_INVOKE_URL, "string", null); + delegatedOperation.setQualifiers(Arrays.asList(qualifier)); + + OperationProvider delegatedOpProvider = new OperationProvider(new VABLambdaProvider(delegatedOperation)); + + assertEquals(4, delegatedOpProvider.invokeOperation("invoke", 10, 6)); + + server.shutdown(); + } + + @SuppressWarnings("unchecked") + private Collection invokeSync(OperationProvider provider, InvocationRequest request) { + InvocationResponse response = InvocationResponse + .createAsFacade((Map) provider.invokeOperation("invoke", request)); + return response.getOutputArguments(); + } + + @SuppressWarnings("unchecked") + private Collection invokeAsync(OperationProvider provider, InvocationRequest request) throws Exception { + Object response = provider.invokeOperation("invoke?async=true", request); + assertTrue(response instanceof CallbackResponse); + Thread.sleep(10); + + InvocationResponse invokeResponse = InvocationResponse.createAsFacade((Map) provider.getValue("/invocationList/" + request.getRequestId())); + return invokeResponse.getOutputArguments(); + } + + /** + * Builds an InvocationRequest with given parameters + * + * @param in the parameters for the InvocationRequest + * @return the InvocationRequest + */ + private InvocationRequest getInvocationRequest(Property... in) { + Collection inout = new ArrayList<>(); + + Collection inVariables = Arrays.asList(in).stream() + .map(i -> new OperationVariable(i)).collect(Collectors.toList()); + + return new InvocationRequest((requestId++).toString(), inout, inVariables, 100); + } +} diff --git a/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/restapi/SimpleAASSubmodel.java b/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/restapi/SimpleAASSubmodel.java index c6c89492..4e51b76d 100644 --- a/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/restapi/SimpleAASSubmodel.java +++ b/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/restapi/SimpleAASSubmodel.java @@ -35,6 +35,9 @@ public class SimpleAASSubmodel extends Submodel { public static final String INTPROPIDSHORT = "integerProperty"; public static final String OPERATIONSIMPLEIDSHORT = "simple"; + + public static final String EXCEPTION_MESSAGE = "Exception description"; + public static final List KEYWORDS = Collections.unmodifiableList(Arrays.asList( Property.MODELTYPE, Property.VALUETYPE, Property.VALUE, Property.VALUEID, Submodel.MODELTYPE, Submodel.SUBMODELELEMENT, @@ -98,7 +101,7 @@ public SimpleAASSubmodel(String idShort) { // - Contained operation that throws VAB exception Operation exception2 = new Operation((Function) elId -> { - throw new ProviderException("Exception description"); + throw new ProviderException(EXCEPTION_MESSAGE); }); exception2.setIdShort("exception2"); addSubmodelElement(exception2); diff --git a/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/restapi/SubmodelProviderTest.java b/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/restapi/SubmodelProviderTest.java index 2d2d07b3..c88e368a 100644 --- a/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/restapi/SubmodelProviderTest.java +++ b/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/restapi/SubmodelProviderTest.java @@ -20,7 +20,6 @@ import java.util.HashMap; import java.util.Iterator; import java.util.Map; -import java.util.function.Function; import org.eclipse.basyx.submodel.metamodel.api.submodelelement.ISubmodelElement; import org.eclipse.basyx.submodel.metamodel.facade.SubmodelElementMapCollectionConverter; @@ -97,8 +96,8 @@ public void testOperationIdShortsWithKeywords() { for (String keyword : SimpleAASSubmodel.KEYWORDS) { Operation op = new Operation(); op.setIdShort(keyword + "Operation"); - op.setInvokable((Function) x -> { - return null; + op.setInvokable(() -> { + // Do nothing }); Map param = wrapParameter("argument", 5); diff --git a/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/restapi/TestSubmodelElementCollectionProvider.java b/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/restapi/TestSubmodelElementCollectionProvider.java new file mode 100644 index 00000000..3f8b13b6 --- /dev/null +++ b/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/restapi/TestSubmodelElementCollectionProvider.java @@ -0,0 +1,107 @@ +/******************************************************************************* +* Copyright (C) 2021 the Eclipse BaSyx Authors +* +* This program and the accompanying materials are made +* available under the terms of the Eclipse Public License 2.0 +* which is available at https://www.eclipse.org/legal/epl-2.0/ + +* +* SPDX-License-Identifier: EPL-2.0 +******************************************************************************/ + +package org.eclipse.basyx.testsuite.regression.submodel.restapi; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.util.Collection; +import java.util.Map; + +import org.eclipse.basyx.submodel.metamodel.api.qualifier.haskind.ModelingKind; +import org.eclipse.basyx.submodel.metamodel.map.submodelelement.SubmodelElementCollection; +import org.eclipse.basyx.submodel.metamodel.map.submodelelement.dataelement.property.Property; +import org.eclipse.basyx.submodel.metamodel.map.submodelelement.dataelement.property.valuetype.ValueType; +import org.eclipse.basyx.submodel.metamodel.map.submodelelement.operation.Operation; +import org.eclipse.basyx.submodel.restapi.MultiSubmodelElementProvider; +import org.eclipse.basyx.submodel.restapi.SubmodelElementCollectionProvider; +import org.eclipse.basyx.submodel.restapi.SubmodelProvider; +import org.eclipse.basyx.vab.modelprovider.lambda.VABLambdaProvider; +import org.junit.Before; +import org.junit.Test; + +public class TestSubmodelElementCollectionProvider { + private static final String PROP_ID1 = "prop1"; + private static final String PROP_VALUE1 = "value1"; + private static final String PROP_ID2 = "prop2"; + private static final String PROP_VALUE2 = "value2"; + private static final String OP_ID1 = "op1"; + private static final String COL_ID1 = "col1"; + + private static SubmodelElementCollectionProvider colProvider; + + @Before + public void setup() { + Property prop1 = new Property(PROP_ID1, PROP_VALUE1); + prop1.setModelingKind(ModelingKind.TEMPLATE); + prop1.setValueType(ValueType.String); + + Property prop2 = new Property(PROP_ID2, PROP_VALUE2); + prop2.setModelingKind(ModelingKind.TEMPLATE); + prop2.setValueType(ValueType.String); + + Operation op = new Operation(OP_ID1); + + SubmodelElementCollection smCol = new SubmodelElementCollection(COL_ID1); + smCol.addSubmodelElement(prop1); + smCol.addSubmodelElement(prop2); + smCol.addSubmodelElement(op); + + colProvider = new SubmodelElementCollectionProvider(new VABLambdaProvider(smCol)); + } + + @SuppressWarnings("unchecked") + @Test + public void testGetValuesByEmptyPath() { + Map values = (Map) colProvider.getValue(""); + SubmodelElementCollection retrievedCol = SubmodelElementCollection.createAsFacade(values); + assertEquals(COL_ID1, retrievedCol.getIdShort()); + assertEquals(1, retrievedCol.getOperations().size()); + assertEquals(2, retrievedCol.getProperties().size()); + } + + @SuppressWarnings("unchecked") + @Test + public void testGetValuesByValuesCall() { + Map values = (Map) colProvider.getValue("/" + SubmodelProvider.VALUES + "/"); + assertEquals(2, values.size()); + assertTrue(values.containsKey(PROP_ID1)); + assertTrue(values.containsKey(PROP_ID2)); + assertEquals(PROP_VALUE1, values.get(PROP_ID1)); + assertEquals(PROP_VALUE2, values.get(PROP_ID2)); + } + + @SuppressWarnings("unchecked") + @Test + public void testGetValuesByValueCall() { + Collection> colElements = (Collection>) colProvider.getValue("/" + MultiSubmodelElementProvider.VALUE + "/"); + assertEquals(3, colElements.size()); + } + + @SuppressWarnings("unchecked") + @Test + public void testGetValuesByIdShortCall() { + Map elemMap = (Map) colProvider.getValue("/" + PROP_ID1); + Property propElem1 = Property.createAsFacade(elemMap); + assertEquals(PROP_ID1, propElem1.getIdShort()); + assertEquals(PROP_VALUE1, propElem1.getValue()); + + elemMap = (Map) colProvider.getValue("/" + PROP_ID2); + Property propElem2 = Property.createAsFacade(elemMap); + assertEquals(PROP_ID2, propElem2.getIdShort()); + assertEquals(PROP_VALUE2, propElem2.getValue()); + + elemMap = (Map) colProvider.getValue("/" + OP_ID1); + Operation opElem = Operation.createAsFacade(elemMap); + assertEquals(OP_ID1, opElem.getIdShort()); + } +} diff --git a/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/restapi/observing/ObservableSubmodelAPITest.java b/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/restapi/observing/ObservableSubmodelAPITest.java new file mode 100644 index 00000000..86efd6df --- /dev/null +++ b/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/restapi/observing/ObservableSubmodelAPITest.java @@ -0,0 +1,138 @@ +/******************************************************************************* +* Copyright (C) 2021 the Eclipse BaSyx Authors +* +* This program and the accompanying materials are made +* available under the terms of the Eclipse Public License 2.0 +* which is available at https://www.eclipse.org/legal/epl-2.0/ +* +* SPDX-License-Identifier: EPL-2.0 +******************************************************************************/ +package org.eclipse.basyx.testsuite.regression.submodel.restapi.observing; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.eclipse.basyx.submodel.metamodel.api.identifier.IdentifierType; +import org.eclipse.basyx.submodel.metamodel.api.reference.enums.KeyElements; +import org.eclipse.basyx.submodel.metamodel.map.Submodel; +import org.eclipse.basyx.submodel.metamodel.map.identifier.Identifier; +import org.eclipse.basyx.submodel.metamodel.map.reference.Key; +import org.eclipse.basyx.submodel.metamodel.map.reference.Reference; +import org.eclipse.basyx.submodel.metamodel.map.submodelelement.dataelement.property.Property; +import org.eclipse.basyx.submodel.restapi.observing.ISubmodelAPIObserver; +import org.eclipse.basyx.submodel.restapi.observing.ObservableSubmodelAPI; +import org.eclipse.basyx.submodel.restapi.vab.VABSubmodelAPI; +import org.eclipse.basyx.vab.modelprovider.map.VABMapProvider; +import org.junit.Before; +import org.junit.Test; + +/** + * Test for ObservableSubmodelAPI + * + * @author conradi + * + */ +public class ObservableSubmodelAPITest { + + private static final String AAS_ID = "testaasid"; + private static final String SUBMODEL_ID = "testsubmodelid"; + private static final String PROPERTY_ID = "testpropertyid"; + + + private ObservableSubmodelAPI api; + private MockObserver observer; + + @Before + public void setup() { + // Create submodel + Submodel sm = new Submodel(SUBMODEL_ID, new Identifier(IdentifierType.CUSTOM, SUBMODEL_ID)); + Reference parentRef = new Reference(new Key(KeyElements.ASSETADMINISTRATIONSHELL, true, AAS_ID, IdentifierType.IRDI)); + sm.setParent(parentRef); + + // Create Property + Property prop = new Property(PROPERTY_ID, 1); + sm.addSubmodelElement(prop); + + // Create an Observer + observer = new MockObserver(); + + // Create ObservableAPI + VABSubmodelAPI vabAPI = new VABSubmodelAPI(new VABMapProvider(sm)); + api = new ObservableSubmodelAPI(vabAPI); + + // Register the observer at the API + api.addObserver(observer); + } + + @Test + public void testAddElement() { + Property prop1 = new Property("newProperty1", "newtest1"); + api.addSubmodelElement(prop1); + assertTrue(observer.addedNotified); + assertEquals(prop1.getIdShort(), observer.idShortPath); + assertEquals(prop1.getValue(), observer.newValue); + + observer.addedNotified = false; + + Property prop2 = new Property("newProperty2", "newtest2"); + api.addSubmodelElement(prop2.getIdShort(), prop2); + assertTrue(observer.addedNotified); + assertEquals(prop2.getIdShort(), observer.idShortPath); + assertEquals(prop2.getValue(), observer.newValue); + } + + @Test + public void testDeleteElement() { + api.deleteSubmodelElement(PROPERTY_ID); + assertTrue(observer.deletedNotified); + assertEquals(PROPERTY_ID, observer.idShortPath); + } + + @Test + public void testUpdateElement() { + api.updateSubmodelElement(PROPERTY_ID, 2); + assertTrue(observer.updatedNotified); + assertEquals(PROPERTY_ID, observer.idShortPath); + assertEquals(2, observer.newValue); + } + + @Test + public void testRemoveObserver() { + assertTrue(api.removeObserver(observer)); + api.deleteSubmodelElement(PROPERTY_ID); + assertFalse(observer.deletedNotified); + } + + private class MockObserver implements ISubmodelAPIObserver { + + public boolean addedNotified = false; + public boolean deletedNotified = false; + public boolean updatedNotified = false; + + public String idShortPath = ""; + public Object newValue = null; + + @Override + public void elementAdded(String idShortPath, Object newValue) { + addedNotified = true; + this.idShortPath = idShortPath; + this.newValue = newValue; + } + + @Override + public void elementDeleted(String idShortPath) { + deletedNotified = true; + this.idShortPath = idShortPath; + } + + @Override + public void elementUpdated(String idShortPath, Object newValue) { + updatedNotified = true; + this.idShortPath = idShortPath; + this.newValue = newValue; + } + + } + +} diff --git a/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/types/digitalnameplate/submodelelementcollections/address/TestAddress.java b/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/types/digitalnameplate/submodelelementcollections/address/TestAddress.java index 9da5fca2..a996ed9f 100644 --- a/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/types/digitalnameplate/submodelelementcollections/address/TestAddress.java +++ b/src/test/java/org/eclipse/basyx/testsuite/regression/submodel/types/digitalnameplate/submodelelementcollections/address/TestAddress.java @@ -118,14 +118,14 @@ public void testCreateAsFacade() { assertEquals(addressRemarks, addressFromMap.getAddressRemarks()); assertEquals(additLink, addressFromMap.getAddressOfAdditionalLink()); List phones = new ArrayList(); - phones.add(phone2); phones.add(phone1); + phones.add(phone2); List faxes = new ArrayList(); faxes.add(fax1); faxes.add(fax2); List emails = new ArrayList(); - emails.add(email2); emails.add(email1); + emails.add(email2); assertEquals(phones, addressFromMap.getPhone()); assertEquals(faxes, addressFromMap.getFax()); assertEquals(emails, addressFromMap.getEmail()); diff --git a/src/test/java/org/eclipse/basyx/testsuite/regression/vab/modelprovider/Exceptions.java b/src/test/java/org/eclipse/basyx/testsuite/regression/vab/modelprovider/Exceptions.java index dba4bf82..f7355f73 100644 --- a/src/test/java/org/eclipse/basyx/testsuite/regression/vab/modelprovider/Exceptions.java +++ b/src/test/java/org/eclipse/basyx/testsuite/regression/vab/modelprovider/Exceptions.java @@ -15,7 +15,6 @@ import org.eclipse.basyx.vab.coder.json.metaprotocol.Message; import org.eclipse.basyx.vab.coder.json.metaprotocol.Result; import org.eclipse.basyx.vab.exception.provider.MalformedRequestException; -import org.eclipse.basyx.vab.exception.provider.ProviderException; import org.eclipse.basyx.vab.exception.provider.ResourceAlreadyExistsException; import org.eclipse.basyx.vab.exception.provider.ResourceNotFoundException; import org.eclipse.basyx.vab.manager.VABConnectionManager; @@ -64,21 +63,5 @@ public static void testHandlingException(VABConnectionManager connManager) { Message msg = result.getMessages().get(0); assertEquals("400", msg.getCode()); } - - // Invoke unsupported functional interface - try { - connVABElement.invokeOperation("operations/supplier/invoke"); - fail(); - } catch (MalformedRequestException e) { - // this is for FileSystemProvider that does not support invoke - Result result = new Result(e); - Message msg = result.getMessages().get(0); - assertEquals("400", msg.getCode()); - } catch (ProviderException e) { - Result result = new Result(e); - Message msg = result.getMessages().get(0); - assertEquals("500", msg.getCode()); - } - } } diff --git a/src/test/java/org/eclipse/basyx/testsuite/regression/vab/modelprovider/MapInvoke.java b/src/test/java/org/eclipse/basyx/testsuite/regression/vab/modelprovider/MapInvoke.java index a84f32f2..d858ee61 100644 --- a/src/test/java/org/eclipse/basyx/testsuite/regression/vab/modelprovider/MapInvoke.java +++ b/src/test/java/org/eclipse/basyx/testsuite/regression/vab/modelprovider/MapInvoke.java @@ -9,7 +9,9 @@ ******************************************************************************/ package org.eclipse.basyx.testsuite.regression.vab.modelprovider; +import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; import org.eclipse.basyx.submodel.metamodel.map.submodelelement.operation.Operation; @@ -26,34 +28,62 @@ * */ public class MapInvoke { - + public static void test(VABConnectionManager connManager) { - // Connect to VAB element with ID "urn:fhg:es.iese:vab:1:1:simplevabelement" VABElementProxy connVABElement = connManager.connectToVABElement("urn:fhg:es.iese:vab:1:1:simplevabelement"); - - // Invoke complex function + + invokeComplexObjectReturningFunction(connVABElement); + + invokeSupportedFunctionalInterfaces(connVABElement); + + invokeNonexistantPath(connVABElement); + + invokeInvalidPath(connVABElement); + + invokeExceptionFunction(connVABElement); + + invokeNullPointerExceptionFunction(connVABElement); + + invokeEmptyPathException(connVABElement); + + invokeNullPathException(connVABElement); + } + + private static void invokeComplexObjectReturningFunction(VABElementProxy connVABElement) throws ProviderException { Object complex = connVABElement.invokeOperation("operations/complex/", 12, 34); assertEquals(46, complex); - - // Invoke unsupported functional interface - try { - connVABElement.invokeOperation("operations/supplier/" + Operation.INVOKE); - fail(); - } catch (ProviderException e) {} - - // Invoke non-existing operation + } + + private static void invokeSupportedFunctionalInterfaces(VABElementProxy connVABElement) { + boolean result = (boolean) connVABElement.invokeOperation("operations/supplier/" + Operation.INVOKE); + assertTrue(result); + + Object[] toConsume = { 10 }; + connVABElement.invokeOperation("operations/consumer/" + Operation.INVOKE, toConsume); + Object[] consumed = (Object[]) SimpleVABElement.getAndResetConsumed(); + assertArrayEquals(toConsume, consumed); + + connVABElement.invokeOperation("operations/runnable/" + Operation.INVOKE); + assertTrue(SimpleVABElement.getAndResetRunnableRan()); + } + + private static void invokeNonexistantPath(VABElementProxy connVABElement) throws ProviderException { try { connVABElement.invokeOperation("operations/unknown/" + Operation.INVOKE); fail(); - } catch (ResourceNotFoundException e) {} - - // Invoke invalid operation -> not a function, but a primitive data type + } catch (ResourceNotFoundException e) { + } + } + + private static void invokeInvalidPath(VABElementProxy connVABElement) { try { connVABElement.invokeOperation("operations/invalid/" + Operation.INVOKE); fail(); - } catch (ProviderException e) {} - - // Invoke operations that throw Exceptions + } catch (ProviderException e) { + } + } + + private static void invokeExceptionFunction(VABElementProxy connVABElement) { try { connVABElement.invokeOperation("operations/providerException/" + Operation.INVOKE); fail(); @@ -61,7 +91,9 @@ public static void test(VABConnectionManager connManager) { // exception type not implemented, yet // assertEquals(e.getType(), "testExceptionType"); } - + } + + private static void invokeNullPointerExceptionFunction(VABElementProxy connVABElement) { try { connVABElement.invokeOperation("operations/nullException/" + Operation.INVOKE); fail(); @@ -69,18 +101,22 @@ public static void test(VABConnectionManager connManager) { // exception type not implemented, yet // assertEquals(e.getType(), "java.lang.NullPointerException"); } - - // Empty paths - should execute, but has no effect + } + + private static void invokeEmptyPathException(VABElementProxy connVABElement) { try { connVABElement.invokeOperation("", ""); fail(); - } catch (ProviderException e) {} - - - // Null path - should throw exception + } catch (ProviderException e) { + } + } + + private static void invokeNullPathException(VABElementProxy connVABElement) throws ProviderException { try { connVABElement.invokeOperation(null, ""); fail(); - } catch (MalformedRequestException e) {} + } catch (MalformedRequestException e) { + } } + } diff --git a/src/test/java/org/eclipse/basyx/testsuite/regression/vab/modelprovider/SimpleVABElement.java b/src/test/java/org/eclipse/basyx/testsuite/regression/vab/modelprovider/SimpleVABElement.java index 8087a69d..10d64edf 100644 --- a/src/test/java/org/eclipse/basyx/testsuite/regression/vab/modelprovider/SimpleVABElement.java +++ b/src/test/java/org/eclipse/basyx/testsuite/regression/vab/modelprovider/SimpleVABElement.java @@ -13,6 +13,8 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; +import java.util.Map; +import java.util.function.Consumer; import java.util.function.Function; import java.util.function.Supplier; @@ -25,30 +27,98 @@ * */ public class SimpleVABElement extends HashMap { + public static final String EXCEPTION_MESSAGE = "Exception description"; + private static final long serialVersionUID = 3942399852711325850L; + private static Object consumed; + + public static Object getAndResetConsumed() { + Object tmp = consumed; + consumed = null; + return tmp; + } + + private static boolean runnableRan = false; + + public static boolean getAndResetRunnableRan() { + boolean tmp = runnableRan; + runnableRan = false; + return tmp; + } + /** * Constructor for a simple VAB element that contains all data types */ public SimpleVABElement() { - // Add primitive types - HashMap primitives = new HashMap<>(); - primitives.put("integer", 123); - primitives.put("double", 3.14d); - primitives.put("string", "TestValue"); + Map primitives = createPrimitiveTypes(); put("primitives", primitives); - // Add function types - HashMap functions = new HashMap<>(); + Map functions = createFunctions(); + put("operations", functions); + + Map structure = createStructureTypes(); + put("structure", structure); + + Map special = new HashMap<>(); + special.putAll(createCaseSensitiveEntries()); + + Map nestedA = createNestedMap(); + special.put("nested", nestedA); + + special.put("null", null); + + put("special", special); + } + + private Map createNestedMap() { + Map nestedA = new HashMap<>(); + Map nestedB = new HashMap<>(); + nestedA.put("nested", nestedB); + nestedB.put("value", 100); + return nestedA; + } + + private Map createCaseSensitiveEntries() { + Map caseSensitive = new HashMap<>(); + caseSensitive.put("casesensitivity", true); + caseSensitive.put("caseSensitivity", false); + return caseSensitive; + } + + private Map createStructureTypes() { + Map structure = new HashMap<>(); + structure.put("map", new HashMap()); + structure.put("set", new HashSet()); + structure.put("list", new ArrayList()); + return structure; + } + + private Map createFunctions() { + Map functions = new HashMap<>(); functions.put("supplier", (Supplier) () -> { return true; }); + + functions.put("consumer", (Consumer) (o) -> { + consumed = o; + }); + + functions.put("runnable", new Runnable() { + + @Override + public void run() { + runnableRan = true; + } + }); + functions.put("providerException", (Function) (param) -> { - throw new ProviderException("Exception description"); + throw new ProviderException(EXCEPTION_MESSAGE); }); functions.put("nullException", (Function) (param) -> { throw new NullPointerException(); }); + functions.put("complex", (Function) (param) -> { return (int) param[0] + (int) param[1]; }); @@ -59,25 +129,14 @@ public SimpleVABElement() { functions.put("invokable", (Function) (param) -> { return true; }); - put("operations", functions); - - // Add structure types - HashMap structure = new HashMap<>(); - structure.put("map", new HashMap()); - structure.put("set", new HashSet()); - structure.put("list", new ArrayList()); - put("structure", structure); + return functions; + } - // Add corner cases - HashMap special = new HashMap<>(); - special.put("casesensitivity", true); - special.put("caseSensitivity", false); - HashMap nestedA = new HashMap<>(); - HashMap nestedB = new HashMap<>(); - nestedA.put("nested", nestedB); - nestedB.put("value", 100); - special.put("nested", nestedA); - special.put("null", null); - put("special", special); + private Map createPrimitiveTypes() { + Map primitives = new HashMap<>(); + primitives.put("integer", 123); + primitives.put("double", 3.14d); + primitives.put("string", "TestValue"); + return primitives; } } diff --git a/src/test/java/org/eclipse/basyx/testsuite/regression/vab/modelprovider/VABPathToolsTest.java b/src/test/java/org/eclipse/basyx/testsuite/regression/vab/modelprovider/VABPathToolsTest.java index 33d28421..4e7c32ba 100644 --- a/src/test/java/org/eclipse/basyx/testsuite/regression/vab/modelprovider/VABPathToolsTest.java +++ b/src/test/java/org/eclipse/basyx/testsuite/regression/vab/modelprovider/VABPathToolsTest.java @@ -200,6 +200,16 @@ public void testStripInvokeFromPath() { assertEquals("", VABPathTools.stripInvokeFromPath("")); } + @Test + public void testStripFromPath() { + assertEquals("id", VABPathTools.stripFromPath("id/invoke", "invoke")); + assertEquals("", VABPathTools.stripFromPath("invoke", "invoke")); + assertEquals("", VABPathTools.stripFromPath("/invoke", "invoke")); + assertEquals("id/value", VABPathTools.stripFromPath("id/value", "invoke")); + assertEquals("id", VABPathTools.stripFromPath("id/value", "value")); + assertEquals("", VABPathTools.stripFromPath("", "")); + } + @Test public void testGetPathFromURL() { diff --git a/src/test/java/org/eclipse/basyx/testsuite/regression/vab/protocol/http/TestVABHTTP.java b/src/test/java/org/eclipse/basyx/testsuite/regression/vab/protocol/http/TestVABHTTP.java index 6a1dc455..2adb496a 100644 --- a/src/test/java/org/eclipse/basyx/testsuite/regression/vab/protocol/http/TestVABHTTP.java +++ b/src/test/java/org/eclipse/basyx/testsuite/regression/vab/protocol/http/TestVABHTTP.java @@ -10,7 +10,11 @@ package org.eclipse.basyx.testsuite.regression.vab.protocol.http; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import java.io.ByteArrayInputStream; +import java.io.IOException; import java.util.HashMap; import java.util.List; @@ -20,14 +24,21 @@ import javax.ws.rs.client.WebTarget; import javax.ws.rs.core.MediaType; +import org.apache.http.client.ClientProtocolException; +import org.eclipse.basyx.submodel.metamodel.map.submodelelement.operation.Operation; +import org.eclipse.basyx.testsuite.regression.vab.modelprovider.SimpleVABElement; import org.eclipse.basyx.testsuite.regression.vab.modelprovider.TestProvider; import org.eclipse.basyx.testsuite.regression.vab.support.RecordingProvider; +import org.eclipse.basyx.vab.coder.json.metaprotocol.Message; +import org.eclipse.basyx.vab.exception.provider.ProviderException; import org.eclipse.basyx.vab.exception.provider.ResourceNotFoundException; import org.eclipse.basyx.vab.manager.VABConnectionManager; +import org.eclipse.basyx.vab.modelprovider.VABElementProxy; import org.eclipse.basyx.vab.modelprovider.VABPathTools; import org.eclipse.basyx.vab.modelprovider.api.IModelProvider; import org.eclipse.basyx.vab.modelprovider.map.VABMapProvider; import org.eclipse.basyx.vab.protocol.http.connector.HTTPConnectorFactory; +import org.eclipse.basyx.vab.protocol.http.helper.HTTPUploadHelper; import org.eclipse.basyx.vab.protocol.http.server.BaSyxContext; import org.eclipse.basyx.vab.protocol.http.server.VABHTTPInterface; import org.junit.Rule; @@ -40,6 +51,11 @@ * */ public class TestVABHTTP extends TestProvider { + public static final String AASX_PATH = "src/test/resources/aas/factory/aasx/01_Festo.aasx"; + + private static final String RECORDER_URL = "http://localhost:8080/basys.sdk/Testsuite/Recorder/"; + private static final String SIMPLE_VAB_URL = "http://localhost:8080/basys.sdk/Testsuite/SimpleVAB"; + protected VABConnectionManager connManager = new VABConnectionManager(new TestsuiteDirectory(), new HTTPConnectorFactory()); @@ -67,7 +83,7 @@ protected VABConnectionManager getConnectionManager() { */ @Test public void testRootURL() { - performRequest("http://localhost:8080/basys.sdk/Testsuite/SimpleVAB"); + performRequest(SIMPLE_VAB_URL); } /** @@ -90,8 +106,7 @@ public void testParameters() { String parameterRequest = "test?a=1,2&b=3,4"; - performRequestNoException("http://localhost:8080/basys.sdk/Testsuite/Recorder/" + parameterRequest); - + performRequestNoException(RECORDER_URL + parameterRequest); List paths = recorder.getPaths(); assertEquals(1, paths.size()); @@ -107,13 +122,56 @@ public void testNoParameters() { String parameterRequest = "test"; - performRequestNoException("http://localhost:8080/basys.sdk/Testsuite/Recorder/" + parameterRequest); + performRequestNoException(RECORDER_URL + parameterRequest); List paths = recorder.getPaths(); assertEquals(1, paths.size()); assertEquals(parameterRequest, VABPathTools.stripSlashes(paths.get(0))); } + + @Test + public void testUpload() throws ClientProtocolException, IOException { + String parameterRequest = "aasx"; + String uploadURL = RECORDER_URL + parameterRequest; + String strToSend = "1"; + HTTPUploadHelper.uploadHTTPPost(new ByteArrayInputStream(strToSend.getBytes()), uploadURL); + + List paths = recorder.getPaths(); + assertEquals(1, paths.size()); + assertEquals(parameterRequest, VABPathTools.stripSlashes(paths.get(0))); + + Object retStr = recorder.getValue(parameterRequest); + assertEquals(strToSend, retStr); + } + + @Test + public void invokeExceptionFunction() { + VABElementProxy connVABElement = connManager.connectToVABElement("urn:fhg:es.iese:vab:1:1:simplevabelement"); + try { + connVABElement.invokeOperation("operations/providerException/" + Operation.INVOKE); + fail(); + } catch (ProviderException e) { + List msg = e.getMessages(); + assertEquals(2, msg.size()); + String msgText = msg.get(1).getText(); + assertTrue(msgText.contains("ProviderException: " + SimpleVABElement.EXCEPTION_MESSAGE)); + } + } + + @Test + public void invokeNullPointerExceptionFunction() { + VABElementProxy connVABElement = connManager.connectToVABElement("urn:fhg:es.iese:vab:1:1:simplevabelement"); + try { + connVABElement.invokeOperation("operations/nullException/" + Operation.INVOKE); + fail(); + } catch (ProviderException e) { + List msg = e.getMessages(); + assertEquals(2, msg.size()); + String msgText = msg.get(1).getText(); + assertTrue(msgText.contains("ProviderException: " + NullPointerException.class.getName())); + } + } /** * Performs an HTTP request on an URL diff --git a/src/test/java/org/eclipse/basyx/testsuite/regression/vab/protocol/opcua/BrowsePathHelperTest.java b/src/test/java/org/eclipse/basyx/testsuite/regression/vab/protocol/opcua/BrowsePathHelperTest.java new file mode 100644 index 00000000..ffa65aae --- /dev/null +++ b/src/test/java/org/eclipse/basyx/testsuite/regression/vab/protocol/opcua/BrowsePathHelperTest.java @@ -0,0 +1,242 @@ +/******************************************************************************* + * Copyright (C) 2021 Festo Didactic SE + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + ******************************************************************************/ +package org.eclipse.basyx.testsuite.regression.vab.protocol.opcua; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +import org.eclipse.basyx.vab.protocol.opcua.connector.milo.BrowsePathHelper; +import org.eclipse.basyx.vab.protocol.opcua.exception.OpcUaException; +import org.eclipse.milo.opcua.stack.core.BuiltinReferenceType; +import org.eclipse.milo.opcua.stack.core.types.builtin.QualifiedName; +import org.eclipse.milo.opcua.stack.core.types.structured.BrowsePath; +import org.eclipse.milo.opcua.stack.core.types.structured.RelativePath; +import org.eclipse.milo.opcua.stack.core.types.structured.RelativePathElement; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; + +public class BrowsePathHelperTest { + + @Rule + public final ExpectedException thrown = ExpectedException.none(); + + @Test + public void nullThrowsIllegalArgumentException() { + thrown.expect(IllegalArgumentException.class); + + BrowsePathHelper.parse(null); + } + + @Test + public void emptyStringThrowsException() { + thrown.expect(IllegalArgumentException.class); + + BrowsePathHelper.parse(""); + } + + @Test + public void validPathWithEscapedPeriod() { + String s = "/2:Block&.Output"; + BrowsePath bp = BrowsePathHelper.parse(s); + + RelativePathElement rpe = new RelativePathElement( + BuiltinReferenceType.HierarchicalReferences.getNodeId(), + false, + true, + new QualifiedName(2, "Block.Output")); + + assertEquals(1, bp.getRelativePath().getElements().length); + assertEquals(rpe, bp.getRelativePath().getElements()[0]); + } + + @Test + public void validPathWithTwoElements() { + String s = "/3:Truck.0:NodeVersion"; + BrowsePath bp = BrowsePathHelper.parse(s); + + RelativePathElement rpe1 = new RelativePathElement( + BuiltinReferenceType.HierarchicalReferences.getNodeId(), + false, + true, + new QualifiedName(3, "Truck")); + + RelativePathElement rpe2 = new RelativePathElement( + BuiltinReferenceType.Aggregates.getNodeId(), + false, + true, + new QualifiedName(0, "NodeVersion")); + + assertEquals(2, bp.getRelativePath().getElements().length); + assertEquals(rpe1, bp.getRelativePath().getElements()[0]); + assertEquals(rpe2, bp.getRelativePath().getElements()[1]); + } + + @Test + public void validPathWithTwoElementsAndEscapes() { + String s = ".3:Truck&/Car/Node&:Version"; + BrowsePath bp = BrowsePathHelper.parse(s); + + RelativePathElement rpe1 = new RelativePathElement( + BuiltinReferenceType.Aggregates.getNodeId(), + false, + true, + new QualifiedName(3, "Truck/Car")); + + RelativePathElement rpe2 = new RelativePathElement( + BuiltinReferenceType.HierarchicalReferences.getNodeId(), + false, + true, + new QualifiedName(0, "Node:Version")); + + assertEquals(2, bp.getRelativePath().getElements().length); + assertEquals(rpe1, bp.getRelativePath().getElements()[0]); + assertEquals(rpe2, bp.getRelativePath().getElements()[1]); + } + + @Test + public void multiSelectorPath() { + String s = "/2:Block/Output."; + BrowsePath bp = BrowsePathHelper.parse(s); + + RelativePathElement last = bp.getRelativePath().getElements()[2]; + assertNull(last.getTargetName()); + } + + @Test + public void invalidPathWithMissingReferenceType() { + thrown.expect(OpcUaException.class); + thrown.expectMessage(org.hamcrest.CoreMatchers.containsString("index 0")); + + String s = "3:Truck/NodeVersion"; + BrowsePathHelper.parse(s); + } + + @Test + public void pathWithEmptyPathElement() { + thrown.expect(OpcUaException.class); + thrown.expectMessage(org.hamcrest.CoreMatchers.containsString("index 9")); + + String s = "/3:Truck/.NodeVersion"; + BrowsePathHelper.parse(s); + } + + @Test + public void pathWithIllegalNamespace() { + thrown.expect(OpcUaException.class); + thrown.expectMessage(org.hamcrest.CoreMatchers.containsString("index 1")); + + String s = "/3a:Truck"; + BrowsePathHelper.parse(s); + } + + @Test + public void pathWithTwoNamespaces() { + thrown.expect(OpcUaException.class); + thrown.expectMessage(org.hamcrest.CoreMatchers.containsString("index 9")); + + String s = "/3:Truck/2:Car:Bike"; + BrowsePathHelper.parse(s); + } + + @Test + public void pathWithDoubleColon() { + thrown.expect(OpcUaException.class); + thrown.expectMessage(org.hamcrest.CoreMatchers.containsString("index 9")); + + String s = "/3:Truck/2::Bike"; + BrowsePathHelper.parse(s); + } + + @Test + public void pathWithDirectReferenceTypeAtStart() { + thrown.expect(IllegalArgumentException.class); + + String s = "<1:ConnectedTo>1:Boiler/1:HeatSensor"; + BrowsePathHelper.parse(s); + } + + @Test + public void pathWithDirectReferenceTypeInMiddle() { + thrown.expect(IllegalArgumentException.class); + + String s = ".1:Boiler<1:ConnectedTo>1:HeatSensor"; + BrowsePathHelper.parse(s); + } + + @Test + public void relativePathToString() { + RelativePathElement rpe1 = new RelativePathElement( + BuiltinReferenceType.Aggregates.getNodeId(), + false, + true, + new QualifiedName(0, "Objects")); + + RelativePathElement rpe2 = new RelativePathElement( + BuiltinReferenceType.HierarchicalReferences.getNodeId(), + false, + true, + new QualifiedName(1, "Cars")); + + RelativePathElement rpe3 = new RelativePathElement( + BuiltinReferenceType.HierarchicalReferences.getNodeId(), + false, + true, + null); + + RelativePath rp = new RelativePath(new RelativePathElement[] {rpe1, rpe2, rpe3}); + + String s = BrowsePathHelper.toString(rp); + + assertEquals(".0:Objects/1:Cars/", s); + } + + @Test + public void relativePathWithMissingQualifiedNameToString() { + thrown.expect(IllegalArgumentException.class); + + RelativePathElement rpe1 = new RelativePathElement( + BuiltinReferenceType.Aggregates.getNodeId(), + false, + true, + new QualifiedName(0, "Objects")); + + RelativePathElement rpe2 = new RelativePathElement( + BuiltinReferenceType.HierarchicalReferences.getNodeId(), + false, + true, + null); + + RelativePathElement rpe3 = new RelativePathElement( + BuiltinReferenceType.HierarchicalReferences.getNodeId(), + false, + true, + new QualifiedName(1, "Cars")); + + RelativePath rp = new RelativePath(new RelativePathElement[] {rpe1, rpe2, rpe3}); + + BrowsePathHelper.toString(rp); + } + + @Test + public void relativePathWithInverseReferencesToString() { + thrown.expect(IllegalArgumentException.class); + + RelativePathElement rpe1 = new RelativePathElement( + BuiltinReferenceType.Aggregates.getNodeId(), + true, + true, + new QualifiedName(0, "Objects")); + + RelativePath rp = new RelativePath(new RelativePathElement[] {rpe1}); + + BrowsePathHelper.toString(rp); + } +} diff --git a/src/test/java/org/eclipse/basyx/testsuite/regression/vab/protocol/opcua/TestVABOpcUa.java b/src/test/java/org/eclipse/basyx/testsuite/regression/vab/protocol/opcua/TestVABOpcUa.java index fa50ad49..3bdaa596 100644 --- a/src/test/java/org/eclipse/basyx/testsuite/regression/vab/protocol/opcua/TestVABOpcUa.java +++ b/src/test/java/org/eclipse/basyx/testsuite/regression/vab/protocol/opcua/TestVABOpcUa.java @@ -10,7 +10,7 @@ package org.eclipse.basyx.testsuite.regression.vab.protocol.opcua; import org.eclipse.basyx.vab.gateway.ConnectorProviderMapper; -import org.eclipse.basyx.vab.protocol.opcua.connector.OpcUaConnectorProvider; +import org.eclipse.basyx.vab.protocol.opcua.connector.OpcUaConnectorFactory; import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UByte; import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UInteger; import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.ULong; @@ -34,7 +34,7 @@ public class TestVABOpcUa { // @Test public void testOpcUaMethodCall() { - clientMapper.addConnectorProvider("opc.tcp", new OpcUaConnectorProvider()); + clientMapper.addConnectorProvider("opc.tcp", new OpcUaConnectorFactory()); try { Object methodCallRes = clientMapper.getConnector("opc.tcp://opcua.demo-this.com:51210/UA/SampleServer") .invokeOperation("0:Objects/2:Data/2:Static/2:MethodTest/2:ScalarMethod1", @@ -51,7 +51,7 @@ public void testOpcUaMethodCall() { // @Test public void testOpcUaReadAndWrite() { - clientMapper.addConnectorProvider("opc.tcp", new OpcUaConnectorProvider()); + clientMapper.addConnectorProvider("opc.tcp", new OpcUaConnectorFactory()); try { clientMapper.getConnector("opc.tcp://opcua.demo-this.com:51210/UA/SampleServer") .setValue("0:Objects/2:Data/2:Static/2:AnalogScalar/2:Int32Value", 42); diff --git a/src/test/java/org/eclipse/basyx/testsuite/regression/vab/support/RecordingProvider.java b/src/test/java/org/eclipse/basyx/testsuite/regression/vab/support/RecordingProvider.java index c59d7315..29f027c1 100644 --- a/src/test/java/org/eclipse/basyx/testsuite/regression/vab/support/RecordingProvider.java +++ b/src/test/java/org/eclipse/basyx/testsuite/regression/vab/support/RecordingProvider.java @@ -9,6 +9,8 @@ ******************************************************************************/ package org.eclipse.basyx.testsuite.regression.vab.support; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; @@ -59,7 +61,21 @@ public void setValue(String path, Object newValue) throws ProviderException { @Override public void createValue(String path, Object newEntity) throws ProviderException { paths.add(path); - wrapped.createValue(path, newEntity); + if (newEntity instanceof InputStream) { + try { + InputStream in = (InputStream) newEntity; + int n = in.available(); + byte[] bytes = new byte[n]; + in.read(bytes, 0, n); + String s = new String(bytes, StandardCharsets.UTF_8); + wrapped.createValue(path, s); + } catch (Exception e) { + throw new ProviderException("Cannot parse input stream"); + } + + } else { + wrapped.createValue(path, newEntity); + } } @Override diff --git a/src/test/resources/aas/factory/aasx/01_Festo.aasx b/src/test/resources/aas/factory/aasx/01_Festo.aasx new file mode 100644 index 0000000000000000000000000000000000000000..64900301106757efda5af83d63337b18b9d75d10 GIT binary patch literal 7697369 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�${k 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

ajLc7LK9$3dYJ~Nho>R(U z9YD50%2&V6yu$NEULLFy^LjmM&pJqna`X!m=hT!zt_P*?1WPUxyKsS4ZI~6jKY$Y# zvEsKR!DckwH0{;CCo4tf-j4F#%7|VVlEm#}BTV^mFJ_h_<9XNRlw=9(D={w}cp*j& z+_6yVsr@X5{WMm-ebmw-BgZwFr=Ev&*!c2FWU+|G&X52|sU!VS97Ja)d;QW@>=`EE zZva1EnuTS>?zIJCA^T<{%!W=OUbKHguLnJSYUXmExyP6Mi(*yaIysLECC@jsVJ#3^ zyb?GP&*v$XP+;;Y@_AfD_zY;_G4ifa{vgwRhzXu*+T4{5C#`~2&1y2)G(Vg>PDwe9 zIEQCrz!|IRpZo40?x#a8YuGzicPYMSs%XV}FS?$XvcfRDwNOvfT~J)2yUjvW|6_W< z?XvjoIE|546(lb;rVfQ6p=5pjoCwAga2JUyc zeNhW_j>K_v<8{`)I^zELDK+4@nLk`^#?yK{~iJ=%(6 z`HXeBRU)N>xccNv*jWCf|wP%)taeQRt zC#jx!-E%zlEJO6KS@@1CaAKC8k8*>qoEJws(5Co3+W34v0`73rCb!MH^zDNI@q7A* zp8_F@f-EslVJ?ucb9csRdmB!gX3Z0`WJ#a6IB?GNI?96blQUK#_q;l-xTwNp_hh!d~++e%Wm=Y1@vW^-!7-})>cP<81QRXT0>7e zfbH9xqSCsI=jnr;iyI(>UdPv#L*>snQp*Mh#j4pud&t+wAd?cn-3M`5ciyxGF#rq= ziG{FjN?!{yXc>=6B(Jk|_&>WsZ%{rsOCOT?%!=FlRy{lgJ^d&=Y9@Fq+W&Rbeq_3v z20itD@mY^Y3Y6XtMhxsJ;McDiQ3(Oc_u9rX5$q+g5S!{8a5?`IqqQeq-FA$;1W8W! z)KQvmfXhIqfEUD%Zu3}cI0aP-H5ne3Q<74-&zZQR8{$WN38A|WYoeY)2UG9AJO){f zvs49gnjT`NalH!wUm2X6K#SO9E+IIf#&)l!oDw@6jsjQ&Zc*xjZcrOU&$e5FuM=8& zd=Nm7xAPr>93edg8Z8f`W2)1LmvecrTO#7fL0a0M$kEvcAcLMaSCaGbX@zb*?w% z`hvGSPk-<|>bQ>BdbXdkia%61<~`Ufm2e$rr~5Le9v&*bX8In<-$(PZdU=5_A;}sZ?>~p! z)~sWAizMz}=QWkn6Z!^FDxaDu4de3Uy2$Oz5akEz5-I8|$jc&L?AyypQfbq)r&lla zO=_Y=t;MuC0ZO3gr#mmO6O%8u-yx1#n2nfQjp6Rtt#9n>7EifW&0ZRsWtQj6?MW(q zv526CI8CtNv@|Qaq0X;hO**!B3Yet_5-4gIfNdD>dl&G+Jrz15h$g+^)owXZ$=KMr z9h++JDMdfq>gmu53h%a(Jr}5Luw|I3#s;-N+vw@aFzyVWE2e0%ok#-PR!LVH-IwJO zd$arHZ{(DBhXoGL9P$FVY1pL&bT^2l5j!vtRIAW*g*Cz4Ke{gi5YE2?!~+E|C5ea| zFiOdZ2sy)taQs{if{ILjg+O z)?a#%tu-$2yq(vS3B}2;ZDQsiCtGF^ESeUgHmg&6&UiZ0TM>@+zD(?mBH0&dH+W-V zGSLfL6S(8djG7M8k3>%zDp=?(ot0}|q~c@Yg}&KSfkG2zL