Browse Source
* created aws lambda node * updated logic for processing input keys * removed logic for parsing mappings * added possibility to pass qualifier using patterns * added check for function name and invocation type * tests refactoring after review * removed ability to pass invocation type to the config * added node teplatization doc * changed title for documenatation * added uiResources and configDirective * changed names of classes * removed false param from TbNodeException in init method --------- Co-authored-by: ShvaykaD <dshvaika@thingsboard.io>pull/11360/head
committed by
GitHub
6 changed files with 597 additions and 0 deletions
@ -0,0 +1,151 @@ |
|||||
|
/** |
||||
|
* Copyright © 2016-2024 The Thingsboard Authors |
||||
|
* |
||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
|
* you may not use this file except in compliance with the License. |
||||
|
* You may obtain a copy of the License at |
||||
|
* |
||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
* |
||||
|
* Unless required by applicable law or agreed to in writing, software |
||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
|
* See the License for the specific language governing permissions and |
||||
|
* limitations under the License. |
||||
|
*/ |
||||
|
package org.thingsboard.rule.engine.aws.lambda; |
||||
|
|
||||
|
import com.amazonaws.ClientConfiguration; |
||||
|
import com.amazonaws.auth.AWSCredentials; |
||||
|
import com.amazonaws.auth.AWSStaticCredentialsProvider; |
||||
|
import com.amazonaws.auth.BasicAWSCredentials; |
||||
|
import com.amazonaws.handlers.AsyncHandler; |
||||
|
import com.amazonaws.services.lambda.AWSLambdaAsync; |
||||
|
import com.amazonaws.services.lambda.AWSLambdaAsyncClientBuilder; |
||||
|
import com.amazonaws.services.lambda.model.InvokeRequest; |
||||
|
import com.amazonaws.services.lambda.model.InvokeResult; |
||||
|
import lombok.extern.slf4j.Slf4j; |
||||
|
import org.thingsboard.rule.engine.api.RuleNode; |
||||
|
import org.thingsboard.rule.engine.api.TbContext; |
||||
|
import org.thingsboard.rule.engine.api.TbNodeConfiguration; |
||||
|
import org.thingsboard.rule.engine.api.TbNodeException; |
||||
|
import org.thingsboard.rule.engine.api.util.TbNodeUtils; |
||||
|
import org.thingsboard.rule.engine.external.TbAbstractExternalNode; |
||||
|
import org.thingsboard.server.common.data.StringUtils; |
||||
|
import org.thingsboard.server.common.data.plugin.ComponentType; |
||||
|
import org.thingsboard.server.common.msg.TbMsg; |
||||
|
import org.thingsboard.server.common.msg.TbMsgMetaData; |
||||
|
|
||||
|
import java.nio.ByteBuffer; |
||||
|
import java.util.concurrent.TimeUnit; |
||||
|
|
||||
|
@Slf4j |
||||
|
@RuleNode( |
||||
|
type = ComponentType.EXTERNAL, |
||||
|
name = "aws lambda", |
||||
|
configClazz = TbAwsLambdaNodeConfiguration.class, |
||||
|
nodeDescription = "Publish message to the AWS Lambda", |
||||
|
nodeDetails = "Publishes messages to AWS Lambda, a service that lets you run code " + |
||||
|
"without provisioning or managing servers. " + |
||||
|
"It sends messages using a RequestResponse invocation type. " + |
||||
|
"The node uses a pre-configured client and specified function to run.<br><br>" + |
||||
|
"Output connections: <code>Success</code>, <code>Failure</code>.", |
||||
|
uiResources = {"static/rulenode/rulenode-core-config.js"}, |
||||
|
configDirective = "tbExternalNodeLambdaConfig", |
||||
|
iconUrl = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjQ4IiBoZWlnaHQ9IjQ4Ij48cGF0aCBkPSJNMTMuMjMgMTAuNTZWMTBjLTEuOTQgMC0zLjk5LjM5LTMuOTkgMi42NyAwIDEuMTYuNjEgMS45NSAxLjYzIDEuOTUuNzYgMCAxLjQzLS40NyAxLjg2LTEuMjIuNTItLjkzLjUtMS44LjUtMi44NG0yLjcgNi41M2MtLjE4LjE2LS40My4xNy0uNjMuMDYtLjg5LS43NC0xLjA1LTEuMDgtMS41NC0xLjc5LTEuNDcgMS41LTIuNTEgMS45NS00LjQyIDEuOTUtMi4yNSAwLTQuMDEtMS4zOS00LjAxLTQuMTcgMC0yLjE4IDEuMTctMy42NCAyLjg2LTQuMzggMS40Ni0uNjQgMy40OS0uNzYgNS4wNC0uOTNWNy41YzAtLjY2LjA1LTEuNDEtLjMzLTEuOTYtLjMyLS40OS0uOTUtLjctMS41LS43LTEuMDIgMC0xLjkzLjUzLTIuMTUgMS42MS0uMDUuMjQtLjI1LjQ4LS40Ny40OWwtMi42LS4yOGMtLjIyLS4wNS0uNDYtLjIyLS40LS41Ni42LTMuMTUgMy40NS00LjEgNi00LjEgMS4zIDAgMyAuMzUgNC4wMyAxLjMzQzE3LjExIDQuNTUgMTcgNi4xOCAxNyA3Ljk1djQuMTdjMCAxLjI1LjUgMS44MSAxIDIuNDguMTcuMjUuMjEuNTQgMCAuNzFsLTIuMDYgMS43OGgtLjAxIj48L3BhdGg+PHBhdGggZD0iTTIwLjE2IDE5LjU0QzE4IDIxLjE0IDE0LjgyIDIyIDEyLjEgMjJjLTMuODEgMC03LjI1LTEuNDEtOS44NS0zLjc2LS4yLS4xOC0uMDItLjQzLjI1LS4yOSAyLjc4IDEuNjMgNi4yNSAyLjYxIDkuODMgMi42MSAyLjQxIDAgNS4wNy0uNSA3LjUxLTEuNTMuMzctLjE2LjY2LjI0LjMyLjUxIj48L3BhdGg+PHBhdGggZD0iTTIxLjA3IDE4LjVjLS4yOC0uMzYtMS44NS0uMTctMi41Ny0uMDgtLjE5LjAyLS4yMi0uMTYtLjAzLS4zIDEuMjQtLjg4IDMuMjktLjYyIDMuNTMtLjMzLjI0LjMtLjA3IDIuMzUtMS4yNCAzLjMyLS4xOC4xNi0uMzUuMDctLjI2LS4xMS4yNi0uNjcuODUtMi4xNC41Ny0yLjV6Ij48L3BhdGg+PC9zdmc+" |
||||
|
) |
||||
|
public class TbAwsLambdaNode extends TbAbstractExternalNode { |
||||
|
|
||||
|
private TbAwsLambdaNodeConfiguration config; |
||||
|
private AWSLambdaAsync client; |
||||
|
|
||||
|
@Override |
||||
|
public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { |
||||
|
config = TbNodeUtils.convert(configuration, TbAwsLambdaNodeConfiguration.class); |
||||
|
if (StringUtils.isBlank(config.getFunctionName())) { |
||||
|
throw new TbNodeException("Function name must be set!", true); |
||||
|
} |
||||
|
try { |
||||
|
AWSCredentials awsCredentials = new BasicAWSCredentials(config.getAccessKey(), config.getSecretKey()); |
||||
|
client = AWSLambdaAsyncClientBuilder.standard() |
||||
|
.withCredentials(new AWSStaticCredentialsProvider(awsCredentials)) |
||||
|
.withRegion(config.getRegion()) |
||||
|
.withClientConfiguration(new ClientConfiguration() |
||||
|
.withConnectionTimeout((int) TimeUnit.SECONDS.toMillis(config.getConnectionTimeout())) |
||||
|
.withRequestTimeout((int) TimeUnit.SECONDS.toMillis(config.getRequestTimeout()))) |
||||
|
.build(); |
||||
|
} catch (Exception e) { |
||||
|
throw new TbNodeException(e); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public void onMsg(TbContext ctx, TbMsg msg) { |
||||
|
var tbMsg = ackIfNeeded(ctx, msg); |
||||
|
String functionName = TbNodeUtils.processPattern(config.getFunctionName(), tbMsg); |
||||
|
String qualifier = StringUtils.isBlank(config.getQualifier()) ? |
||||
|
TbAwsLambdaNodeConfiguration.DEFAULT_QUALIFIER : |
||||
|
TbNodeUtils.processPattern(config.getQualifier(), tbMsg); |
||||
|
InvokeRequest request = toRequest(tbMsg.getData(), functionName, qualifier); |
||||
|
client.invokeAsync(request, new AsyncHandler<>() { |
||||
|
@Override |
||||
|
public void onError(Exception e) { |
||||
|
tellFailure(ctx, tbMsg, e); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public void onSuccess(InvokeRequest request, InvokeResult invokeResult) { |
||||
|
try { |
||||
|
if (config.isTellFailureIfFuncThrowsExc() && invokeResult.getFunctionError() != null) { |
||||
|
throw new RuntimeException(getPayload(invokeResult)); |
||||
|
} |
||||
|
tellSuccess(ctx, getResponseMsg(tbMsg, invokeResult)); |
||||
|
} catch (Exception e) { |
||||
|
tellFailure(ctx, processException(tbMsg, invokeResult, e), e); |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
private InvokeRequest toRequest(String requestBody, String functionName, String qualifier) { |
||||
|
return new InvokeRequest() |
||||
|
.withFunctionName(functionName) |
||||
|
.withPayload(requestBody) |
||||
|
.withQualifier(qualifier); |
||||
|
} |
||||
|
|
||||
|
private String getPayload(InvokeResult invokeResult) { |
||||
|
ByteBuffer buf = invokeResult.getPayload(); |
||||
|
if (buf == null) { |
||||
|
throw new RuntimeException("Payload from result of AWS Lambda function execution is null."); |
||||
|
} |
||||
|
byte[] responseBytes = new byte[buf.remaining()]; |
||||
|
buf.get(responseBytes); |
||||
|
return new String(responseBytes); |
||||
|
} |
||||
|
|
||||
|
private TbMsg getResponseMsg(TbMsg originalMsg, InvokeResult invokeResult) { |
||||
|
TbMsgMetaData metaData = originalMsg.getMetaData().copy(); |
||||
|
metaData.putValue("requestId", invokeResult.getSdkResponseMetadata().getRequestId()); |
||||
|
String data = getPayload(invokeResult); |
||||
|
return TbMsg.transformMsg(originalMsg, metaData, data); |
||||
|
} |
||||
|
|
||||
|
private TbMsg processException(TbMsg origMsg, InvokeResult invokeResult, Throwable t) { |
||||
|
TbMsgMetaData metaData = origMsg.getMetaData().copy(); |
||||
|
metaData.putValue("error", t.getClass() + ": " + t.getMessage()); |
||||
|
metaData.putValue("requestId", invokeResult.getSdkResponseMetadata().getRequestId()); |
||||
|
return TbMsg.transformMsgMetadata(origMsg, metaData); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public void destroy() { |
||||
|
if (client != null) { |
||||
|
try { |
||||
|
client.shutdown(); |
||||
|
} catch (Exception e) { |
||||
|
log.error("Failed to shutdown Lambda client during destroy", e); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,46 @@ |
|||||
|
/** |
||||
|
* Copyright © 2016-2024 The Thingsboard Authors |
||||
|
* |
||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
|
* you may not use this file except in compliance with the License. |
||||
|
* You may obtain a copy of the License at |
||||
|
* |
||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
* |
||||
|
* Unless required by applicable law or agreed to in writing, software |
||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
|
* See the License for the specific language governing permissions and |
||||
|
* limitations under the License. |
||||
|
*/ |
||||
|
package org.thingsboard.rule.engine.aws.lambda; |
||||
|
|
||||
|
import lombok.Data; |
||||
|
import org.thingsboard.rule.engine.api.NodeConfiguration; |
||||
|
|
||||
|
@Data |
||||
|
public class TbAwsLambdaNodeConfiguration implements NodeConfiguration<TbAwsLambdaNodeConfiguration> { |
||||
|
|
||||
|
public static final String DEFAULT_QUALIFIER = "$LATEST"; |
||||
|
|
||||
|
private String accessKey; |
||||
|
private String secretKey; |
||||
|
private String region; |
||||
|
private String functionName; |
||||
|
private String qualifier; |
||||
|
private int connectionTimeout; |
||||
|
private int requestTimeout; |
||||
|
private boolean tellFailureIfFuncThrowsExc; |
||||
|
|
||||
|
@Override |
||||
|
public TbAwsLambdaNodeConfiguration defaultConfiguration() { |
||||
|
TbAwsLambdaNodeConfiguration configuration = new TbAwsLambdaNodeConfiguration(); |
||||
|
configuration.setRegion("us-east-1"); |
||||
|
configuration.setQualifier(DEFAULT_QUALIFIER); |
||||
|
configuration.setConnectionTimeout(10); |
||||
|
configuration.setRequestTimeout(5); |
||||
|
configuration.setTellFailureIfFuncThrowsExc(false); |
||||
|
return configuration; |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -0,0 +1,308 @@ |
|||||
|
/** |
||||
|
* Copyright © 2016-2024 The Thingsboard Authors |
||||
|
* |
||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
|
* you may not use this file except in compliance with the License. |
||||
|
* You may obtain a copy of the License at |
||||
|
* |
||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
* |
||||
|
* Unless required by applicable law or agreed to in writing, software |
||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
|
* See the License for the specific language governing permissions and |
||||
|
* limitations under the License. |
||||
|
*/ |
||||
|
package org.thingsboard.rule.engine.aws.lambda; |
||||
|
|
||||
|
import com.amazonaws.ResponseMetadata; |
||||
|
import com.amazonaws.handlers.AsyncHandler; |
||||
|
import com.amazonaws.services.lambda.AWSLambdaAsync; |
||||
|
import com.amazonaws.services.lambda.model.AWSLambdaException; |
||||
|
import com.amazonaws.services.lambda.model.InvokeRequest; |
||||
|
import com.amazonaws.services.lambda.model.InvokeResult; |
||||
|
import org.junit.jupiter.api.BeforeEach; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.junit.jupiter.api.extension.ExtendWith; |
||||
|
import org.junit.jupiter.params.ParameterizedTest; |
||||
|
import org.junit.jupiter.params.provider.Arguments; |
||||
|
import org.junit.jupiter.params.provider.MethodSource; |
||||
|
import org.junit.jupiter.params.provider.NullAndEmptySource; |
||||
|
import org.junit.jupiter.params.provider.ValueSource; |
||||
|
import org.mockito.ArgumentCaptor; |
||||
|
import org.mockito.Mock; |
||||
|
import org.mockito.junit.jupiter.MockitoExtension; |
||||
|
import org.springframework.test.util.ReflectionTestUtils; |
||||
|
import org.thingsboard.common.util.JacksonUtil; |
||||
|
import org.thingsboard.rule.engine.api.TbContext; |
||||
|
import org.thingsboard.rule.engine.api.TbNodeConfiguration; |
||||
|
import org.thingsboard.rule.engine.api.TbNodeException; |
||||
|
import org.thingsboard.rule.engine.api.util.TbNodeUtils; |
||||
|
import org.thingsboard.server.common.data.StringUtils; |
||||
|
import org.thingsboard.server.common.data.id.DeviceId; |
||||
|
import org.thingsboard.server.common.data.msg.TbMsgType; |
||||
|
import org.thingsboard.server.common.msg.TbMsg; |
||||
|
import org.thingsboard.server.common.msg.TbMsgMetaData; |
||||
|
|
||||
|
import java.nio.ByteBuffer; |
||||
|
import java.nio.charset.StandardCharsets; |
||||
|
import java.util.Map; |
||||
|
import java.util.UUID; |
||||
|
import java.util.stream.Stream; |
||||
|
|
||||
|
import static org.assertj.core.api.AssertionsForClassTypes.assertThat; |
||||
|
import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; |
||||
|
import static org.mockito.ArgumentMatchers.any; |
||||
|
import static org.mockito.ArgumentMatchers.eq; |
||||
|
import static org.mockito.Mockito.verify; |
||||
|
import static org.mockito.Mockito.when; |
||||
|
import static org.thingsboard.rule.engine.aws.lambda.TbAwsLambdaNodeConfiguration.DEFAULT_QUALIFIER; |
||||
|
|
||||
|
@ExtendWith(MockitoExtension.class) |
||||
|
public class TbAwsLambdaNodeTest { |
||||
|
|
||||
|
private final DeviceId DEVICE_ID = new DeviceId(UUID.fromString("ddb88645-7379-4a08-a51c-e84a0b4b3d88")); |
||||
|
|
||||
|
private TbAwsLambdaNode node; |
||||
|
private TbAwsLambdaNodeConfiguration config; |
||||
|
|
||||
|
@Mock |
||||
|
private TbContext ctx; |
||||
|
@Mock |
||||
|
private AWSLambdaAsync clientMock; |
||||
|
|
||||
|
@BeforeEach |
||||
|
void setUp() { |
||||
|
node = new TbAwsLambdaNode(); |
||||
|
config = new TbAwsLambdaNodeConfiguration().defaultConfiguration(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void verifyDefaultConfig() { |
||||
|
assertThat(config.getAccessKey()).isNull(); |
||||
|
assertThat(config.getSecretKey()).isNull(); |
||||
|
assertThat(config.getRegion()).isEqualTo(("us-east-1")); |
||||
|
assertThat(config.getFunctionName()).isNull(); |
||||
|
assertThat(config.getQualifier()).isEqualTo(DEFAULT_QUALIFIER); |
||||
|
assertThat(config.getConnectionTimeout()).isEqualTo(10); |
||||
|
assertThat(config.getRequestTimeout()).isEqualTo(5); |
||||
|
assertThat(config.isTellFailureIfFuncThrowsExc()).isFalse(); |
||||
|
} |
||||
|
|
||||
|
@ParameterizedTest |
||||
|
@NullAndEmptySource |
||||
|
@ValueSource(strings = " ") |
||||
|
public void givenInvalidFunctionName_whenInit_thenThrowsException(String funcName) { |
||||
|
config.setFunctionName(funcName); |
||||
|
var configuration = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); |
||||
|
assertThatThrownBy(() -> node.init(ctx, configuration)) |
||||
|
.isInstanceOf(TbNodeException.class) |
||||
|
.hasMessage("Function name must be set!"); |
||||
|
} |
||||
|
|
||||
|
@ParameterizedTest |
||||
|
@MethodSource |
||||
|
public void givenRequest_whenOnMsg_thenTellSuccess(String data, TbMsgMetaData metadata, String functionName, String qualifier, String expectedQualifier) { |
||||
|
init(); |
||||
|
config.setFunctionName(functionName); |
||||
|
config.setQualifier(qualifier); |
||||
|
|
||||
|
TbMsg msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, DEVICE_ID, metadata, data); |
||||
|
|
||||
|
InvokeRequest request = createInvokeRequest(msg); |
||||
|
String requestIdStr = "a124af57-e7c3-4ebb-83bf-b09ff86eaa23"; |
||||
|
String funcResponsePayload = "{\"statusCode\":200}"; |
||||
|
|
||||
|
when(clientMock.invokeAsync(any(), any())).then(invocation -> { |
||||
|
InvokeResult result = new InvokeResult(); |
||||
|
result.setSdkResponseMetadata(new ResponseMetadata(Map.of(ResponseMetadata.AWS_REQUEST_ID, requestIdStr))); |
||||
|
result.setPayload(ByteBuffer.wrap(funcResponsePayload.getBytes())); |
||||
|
AsyncHandler<InvokeRequest, InvokeResult> asyncHandler = invocation.getArgument(1); |
||||
|
asyncHandler.onSuccess(request, result); |
||||
|
return null; |
||||
|
}); |
||||
|
|
||||
|
node.onMsg(ctx, msg); |
||||
|
|
||||
|
ArgumentCaptor<InvokeRequest> invokeRequestCaptor = ArgumentCaptor.forClass(InvokeRequest.class); |
||||
|
ArgumentCaptor<TbMsg> msgCaptor = ArgumentCaptor.forClass(TbMsg.class); |
||||
|
|
||||
|
verify(clientMock).invokeAsync(invokeRequestCaptor.capture(), any()); |
||||
|
verify(ctx).tellSuccess(msgCaptor.capture()); |
||||
|
|
||||
|
assertThat(invokeRequestCaptor.getValue().getQualifier()).isEqualTo(expectedQualifier); |
||||
|
TbMsgMetaData resultMsgMetadata = metadata.copy(); |
||||
|
resultMsgMetadata.putValue("requestId", requestIdStr); |
||||
|
TbMsg resultedMsg = TbMsg.transformMsg(msg, resultMsgMetadata, funcResponsePayload); |
||||
|
assertThat(msgCaptor.getValue()).usingRecursiveComparison() |
||||
|
.ignoringFields("ctx") |
||||
|
.isEqualTo(resultedMsg); |
||||
|
} |
||||
|
|
||||
|
private static Stream<Arguments> givenRequest_whenOnMsg_thenTellSuccess() { |
||||
|
return Stream.of( |
||||
|
Arguments.of(TbMsg.EMPTY_JSON_OBJECT, TbMsgMetaData.EMPTY, "functionA", "qualifierA", "qualifierA"), |
||||
|
Arguments.of(TbMsg.EMPTY_JSON_OBJECT, TbMsgMetaData.EMPTY, "functionA", null, DEFAULT_QUALIFIER), |
||||
|
Arguments.of("{\"funcNameMsgPattern\": \"functionB\", \"qualifierMsgPattern\": \"qualifierB\"}", |
||||
|
TbMsgMetaData.EMPTY, "$[funcNameMsgPattern]", "$[qualifierMsgPattern]", "qualifierB"), |
||||
|
Arguments.of(TbMsg.EMPTY_JSON_OBJECT, |
||||
|
new TbMsgMetaData( |
||||
|
Map.of( |
||||
|
"funcNameMdPattern", "functionC", |
||||
|
"qualifierMdPattern", "qualifierC") |
||||
|
), "${funcNameMdPattern}", "${qualifierMdPattern}", "qualifierC") |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenExceptionWasThrownInsideFunctionAndTellFailureIfFuncThrowsExcIsTrue_whenOnMsg_thenTellFailure() { |
||||
|
init(); |
||||
|
config.setTellFailureIfFuncThrowsExc(true); |
||||
|
|
||||
|
TbMsg msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, DEVICE_ID, TbMsgMetaData.EMPTY, TbMsg.EMPTY_JSON_ARRAY); |
||||
|
InvokeRequest request = createInvokeRequest(msg); |
||||
|
String requestIdStr = "a124af57-e7c3-4ebb-83bf-b09ff86eaa23"; |
||||
|
String errorMsg = "Unhandled exception from function"; |
||||
|
|
||||
|
when(clientMock.invokeAsync(any(), any())).then(invocation -> { |
||||
|
InvokeResult result = new InvokeResult(); |
||||
|
result.setPayload(ByteBuffer.wrap(errorMsg.getBytes(StandardCharsets.UTF_8))); |
||||
|
result.setFunctionError(errorMsg); |
||||
|
result.setSdkResponseMetadata(new ResponseMetadata(Map.of(ResponseMetadata.AWS_REQUEST_ID, requestIdStr))); |
||||
|
AsyncHandler<InvokeRequest, InvokeResult> asyncHandler = invocation.getArgument(1); |
||||
|
asyncHandler.onSuccess(request, result); |
||||
|
return null; |
||||
|
}); |
||||
|
|
||||
|
node.onMsg(ctx, msg); |
||||
|
|
||||
|
ArgumentCaptor<Throwable> throwableCaptor = ArgumentCaptor.forClass(Throwable.class); |
||||
|
ArgumentCaptor<TbMsg> msgCaptor = ArgumentCaptor.forClass(TbMsg.class); |
||||
|
|
||||
|
verify(clientMock).invokeAsync(eq(request), any()); |
||||
|
verify(ctx).tellFailure(msgCaptor.capture(), throwableCaptor.capture()); |
||||
|
|
||||
|
var metadata = Map.of("error", RuntimeException.class + ": " + errorMsg, "requestId", requestIdStr); |
||||
|
TbMsg resultedMsg = TbMsg.transformMsgMetadata(msg, new TbMsgMetaData(metadata)); |
||||
|
|
||||
|
assertThat(msgCaptor.getValue()).usingRecursiveComparison() |
||||
|
.ignoringFields("ctx") |
||||
|
.isEqualTo(resultedMsg); |
||||
|
assertThat(throwableCaptor.getValue()).isInstanceOf(RuntimeException.class).hasMessage(errorMsg); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenExceptionWasThrownInsideFunctionAndTellFailureIfFuncThrowsExcIsFalse_whenOnMsg_thenTellSuccess() { |
||||
|
init(); |
||||
|
|
||||
|
TbMsg msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, DEVICE_ID, TbMsgMetaData.EMPTY, TbMsg.EMPTY_JSON_OBJECT); |
||||
|
InvokeRequest request = createInvokeRequest(msg); |
||||
|
String requestIdStr = "e83dfbc4-68d5-441c-8ee9-289959a30d3b"; |
||||
|
String payload = "{\"errorMessage\":\"Something went wrong\",\"errorType\":\"Exception\",\"requestId\":\"" + requestIdStr + "\"}"; |
||||
|
|
||||
|
when(clientMock.invokeAsync(any(), any())).then(invocation -> { |
||||
|
InvokeResult result = new InvokeResult(); |
||||
|
result.setSdkResponseMetadata(new ResponseMetadata(Map.of("AWS_REQUEST_ID", requestIdStr))); |
||||
|
result.setPayload(ByteBuffer.wrap(payload.getBytes())); |
||||
|
AsyncHandler<InvokeRequest, InvokeResult> asyncHandler = invocation.getArgument(1); |
||||
|
asyncHandler.onSuccess(request, result); |
||||
|
return null; |
||||
|
}); |
||||
|
|
||||
|
node.onMsg(ctx, msg); |
||||
|
|
||||
|
ArgumentCaptor<TbMsg> msgCaptor = ArgumentCaptor.forClass(TbMsg.class); |
||||
|
|
||||
|
verify(clientMock).invokeAsync(eq(request), any()); |
||||
|
verify(ctx).tellSuccess(msgCaptor.capture()); |
||||
|
|
||||
|
Map<String, String> metadata = Map.of("requestId", requestIdStr); |
||||
|
TbMsg resultedMsg = TbMsg.transformMsg(msg, new TbMsgMetaData(metadata), payload); |
||||
|
|
||||
|
assertThat(msgCaptor.getValue()).usingRecursiveComparison() |
||||
|
.ignoringFields("ctx") |
||||
|
.isEqualTo(resultedMsg); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenPayloadFromResultIsNull_whenOnMsg_thenTellFailure() { |
||||
|
init(); |
||||
|
|
||||
|
TbMsg msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, DEVICE_ID, TbMsgMetaData.EMPTY, TbMsg.EMPTY_JSON_OBJECT); |
||||
|
InvokeRequest request = createInvokeRequest(msg); |
||||
|
String requestIdStr = "12bbb074-e2fc-4381-8f28-d4bd235103d5"; |
||||
|
String errorMsg = "Payload from result of AWS Lambda function execution is null."; |
||||
|
|
||||
|
when(clientMock.invokeAsync(any(), any())).then(invocation -> { |
||||
|
InvokeResult result = new InvokeResult(); |
||||
|
result.setSdkResponseMetadata(new ResponseMetadata(Map.of(ResponseMetadata.AWS_REQUEST_ID, requestIdStr))); |
||||
|
AsyncHandler<InvokeRequest, InvokeResult> asyncHandler = invocation.getArgument(1); |
||||
|
asyncHandler.onSuccess(request, result); |
||||
|
return null; |
||||
|
}); |
||||
|
|
||||
|
node.onMsg(ctx, msg); |
||||
|
|
||||
|
verify(clientMock).invokeAsync(eq(request), any()); |
||||
|
|
||||
|
ArgumentCaptor<Throwable> throwableCaptor = ArgumentCaptor.forClass(Throwable.class); |
||||
|
ArgumentCaptor<TbMsg> msgCaptor = ArgumentCaptor.forClass(TbMsg.class); |
||||
|
|
||||
|
verify(ctx).tellFailure(msgCaptor.capture(), throwableCaptor.capture()); |
||||
|
|
||||
|
var metadata = Map.of("error", RuntimeException.class + ": " + errorMsg, "requestId", requestIdStr); |
||||
|
TbMsg resultedMsg = TbMsg.transformMsgMetadata(msg, new TbMsgMetaData(metadata)); |
||||
|
|
||||
|
assertThat(msgCaptor.getValue()).usingRecursiveComparison() |
||||
|
.ignoringFields("ctx") |
||||
|
.isEqualTo(resultedMsg); |
||||
|
assertThat(throwableCaptor.getValue()).isInstanceOf(RuntimeException.class).hasMessage(errorMsg); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenExceptionWasThrownOnAWS_whenOnMsg_thenTellFailure() { |
||||
|
init(); |
||||
|
TbMsg msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, DEVICE_ID, TbMsgMetaData.EMPTY, TbMsg.EMPTY_JSON_OBJECT); |
||||
|
InvokeRequest request = createInvokeRequest(msg); |
||||
|
|
||||
|
String errorMsg = "Simulated error"; |
||||
|
when(clientMock.invokeAsync(any(), any())).then(invocation -> { |
||||
|
AsyncHandler<InvokeRequest, InvokeResult> asyncHandler = invocation.getArgument(1); |
||||
|
asyncHandler.onError(new AWSLambdaException(errorMsg)); |
||||
|
return null; |
||||
|
}); |
||||
|
|
||||
|
node.onMsg(ctx, msg); |
||||
|
|
||||
|
verify(clientMock).invokeAsync(eq(request), any()); |
||||
|
ArgumentCaptor<Throwable> throwableCaptor = ArgumentCaptor.forClass(Throwable.class); |
||||
|
verify(ctx).tellFailure(eq(msg), throwableCaptor.capture()); |
||||
|
assertThat(throwableCaptor.getValue()).isInstanceOf(AWSLambdaException.class).hasMessageStartingWith(errorMsg); |
||||
|
} |
||||
|
|
||||
|
private void init() { |
||||
|
config.setAccessKey("accessKey"); |
||||
|
config.setSecretKey("secretKey"); |
||||
|
config.setFunctionName("new-function"); |
||||
|
ReflectionTestUtils.setField(node, "client", clientMock); |
||||
|
ReflectionTestUtils.setField(node, "config", config); |
||||
|
} |
||||
|
|
||||
|
private InvokeRequest createInvokeRequest(TbMsg msg) { |
||||
|
return new InvokeRequest() |
||||
|
.withFunctionName(getFunctionName(msg)) |
||||
|
.withPayload(msg.getData()) |
||||
|
.withQualifier(getQualifier(msg)); |
||||
|
} |
||||
|
|
||||
|
private String getQualifier(TbMsg msg) { |
||||
|
return StringUtils.isBlank(config.getQualifier()) ? |
||||
|
TbAwsLambdaNodeConfiguration.DEFAULT_QUALIFIER : |
||||
|
TbNodeUtils.processPattern(config.getQualifier(), msg); |
||||
|
} |
||||
|
|
||||
|
private String getFunctionName(TbMsg msg) { |
||||
|
return TbNodeUtils.processPattern(config.getFunctionName(), msg); |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -0,0 +1,83 @@ |
|||||
|
# Rule nodes fields templatization |
||||
|
|
||||
|
Templatization is the process of using predefined templates to dynamically insert or substitute values into text. |
||||
|
These templates serve as placeholders for variables that can be filled in later with actual data. |
||||
|
|
||||
|
In the context of rule engine, templates are used to extract data from incoming messages during runtime. |
||||
|
This is particularly helpful in the rule node configuration, where templatization allows for dynamic configuration by replacing static values in the configuration fields with real-time values from the incoming messages. |
||||
|
This enables more flexible and automated handling of data, making it easier to perform conditional operations based on varying inputs. |
||||
|
|
||||
|
## Syntax |
||||
|
|
||||
|
Templates start with a dollar sign (`$`), followed by brackets with a key name inside. |
||||
|
Square brackets (`[]`) are used for message keys, while curly brackets (`{}`) are used for message metadata keys. |
||||
|
For example: |
||||
|
- `$[messageKey]` - will extract value of `messageKey` from incoming message. |
||||
|
- `${metadataKey}` - will extract value of `metadataKey` from incoming message metadata. |
||||
|
|
||||
|
In the example above, `messageKey` and `metadataKey` represent any key name that may exist within the message or its metadata. |
||||
|
|
||||
|
## Example |
||||
|
|
||||
|
Let's review an example. First JSON is message, second is message metadata: |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"temperature": 26.5, |
||||
|
"humidity": 75.2, |
||||
|
"soilMoisture": 28.9, |
||||
|
"windSpeed": 26.2, |
||||
|
"location": "riverside" |
||||
|
} |
||||
|
``` |
||||
|
```json |
||||
|
{ |
||||
|
"deviceType": "weather_sensor", |
||||
|
"deviceName": "weather1", |
||||
|
"ts": "1685379440000" |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
Assume, we detected an unusually high wind speed and want to send this telemetry reading to some external REST API. |
||||
|
Every reading needs to be associated with specific device and location - this information is available only in real-time. |
||||
|
We can use templates extract necessary data and to construct URL for sending data: |
||||
|
|
||||
|
`example-base-url.com/report-reading?location=$[location]&deviceName=${deviceName}` |
||||
|
|
||||
|
This template will be resolved to: |
||||
|
|
||||
|
`example-base-url.com/report-reading?location=riverside&deviceName=weather1` |
||||
|
|
||||
|
Templates are ideal for scenarios where the specific values aren't known at the time of configuration but will become available at runtime. |
||||
|
|
||||
|
## Notes |
||||
|
|
||||
|
- Templates can be combined with regular text. For example: "Fuel tanks are filled to `$[fuelLevel]`%". |
||||
|
- You can access nested keys in JSON object using dot notation: `$[object.key]`. |
||||
|
- If specified key is missing or value associated with that key is an object or an array, then template string will be returned unchanged. |
||||
|
|
||||
|
To illustrate written above let's review an example. Here's content of a message: |
||||
|
```json |
||||
|
{ |
||||
|
"number": 123.45, |
||||
|
"string": "text", |
||||
|
"boolean": true, |
||||
|
"array": [1, 2, 3], |
||||
|
"object": { |
||||
|
"property": "propertyValue" |
||||
|
}, |
||||
|
"null": null |
||||
|
} |
||||
|
``` |
||||
|
Here's a table with comparison between templates and extracted values: |
||||
|
|
||||
|
| **Template** | **Extracted value** | |
||||
|
|--------------------|---------------------| |
||||
|
| $[number] | 123.45 | |
||||
|
| $[string] | text | |
||||
|
| $[boolean] | true | |
||||
|
| $[array] | $[array] | |
||||
|
| $[object] | $[object] | |
||||
|
| $[object.property] | propertyValue | |
||||
|
| $[null] | null | |
||||
|
| $[doesNotExist] | $[doesNotExist] | |
||||
Loading…
Reference in new issue