committed by
GitHub
35 changed files with 2415 additions and 28 deletions
@ -0,0 +1,237 @@ |
|||||
|
/** |
||||
|
* 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.server.controller; |
||||
|
|
||||
|
import com.google.common.util.concurrent.FutureCallback; |
||||
|
import io.swagger.v3.oas.annotations.Parameter; |
||||
|
import jakarta.annotation.Nullable; |
||||
|
import lombok.extern.slf4j.Slf4j; |
||||
|
import org.springframework.beans.factory.annotation.Autowired; |
||||
|
import org.springframework.http.HttpStatus; |
||||
|
import org.springframework.http.ResponseEntity; |
||||
|
import org.springframework.security.access.prepost.PreAuthorize; |
||||
|
import org.springframework.web.bind.annotation.PathVariable; |
||||
|
import org.springframework.web.bind.annotation.RequestBody; |
||||
|
import org.springframework.web.bind.annotation.RequestMapping; |
||||
|
import org.springframework.web.bind.annotation.RequestMethod; |
||||
|
import org.springframework.web.bind.annotation.ResponseBody; |
||||
|
import org.springframework.web.bind.annotation.RestController; |
||||
|
import org.springframework.web.context.request.async.DeferredResult; |
||||
|
import org.thingsboard.common.util.JacksonUtil; |
||||
|
import org.thingsboard.server.common.data.StringUtils; |
||||
|
import org.thingsboard.server.common.data.audit.ActionType; |
||||
|
import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; |
||||
|
import org.thingsboard.server.common.data.exception.ThingsboardException; |
||||
|
import org.thingsboard.server.common.data.id.EntityId; |
||||
|
import org.thingsboard.server.common.data.id.EntityIdFactory; |
||||
|
import org.thingsboard.server.common.data.msg.TbMsgType; |
||||
|
import org.thingsboard.server.common.msg.TbMsg; |
||||
|
import org.thingsboard.server.common.msg.TbMsgMetaData; |
||||
|
import org.thingsboard.server.config.annotations.ApiOperation; |
||||
|
import org.thingsboard.server.exception.ToErrorResponseEntity; |
||||
|
import org.thingsboard.server.queue.util.TbCoreComponent; |
||||
|
import org.thingsboard.server.service.ruleengine.RuleEngineCallService; |
||||
|
import org.thingsboard.server.service.security.AccessValidator; |
||||
|
import org.thingsboard.server.service.security.model.SecurityUser; |
||||
|
import org.thingsboard.server.service.security.permission.Operation; |
||||
|
|
||||
|
import java.util.HashMap; |
||||
|
import java.util.UUID; |
||||
|
import java.util.concurrent.TimeoutException; |
||||
|
|
||||
|
import static org.thingsboard.server.controller.ControllerConstants.ENTITY_ID_PARAM_DESCRIPTION; |
||||
|
import static org.thingsboard.server.controller.ControllerConstants.ENTITY_TYPE_PARAM_DESCRIPTION; |
||||
|
|
||||
|
@RestController |
||||
|
@TbCoreComponent |
||||
|
@RequestMapping(TbUrlConstants.RULE_ENGINE_URL_PREFIX) |
||||
|
@Slf4j |
||||
|
public class RuleEngineController extends BaseController { |
||||
|
public static final int DEFAULT_TIMEOUT = 10000; |
||||
|
private static final String MSG_DESCRIPTION_PREFIX = "Creates the Message with type 'REST_API_REQUEST' and payload taken from the request body. "; |
||||
|
private static final String MSG_DESCRIPTION = "This method allows you to extend the regular platform API with the power of Rule Engine. You may use default and custom rule nodes to handle the message. " + |
||||
|
"The generated message contains two important metadata fields:\n\n" + |
||||
|
" * **'serviceId'** to identify the platform server that received the request;\n" + |
||||
|
" * **'requestUUID'** to identify the request and route possible response from the Rule Engine;\n\n" + |
||||
|
"Use **'rest call reply'** rule node to push the reply from rule engine back as a REST API call response. "; |
||||
|
|
||||
|
@Autowired |
||||
|
private RuleEngineCallService ruleEngineCallService; |
||||
|
@Autowired |
||||
|
private AccessValidator accessValidator; |
||||
|
|
||||
|
@ApiOperation(value = "Push user message to the rule engine (handleRuleEngineRequest)", |
||||
|
notes = MSG_DESCRIPTION_PREFIX + |
||||
|
"Uses current User Id ( the one which credentials is used to perform the request) as the Rule Engine message originator. " + |
||||
|
MSG_DESCRIPTION + |
||||
|
"The default timeout of the request processing is 10 seconds." |
||||
|
+ "\n\n" + ControllerConstants.SECURITY_WRITE_CHECK) |
||||
|
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") |
||||
|
@RequestMapping(value = "/", method = RequestMethod.POST) |
||||
|
@ResponseBody |
||||
|
public DeferredResult<ResponseEntity> handleRuleEngineRequest( |
||||
|
@Parameter(description = "A JSON value representing the message.", required = true) |
||||
|
@RequestBody String requestBody) throws ThingsboardException { |
||||
|
return handleRuleEngineRequest(null, null, null, DEFAULT_TIMEOUT, requestBody); |
||||
|
} |
||||
|
|
||||
|
@ApiOperation(value = "Push entity message to the rule engine (handleRuleEngineRequest)", |
||||
|
notes = MSG_DESCRIPTION_PREFIX + |
||||
|
"Uses specified Entity Id as the Rule Engine message originator. " + |
||||
|
MSG_DESCRIPTION + |
||||
|
"The default timeout of the request processing is 10 seconds." |
||||
|
+ "\n\n" + ControllerConstants.SECURITY_WRITE_CHECK) |
||||
|
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") |
||||
|
@RequestMapping(value = "/{entityType}/{entityId}", method = RequestMethod.POST) |
||||
|
@ResponseBody |
||||
|
public DeferredResult<ResponseEntity> handleRuleEngineRequest( |
||||
|
@Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) |
||||
|
@PathVariable("entityType") String entityType, |
||||
|
@Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) |
||||
|
@PathVariable("entityId") String entityIdStr, |
||||
|
@Parameter(description = "A JSON value representing the message.", required = true) |
||||
|
@RequestBody String requestBody) throws ThingsboardException { |
||||
|
return handleRuleEngineRequest(entityType, entityIdStr, null, DEFAULT_TIMEOUT, requestBody); |
||||
|
} |
||||
|
|
||||
|
@ApiOperation(value = "Push entity message with timeout to the rule engine (handleRuleEngineRequest)", |
||||
|
notes = MSG_DESCRIPTION_PREFIX + |
||||
|
"Uses specified Entity Id as the Rule Engine message originator. " + |
||||
|
MSG_DESCRIPTION + |
||||
|
"The platform expects the timeout value in milliseconds." |
||||
|
+ "\n\n" + ControllerConstants.SECURITY_WRITE_CHECK) |
||||
|
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") |
||||
|
@RequestMapping(value = "/{entityType}/{entityId}/{timeout}", method = RequestMethod.POST) |
||||
|
@ResponseBody |
||||
|
public DeferredResult<ResponseEntity> handleRuleEngineRequest( |
||||
|
@Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) |
||||
|
@PathVariable("entityType") String entityType, |
||||
|
@Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) |
||||
|
@PathVariable("entityId") String entityIdStr, |
||||
|
@Parameter(description = "Timeout to process the request in milliseconds", required = true) |
||||
|
@PathVariable("timeout") int timeout, |
||||
|
@Parameter(description = "A JSON value representing the message.", required = true) |
||||
|
@RequestBody String requestBody) throws ThingsboardException { |
||||
|
return handleRuleEngineRequest(entityType, entityIdStr, null, timeout, requestBody); |
||||
|
} |
||||
|
|
||||
|
@ApiOperation(value = "Push entity message with timeout and specified queue to the rule engine (handleRuleEngineRequest)", |
||||
|
notes = MSG_DESCRIPTION_PREFIX + |
||||
|
"Uses specified Entity Id as the Rule Engine message originator. " + |
||||
|
MSG_DESCRIPTION + |
||||
|
"If request sent for Device/Device Profile or Asset/Asset Profile entity, specified queue will be used instead of the queue selected in the device or asset profile. " + |
||||
|
"The platform expects the timeout value in milliseconds." |
||||
|
+ "\n\n" + ControllerConstants.SECURITY_WRITE_CHECK) |
||||
|
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") |
||||
|
@RequestMapping(value = "/{entityType}/{entityId}/{queueName}/{timeout}", method = RequestMethod.POST) |
||||
|
@ResponseBody |
||||
|
public DeferredResult<ResponseEntity> handleRuleEngineRequest( |
||||
|
@Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) |
||||
|
@PathVariable("entityType") String entityType, |
||||
|
@Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) |
||||
|
@PathVariable("entityId") String entityIdStr, |
||||
|
@Parameter(description = "Queue name to process the request in the rule engine", required = true) |
||||
|
@PathVariable("queueName") String queueName, |
||||
|
@Parameter(description = "Timeout to process the request in milliseconds", required = true) |
||||
|
@PathVariable("timeout") int timeout, |
||||
|
@Parameter(description = "A JSON value representing the message.", required = true) |
||||
|
@RequestBody String requestBody) throws ThingsboardException { |
||||
|
try { |
||||
|
SecurityUser currentUser = getCurrentUser(); |
||||
|
EntityId entityId; |
||||
|
if (StringUtils.isEmpty(entityType) || StringUtils.isEmpty(entityIdStr)) { |
||||
|
entityId = currentUser.getId(); |
||||
|
} else { |
||||
|
entityId = EntityIdFactory.getByTypeAndId(entityType, entityIdStr); |
||||
|
} |
||||
|
//Check that this is a valid JSON
|
||||
|
JacksonUtil.toJsonNode(requestBody); |
||||
|
final DeferredResult<ResponseEntity> response = new DeferredResult<>(); |
||||
|
accessValidator.validate(currentUser, Operation.WRITE, entityId, new HttpValidationCallback(response, new FutureCallback<DeferredResult<ResponseEntity>>() { |
||||
|
@Override |
||||
|
public void onSuccess(@Nullable DeferredResult<ResponseEntity> result) { |
||||
|
long expTime = System.currentTimeMillis() + timeout; |
||||
|
HashMap<String, String> metaData = new HashMap<>(); |
||||
|
UUID requestId = UUID.randomUUID(); |
||||
|
metaData.put("serviceId", serviceInfoProvider.getServiceId()); |
||||
|
metaData.put("requestUUID", requestId.toString()); |
||||
|
metaData.put("expirationTime", Long.toString(expTime)); |
||||
|
TbMsg msg = TbMsg.newMsg(queueName, TbMsgType.REST_API_REQUEST, entityId, currentUser.getCustomerId(), new TbMsgMetaData(metaData), requestBody); |
||||
|
ruleEngineCallService.processRestApiCallToRuleEngine(currentUser.getTenantId(), requestId, msg, queueName != null, |
||||
|
reply -> reply(new LocalRequestMetaData(msg, currentUser, result), reply)); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public void onFailure(Throwable e) { |
||||
|
ResponseEntity entity; |
||||
|
if (e instanceof ToErrorResponseEntity) { |
||||
|
entity = ((ToErrorResponseEntity) e).toErrorResponseEntity(); |
||||
|
} else { |
||||
|
entity = new ResponseEntity(HttpStatus.UNAUTHORIZED); |
||||
|
} |
||||
|
logRuleEngineCall(currentUser, entityId, requestBody, null, e); |
||||
|
response.setResult(entity); |
||||
|
} |
||||
|
})); |
||||
|
return response; |
||||
|
} catch (IllegalArgumentException iae) { |
||||
|
throw new ThingsboardException("Invalid request body", iae, ThingsboardErrorCode.BAD_REQUEST_PARAMS); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private void reply(LocalRequestMetaData rpcRequest, TbMsg response) { |
||||
|
DeferredResult<ResponseEntity> responseWriter = rpcRequest.responseWriter(); |
||||
|
if (response == null) { |
||||
|
logRuleEngineCall(rpcRequest, null, new TimeoutException("Processing timeout detected!")); |
||||
|
responseWriter.setResult(new ResponseEntity<>(HttpStatus.REQUEST_TIMEOUT)); |
||||
|
} else { |
||||
|
String responseData = response.getData(); |
||||
|
if (!StringUtils.isEmpty(responseData)) { |
||||
|
try { |
||||
|
logRuleEngineCall(rpcRequest, response, null); |
||||
|
responseWriter.setResult(new ResponseEntity<>(JacksonUtil.toJsonNode(responseData), HttpStatus.OK)); |
||||
|
} catch (IllegalArgumentException e) { |
||||
|
log.debug("Failed to decode device response: {}", responseData, e); |
||||
|
logRuleEngineCall(rpcRequest, response, e); |
||||
|
responseWriter.setResult(new ResponseEntity<>(HttpStatus.NOT_ACCEPTABLE)); |
||||
|
} |
||||
|
} else { |
||||
|
logRuleEngineCall(rpcRequest, response, null); |
||||
|
responseWriter.setResult(new ResponseEntity<>(HttpStatus.OK)); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private void logRuleEngineCall(LocalRequestMetaData rpcRequest, TbMsg response, Throwable e) { |
||||
|
logRuleEngineCall(rpcRequest.user(), rpcRequest.request().getOriginator(), rpcRequest.request().getData(), response, e); |
||||
|
} |
||||
|
|
||||
|
private void logRuleEngineCall(SecurityUser user, EntityId entityId, String request, TbMsg response, Throwable e) { |
||||
|
auditLogService.logEntityAction( |
||||
|
user.getTenantId(), |
||||
|
user.getCustomerId(), |
||||
|
user.getId(), |
||||
|
user.getName(), |
||||
|
entityId, |
||||
|
null, |
||||
|
ActionType.REST_API_RULE_ENGINE_CALL, |
||||
|
BaseController.toException(e), |
||||
|
request, |
||||
|
response != null ? response.getData() : ""); |
||||
|
} |
||||
|
|
||||
|
private record LocalRequestMetaData(TbMsg request, SecurityUser user, DeferredResult<ResponseEntity> responseWriter) {} |
||||
|
} |
||||
@ -0,0 +1,100 @@ |
|||||
|
/** |
||||
|
* 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.server.service.ruleengine; |
||||
|
|
||||
|
import jakarta.annotation.PostConstruct; |
||||
|
import jakarta.annotation.PreDestroy; |
||||
|
import lombok.extern.slf4j.Slf4j; |
||||
|
import org.springframework.stereotype.Service; |
||||
|
import org.thingsboard.common.util.ThingsBoardThreadFactory; |
||||
|
import org.thingsboard.server.cluster.TbClusterService; |
||||
|
import org.thingsboard.server.common.data.id.TenantId; |
||||
|
import org.thingsboard.server.common.msg.TbMsg; |
||||
|
import org.thingsboard.server.common.msg.queue.TbCallback; |
||||
|
import org.thingsboard.server.common.msg.queue.TbMsgCallback; |
||||
|
import org.thingsboard.server.gen.transport.TransportProtos; |
||||
|
|
||||
|
import java.util.UUID; |
||||
|
import java.util.concurrent.ConcurrentHashMap; |
||||
|
import java.util.concurrent.ConcurrentMap; |
||||
|
import java.util.concurrent.Executors; |
||||
|
import java.util.concurrent.ScheduledExecutorService; |
||||
|
import java.util.concurrent.TimeUnit; |
||||
|
import java.util.function.Consumer; |
||||
|
|
||||
|
@Service |
||||
|
@Slf4j |
||||
|
public class DefaultRuleEngineCallService implements RuleEngineCallService { |
||||
|
|
||||
|
private final TbClusterService clusterService; |
||||
|
|
||||
|
private ScheduledExecutorService executor; |
||||
|
|
||||
|
private final ConcurrentMap<UUID, Consumer<TbMsg>> requests = new ConcurrentHashMap<>(); |
||||
|
|
||||
|
public DefaultRuleEngineCallService(TbClusterService clusterService) { |
||||
|
this.clusterService = clusterService; |
||||
|
} |
||||
|
|
||||
|
@PostConstruct |
||||
|
public void initExecutor() { |
||||
|
executor = Executors.newSingleThreadScheduledExecutor(ThingsBoardThreadFactory.forName("re-rest-callback")); |
||||
|
} |
||||
|
|
||||
|
@PreDestroy |
||||
|
public void shutdownExecutor() { |
||||
|
if (executor != null) { |
||||
|
executor.shutdownNow(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public void processRestApiCallToRuleEngine(TenantId tenantId, UUID requestId, TbMsg request, boolean useQueueFromTbMsg, Consumer<TbMsg> responseConsumer) { |
||||
|
log.trace("[{}] Processing REST API call to rule engine: [{}] for entity: [{}]", tenantId, requestId, request.getOriginator()); |
||||
|
requests.put(requestId, responseConsumer); |
||||
|
sendRequestToRuleEngine(tenantId, request, useQueueFromTbMsg); |
||||
|
scheduleTimeout(request, requestId, requests); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public void onQueueMsg(TransportProtos.RestApiCallResponseMsgProto restApiCallResponseMsg, TbCallback callback) { |
||||
|
UUID requestId = new UUID(restApiCallResponseMsg.getRequestIdMSB(), restApiCallResponseMsg.getRequestIdLSB()); |
||||
|
Consumer<TbMsg> consumer = requests.remove(requestId); |
||||
|
if (consumer != null) { |
||||
|
consumer.accept(TbMsg.fromBytes(null, restApiCallResponseMsg.getResponse().toByteArray(), TbMsgCallback.EMPTY)); |
||||
|
} else { |
||||
|
log.trace("[{}] Unknown or stale rest api call response received", requestId); |
||||
|
} |
||||
|
callback.onSuccess(); |
||||
|
} |
||||
|
|
||||
|
private void sendRequestToRuleEngine(TenantId tenantId, TbMsg msg, boolean useQueueFromTbMsg) { |
||||
|
clusterService.pushMsgToRuleEngine(tenantId, msg.getOriginator(), msg, useQueueFromTbMsg, null); |
||||
|
} |
||||
|
|
||||
|
private void scheduleTimeout(TbMsg request, UUID requestId, ConcurrentMap<UUID, Consumer<TbMsg>> requestsMap) { |
||||
|
long expirationTime = Long.parseLong(request.getMetaData().getValue("expirationTime")); |
||||
|
long timeout = Math.max(0, expirationTime - System.currentTimeMillis()); |
||||
|
log.trace("[{}] processing the request: [{}]", this.hashCode(), requestId); |
||||
|
executor.schedule(() -> { |
||||
|
Consumer<TbMsg> consumer = requestsMap.remove(requestId); |
||||
|
if (consumer != null) { |
||||
|
log.trace("[{}] request timeout detected: [{}]", this.hashCode(), requestId); |
||||
|
consumer.accept(null); |
||||
|
} |
||||
|
}, timeout, TimeUnit.MILLISECONDS); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,31 @@ |
|||||
|
/** |
||||
|
* 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.server.service.ruleengine; |
||||
|
|
||||
|
import org.thingsboard.server.common.data.id.TenantId; |
||||
|
import org.thingsboard.server.common.msg.TbMsg; |
||||
|
import org.thingsboard.server.common.msg.queue.TbCallback; |
||||
|
import org.thingsboard.server.gen.transport.TransportProtos; |
||||
|
|
||||
|
import java.util.UUID; |
||||
|
import java.util.function.Consumer; |
||||
|
|
||||
|
public interface RuleEngineCallService { |
||||
|
|
||||
|
void processRestApiCallToRuleEngine(TenantId tenantId, UUID requestId, TbMsg request, boolean useQueueFromTbMsg, Consumer<TbMsg> responseConsumer); |
||||
|
|
||||
|
void onQueueMsg(TransportProtos.RestApiCallResponseMsgProto restApiCallResponseMsg, TbCallback callback); |
||||
|
} |
||||
@ -0,0 +1,249 @@ |
|||||
|
/** |
||||
|
* 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.server.controller; |
||||
|
|
||||
|
import com.fasterxml.jackson.core.type.TypeReference; |
||||
|
import com.fasterxml.jackson.databind.JsonNode; |
||||
|
import org.junit.Test; |
||||
|
import org.mockito.ArgumentCaptor; |
||||
|
import org.springframework.boot.test.mock.mockito.SpyBean; |
||||
|
import org.thingsboard.common.util.JacksonUtil; |
||||
|
import org.thingsboard.server.common.data.DataConstants; |
||||
|
import org.thingsboard.server.common.data.Device; |
||||
|
import org.thingsboard.server.common.data.audit.ActionType; |
||||
|
import org.thingsboard.server.common.data.id.CustomerId; |
||||
|
import org.thingsboard.server.common.data.id.DeviceId; |
||||
|
import org.thingsboard.server.common.data.id.EntityId; |
||||
|
import org.thingsboard.server.common.data.id.TenantId; |
||||
|
import org.thingsboard.server.common.data.msg.TbMsgType; |
||||
|
import org.thingsboard.server.common.msg.TbMsg; |
||||
|
import org.thingsboard.server.common.msg.TbMsgMetaData; |
||||
|
import org.thingsboard.server.dao.service.DaoSqlTest; |
||||
|
import org.thingsboard.server.service.ruleengine.RuleEngineCallService; |
||||
|
|
||||
|
import java.util.Map; |
||||
|
import java.util.UUID; |
||||
|
import java.util.concurrent.TimeoutException; |
||||
|
import java.util.function.Consumer; |
||||
|
|
||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
import static org.hamcrest.Matchers.containsString; |
||||
|
import static org.mockito.ArgumentMatchers.any; |
||||
|
import static org.mockito.ArgumentMatchers.anyBoolean; |
||||
|
import static org.mockito.ArgumentMatchers.eq; |
||||
|
import static org.mockito.Mockito.doAnswer; |
||||
|
import static org.mockito.Mockito.verify; |
||||
|
import static org.mockito.Mockito.verifyNoInteractions; |
||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; |
||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; |
||||
|
|
||||
|
@DaoSqlTest |
||||
|
public class RuleEngineControllerTest extends AbstractControllerTest { |
||||
|
|
||||
|
private final String REQUEST_BODY = "{\"request\":\"download\"}"; |
||||
|
private final String RESPONSE_BODY = "{\"response\":\"downloadOk\"}"; |
||||
|
|
||||
|
@SpyBean |
||||
|
private RuleEngineCallService ruleEngineCallService; |
||||
|
|
||||
|
@Test |
||||
|
public void testHandleRuleEngineRequestWithMsgOriginatorUser() throws Exception { |
||||
|
loginSysAdmin(); |
||||
|
TbMsg responseMsg = TbMsg.newMsg(TbMsgType.REST_API_REQUEST, currentUserId, TbMsgMetaData.EMPTY, RESPONSE_BODY); |
||||
|
mockRestApiCallToRuleEngine(responseMsg); |
||||
|
|
||||
|
JsonNode apiResponse = doPostAsyncWithTypedResponse("/api/rule-engine/", REQUEST_BODY, new TypeReference<>() { |
||||
|
}, status().isOk()); |
||||
|
|
||||
|
assertThat(JacksonUtil.toString(apiResponse)).isEqualTo(RESPONSE_BODY); |
||||
|
ArgumentCaptor<TbMsg> requestMsgCaptor = ArgumentCaptor.forClass(TbMsg.class); |
||||
|
verify(ruleEngineCallService).processRestApiCallToRuleEngine(eq(TenantId.SYS_TENANT_ID), any(UUID.class), requestMsgCaptor.capture(), eq(false), any(Consumer.class)); |
||||
|
TbMsg requestMsgCaptorValue = requestMsgCaptor.getValue(); |
||||
|
assertThat(requestMsgCaptorValue.getData()).isEqualTo(REQUEST_BODY); |
||||
|
assertThat(requestMsgCaptorValue.getType()).isEqualTo(TbMsgType.REST_API_REQUEST.name()); |
||||
|
assertThat(requestMsgCaptorValue.getOriginator()).isEqualTo(currentUserId); |
||||
|
assertThat(requestMsgCaptorValue.getCustomerId()).isNull(); |
||||
|
checkMetadataProperties(requestMsgCaptorValue.getMetaData()); |
||||
|
testLogEntityAction(null, currentUserId, TenantId.SYS_TENANT_ID, new CustomerId(EntityId.NULL_UUID), currentUserId, |
||||
|
SYS_ADMIN_EMAIL, ActionType.REST_API_RULE_ENGINE_CALL, 1, REQUEST_BODY, RESPONSE_BODY); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void testHandleRuleEngineRequestWithMsgOriginatorDevice() throws Exception { |
||||
|
loginTenantAdmin(); |
||||
|
Device device = createDevice("Test", "123"); |
||||
|
DeviceId deviceId = device.getId(); |
||||
|
TbMsg responseMsg = TbMsg.newMsg(TbMsgType.REST_API_REQUEST, deviceId, TbMsgMetaData.EMPTY, RESPONSE_BODY); |
||||
|
mockRestApiCallToRuleEngine(responseMsg); |
||||
|
|
||||
|
JsonNode apiResponse = doPostAsyncWithTypedResponse("/api/rule-engine/DEVICE/" + deviceId.getId(), REQUEST_BODY, new TypeReference<>() { |
||||
|
}, status().isOk()); |
||||
|
|
||||
|
assertThat(JacksonUtil.toString(apiResponse)).isEqualTo(RESPONSE_BODY); |
||||
|
ArgumentCaptor<TbMsg> requestMsgCaptor = ArgumentCaptor.forClass(TbMsg.class); |
||||
|
verify(ruleEngineCallService).processRestApiCallToRuleEngine(eq(tenantId), any(UUID.class), requestMsgCaptor.capture(), eq(false), any(Consumer.class)); |
||||
|
TbMsg requestMsgCaptorValue = requestMsgCaptor.getValue(); |
||||
|
assertThat(requestMsgCaptorValue.getData()).isEqualTo(REQUEST_BODY); |
||||
|
assertThat(requestMsgCaptorValue.getType()).isEqualTo(TbMsgType.REST_API_REQUEST.name()); |
||||
|
assertThat(requestMsgCaptorValue.getOriginator()).isEqualTo(deviceId); |
||||
|
assertThat(requestMsgCaptorValue.getCustomerId()).isNull(); |
||||
|
checkMetadataProperties(requestMsgCaptorValue.getMetaData()); |
||||
|
testLogEntityAction(null, deviceId, tenantId, new CustomerId(EntityId.NULL_UUID), tenantAdminUserId, |
||||
|
TENANT_ADMIN_EMAIL, ActionType.REST_API_RULE_ENGINE_CALL, 1, REQUEST_BODY, RESPONSE_BODY); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void testHandleRuleEngineRequestWithMsgOriginatorDeviceAndSpecifiedTimeout() throws Exception { |
||||
|
loginTenantAdmin(); |
||||
|
Device device = createDevice("Test", "123"); |
||||
|
DeviceId deviceId = device.getId(); |
||||
|
TbMsg responseMsg = TbMsg.newMsg(TbMsgType.REST_API_REQUEST, deviceId, TbMsgMetaData.EMPTY, RESPONSE_BODY); |
||||
|
mockRestApiCallToRuleEngine(responseMsg); |
||||
|
|
||||
|
JsonNode apiResponse = doPostAsyncWithTypedResponse("/api/rule-engine/DEVICE/" + deviceId.getId() + "/15000", REQUEST_BODY, new TypeReference<>() { |
||||
|
}, status().isOk()); |
||||
|
|
||||
|
assertThat(JacksonUtil.toString(apiResponse)).isEqualTo(RESPONSE_BODY); |
||||
|
ArgumentCaptor<TbMsg> requestMsgCaptor = ArgumentCaptor.forClass(TbMsg.class); |
||||
|
verify(ruleEngineCallService).processRestApiCallToRuleEngine(eq(tenantId), any(UUID.class), requestMsgCaptor.capture(), eq(false), any(Consumer.class)); |
||||
|
TbMsg requestMsgCaptorValue = requestMsgCaptor.getValue(); |
||||
|
assertThat(requestMsgCaptorValue.getData()).isEqualTo(REQUEST_BODY); |
||||
|
assertThat(requestMsgCaptorValue.getType()).isEqualTo(TbMsgType.REST_API_REQUEST.name()); |
||||
|
assertThat(requestMsgCaptorValue.getOriginator()).isEqualTo(deviceId); |
||||
|
assertThat(requestMsgCaptorValue.getCustomerId()).isNull(); |
||||
|
checkMetadataProperties(requestMsgCaptorValue.getMetaData()); |
||||
|
testLogEntityAction(null, deviceId, tenantId, new CustomerId(EntityId.NULL_UUID), tenantAdminUserId, |
||||
|
TENANT_ADMIN_EMAIL, ActionType.REST_API_RULE_ENGINE_CALL, 1, REQUEST_BODY, RESPONSE_BODY); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void testHandleRuleEngineRequestWithMsgOriginatorDeviceAndResponseIsNull() throws Exception { |
||||
|
loginTenantAdmin(); |
||||
|
Device device = createDevice("Test", "123"); |
||||
|
DeviceId deviceId = device.getId(); |
||||
|
mockRestApiCallToRuleEngine(null); |
||||
|
|
||||
|
doPostAsync("/api/rule-engine/DEVICE/" + deviceId.getId() + "/15000", REQUEST_BODY, String.class, status().isRequestTimeout()); |
||||
|
|
||||
|
ArgumentCaptor<TbMsg> requestMsgCaptor = ArgumentCaptor.forClass(TbMsg.class); |
||||
|
verify(ruleEngineCallService).processRestApiCallToRuleEngine(eq(tenantId), any(UUID.class), requestMsgCaptor.capture(), eq(false), any(Consumer.class)); |
||||
|
TbMsg requestMsgCaptorValue = requestMsgCaptor.getValue(); |
||||
|
assertThat(requestMsgCaptorValue.getData()).isEqualTo(REQUEST_BODY); |
||||
|
assertThat(requestMsgCaptorValue.getType()).isEqualTo(TbMsgType.REST_API_REQUEST.name()); |
||||
|
assertThat(requestMsgCaptorValue.getOriginator()).isEqualTo(deviceId); |
||||
|
assertThat(requestMsgCaptorValue.getCustomerId()).isNull(); |
||||
|
checkMetadataProperties(requestMsgCaptorValue.getMetaData()); |
||||
|
Exception exception = new TimeoutException("Processing timeout detected!"); |
||||
|
testLogEntityActionError(deviceId, tenantId, new CustomerId(EntityId.NULL_UUID), tenantAdminUserId, |
||||
|
TENANT_ADMIN_EMAIL, ActionType.REST_API_RULE_ENGINE_CALL, exception, REQUEST_BODY, ""); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void testHandleRuleEngineRequestWithMsgOriginatorDeviceAndSpecifiedQueue() throws Exception { |
||||
|
loginTenantAdmin(); |
||||
|
Device device = createDevice("Test", "123"); |
||||
|
DeviceId deviceId = device.getId(); |
||||
|
TbMsg responseMsg = TbMsg.newMsg(DataConstants.HP_QUEUE_NAME, TbMsgType.REST_API_REQUEST, deviceId, TbMsgMetaData.EMPTY, RESPONSE_BODY); |
||||
|
mockRestApiCallToRuleEngine(responseMsg); |
||||
|
|
||||
|
JsonNode apiResponse = doPostAsyncWithTypedResponse("/api/rule-engine/DEVICE/" + deviceId.getId() + "/HighPriority/1000", REQUEST_BODY, new TypeReference<>() { |
||||
|
}, status().isOk()); |
||||
|
|
||||
|
assertThat(JacksonUtil.toString(apiResponse)).isEqualTo(RESPONSE_BODY); |
||||
|
ArgumentCaptor<TbMsg> requestMsgCaptor = ArgumentCaptor.forClass(TbMsg.class); |
||||
|
verify(ruleEngineCallService).processRestApiCallToRuleEngine(eq(tenantId), any(UUID.class), requestMsgCaptor.capture(), eq(true), any(Consumer.class)); |
||||
|
TbMsg requestMsgCaptorValue = requestMsgCaptor.getValue(); |
||||
|
assertThat(requestMsgCaptorValue.getData()).isEqualTo(REQUEST_BODY); |
||||
|
assertThat(requestMsgCaptorValue.getType()).isEqualTo(TbMsgType.REST_API_REQUEST.name()); |
||||
|
assertThat(requestMsgCaptorValue.getQueueName()).isEqualTo(DataConstants.HP_QUEUE_NAME); |
||||
|
assertThat(requestMsgCaptorValue.getOriginator()).isEqualTo(deviceId); |
||||
|
assertThat(requestMsgCaptorValue.getCustomerId()).isNull(); |
||||
|
checkMetadataProperties(requestMsgCaptorValue.getMetaData()); |
||||
|
testLogEntityAction(null, deviceId, tenantId, new CustomerId(EntityId.NULL_UUID), tenantAdminUserId, |
||||
|
TENANT_ADMIN_EMAIL, ActionType.REST_API_RULE_ENGINE_CALL, 1, REQUEST_BODY, RESPONSE_BODY); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void testHandleRuleEngineRequestWithInvalidRequestBody() throws Exception { |
||||
|
loginSysAdmin(); |
||||
|
|
||||
|
doPost("/api/rule-engine/", (Object) "@") |
||||
|
.andExpect(status().isBadRequest()) |
||||
|
.andExpect(statusReason(containsString("Invalid request body"))); |
||||
|
|
||||
|
verifyNoInteractions(ruleEngineCallService); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void testHandleRuleEngineRequestWithAuthorityCustomerUser() throws Exception { |
||||
|
loginTenantAdmin(); |
||||
|
Device device = createDevice("Test", "123"); |
||||
|
DeviceId deviceId = device.getId(); |
||||
|
assignDeviceToCustomer(deviceId, customerId); |
||||
|
loginCustomerUser(); |
||||
|
|
||||
|
TbMsg responseMsg = TbMsg.newMsg(TbMsgType.REST_API_REQUEST, deviceId, customerId, TbMsgMetaData.EMPTY, RESPONSE_BODY); |
||||
|
mockRestApiCallToRuleEngine(responseMsg); |
||||
|
|
||||
|
JsonNode apiResponse = doPostAsyncWithTypedResponse("/api/rule-engine/DEVICE/" + deviceId.getId(), REQUEST_BODY, new TypeReference<>() { |
||||
|
}, status().isOk()); |
||||
|
|
||||
|
assertThat(JacksonUtil.toString(apiResponse)).isEqualTo(RESPONSE_BODY); |
||||
|
ArgumentCaptor<TbMsg> requestMsgCaptor = ArgumentCaptor.forClass(TbMsg.class); |
||||
|
verify(ruleEngineCallService).processRestApiCallToRuleEngine(eq(tenantId), any(UUID.class), requestMsgCaptor.capture(), eq(false), any(Consumer.class)); |
||||
|
TbMsg requestMsgCaptorValue = requestMsgCaptor.getValue(); |
||||
|
assertThat(requestMsgCaptorValue.getData()).isEqualTo(REQUEST_BODY); |
||||
|
assertThat(requestMsgCaptorValue.getType()).isEqualTo(TbMsgType.REST_API_REQUEST.name()); |
||||
|
assertThat(requestMsgCaptorValue.getOriginator()).isEqualTo(deviceId); |
||||
|
assertThat(requestMsgCaptorValue.getCustomerId()).isEqualTo(customerId); |
||||
|
checkMetadataProperties(requestMsgCaptorValue.getMetaData()); |
||||
|
testLogEntityAction(null, deviceId, tenantId, customerId, customerUserId, CUSTOMER_USER_EMAIL, |
||||
|
ActionType.REST_API_RULE_ENGINE_CALL, 1, REQUEST_BODY, RESPONSE_BODY); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void testHandleRuleEngineRequestWithoutPermission() throws Exception { |
||||
|
loginTenantAdmin(); |
||||
|
Device device = createDevice("test", "123"); |
||||
|
loginCustomerUser(); |
||||
|
|
||||
|
doPostAsync("/api/rule-engine/DEVICE/" + device.getId().getId(), (Object) REQUEST_BODY, -1L) |
||||
|
.andExpect(status().isForbidden()) |
||||
|
.andExpect(content().string("You don't have permission to perform this operation!")); |
||||
|
|
||||
|
verifyNoInteractions(ruleEngineCallService); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void testHandleRuleEngineRequestUnauthorized() throws Exception { |
||||
|
doPost("/api/rule-engine/", (Object) REQUEST_BODY) |
||||
|
.andExpect(status().isUnauthorized()) |
||||
|
.andExpect(statusReason(containsString("Authentication failed"))); |
||||
|
} |
||||
|
|
||||
|
private void mockRestApiCallToRuleEngine(TbMsg responseMsg) { |
||||
|
doAnswer(invocation -> { |
||||
|
Consumer<TbMsg> consumer = invocation.getArgument(4); |
||||
|
consumer.accept(responseMsg); |
||||
|
return null; |
||||
|
}).when(ruleEngineCallService).processRestApiCallToRuleEngine(any(TenantId.class), any(UUID.class), any(TbMsg.class), anyBoolean(), any(Consumer.class)); |
||||
|
} |
||||
|
|
||||
|
public void checkMetadataProperties(TbMsgMetaData metaData) { |
||||
|
Map<String, String> data = metaData.getData(); |
||||
|
assertThat(data).containsKeys("serviceId", "requestUUID", "expirationTime"); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,286 @@ |
|||||
|
/** |
||||
|
* 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.server.service.queue.ruleengine; |
||||
|
|
||||
|
import lombok.EqualsAndHashCode; |
||||
|
import lombok.RequiredArgsConstructor; |
||||
|
import lombok.ToString; |
||||
|
import lombok.extern.slf4j.Slf4j; |
||||
|
import org.junit.jupiter.api.Assertions; |
||||
|
import org.junit.jupiter.api.BeforeEach; |
||||
|
import org.junit.jupiter.params.ParameterizedTest; |
||||
|
import org.junit.jupiter.params.provider.Arguments; |
||||
|
import org.junit.jupiter.params.provider.MethodSource; |
||||
|
import org.mockito.Mock; |
||||
|
import org.mockito.junit.jupiter.MockitoSettings; |
||||
|
import org.mockito.quality.Strictness; |
||||
|
import org.thingsboard.server.actors.ActorSystemContext; |
||||
|
import org.thingsboard.server.common.data.id.DeviceId; |
||||
|
import org.thingsboard.server.common.data.queue.ProcessingStrategy; |
||||
|
import org.thingsboard.server.common.data.queue.ProcessingStrategyType; |
||||
|
import org.thingsboard.server.common.data.queue.Queue; |
||||
|
import org.thingsboard.server.common.data.queue.SubmitStrategy; |
||||
|
import org.thingsboard.server.common.data.queue.SubmitStrategyType; |
||||
|
import org.thingsboard.server.common.msg.TbMsg; |
||||
|
import org.thingsboard.server.common.msg.TbMsgDataType; |
||||
|
import org.thingsboard.server.common.msg.TbMsgProcessingCtx; |
||||
|
import org.thingsboard.server.common.msg.queue.QueueToRuleEngineMsg; |
||||
|
import org.thingsboard.server.common.msg.queue.RuleEngineException; |
||||
|
import org.thingsboard.server.common.msg.queue.ServiceType; |
||||
|
import org.thingsboard.server.gen.transport.TransportProtos.ToRuleEngineMsg; |
||||
|
import org.thingsboard.server.queue.TbQueueConsumer; |
||||
|
import org.thingsboard.server.queue.common.TbProtoQueueMsg; |
||||
|
import org.thingsboard.server.queue.discovery.QueueKey; |
||||
|
import org.thingsboard.server.service.queue.processing.TbRuleEngineProcessingStrategyFactory; |
||||
|
import org.thingsboard.server.service.queue.processing.TbRuleEngineSubmitStrategyFactory; |
||||
|
|
||||
|
import java.util.HashMap; |
||||
|
import java.util.List; |
||||
|
import java.util.Map; |
||||
|
import java.util.UUID; |
||||
|
import java.util.concurrent.atomic.AtomicBoolean; |
||||
|
import java.util.concurrent.atomic.AtomicInteger; |
||||
|
import java.util.stream.Collectors; |
||||
|
import java.util.stream.Stream; |
||||
|
|
||||
|
import static org.mockito.ArgumentMatchers.any; |
||||
|
import static org.mockito.ArgumentMatchers.argThat; |
||||
|
import static org.mockito.Mockito.doAnswer; |
||||
|
import static org.mockito.Mockito.mock; |
||||
|
import static org.mockito.Mockito.times; |
||||
|
import static org.mockito.Mockito.verify; |
||||
|
import static org.mockito.Mockito.when; |
||||
|
import static org.thingsboard.server.common.data.queue.ProcessingStrategyType.RETRY_ALL; |
||||
|
import static org.thingsboard.server.common.data.queue.ProcessingStrategyType.RETRY_FAILED; |
||||
|
import static org.thingsboard.server.common.data.queue.ProcessingStrategyType.RETRY_FAILED_AND_TIMED_OUT; |
||||
|
import static org.thingsboard.server.common.data.queue.ProcessingStrategyType.RETRY_TIMED_OUT; |
||||
|
import static org.thingsboard.server.common.data.queue.ProcessingStrategyType.SKIP_ALL_FAILURES; |
||||
|
import static org.thingsboard.server.common.data.queue.ProcessingStrategyType.SKIP_ALL_FAILURES_AND_TIMED_OUT; |
||||
|
import static org.thingsboard.server.common.data.queue.SubmitStrategyType.BATCH; |
||||
|
import static org.thingsboard.server.common.data.queue.SubmitStrategyType.BURST; |
||||
|
import static org.thingsboard.server.common.data.queue.SubmitStrategyType.SEQUENTIAL_BY_ORIGINATOR; |
||||
|
|
||||
|
@Slf4j |
||||
|
@MockitoSettings(strictness = Strictness.LENIENT) |
||||
|
public class TbRuleEngineStrategyTest { |
||||
|
|
||||
|
@Mock |
||||
|
private ActorSystemContext actorContext; |
||||
|
@Mock |
||||
|
private TbQueueConsumer<TbProtoQueueMsg<ToRuleEngineMsg>> consumer; |
||||
|
private TbRuleEngineConsumerContext ruleEngineConsumerContext; |
||||
|
|
||||
|
private static UUID tenantId = UUID.randomUUID(); |
||||
|
private static DeviceId deviceId = new DeviceId(UUID.randomUUID()); |
||||
|
|
||||
|
@BeforeEach |
||||
|
public void beforeEach() { |
||||
|
ruleEngineConsumerContext = new TbRuleEngineConsumerContext( |
||||
|
actorContext, mock(), new TbRuleEngineSubmitStrategyFactory(), |
||||
|
new TbRuleEngineProcessingStrategyFactory(), mock(), mock(), |
||||
|
mock(), mock(), mock(), mock() |
||||
|
); |
||||
|
when(consumer.isStopped()).thenReturn(false); |
||||
|
} |
||||
|
|
||||
|
private static Stream<Arguments> testData() { |
||||
|
return Stream.of( |
||||
|
//BURST
|
||||
|
Arguments.of(BURST, SKIP_ALL_FAILURES, 3, List.of(new ProcessingData(1), new ProcessingData(1), new ProcessingData(1))), |
||||
|
Arguments.of(BURST, SKIP_ALL_FAILURES, 3, List.of(new ProcessingData(true, false, 1), new ProcessingData(1), new ProcessingData(1))), |
||||
|
Arguments.of(BURST, SKIP_ALL_FAILURES, 3, List.of(new ProcessingData(false, true, 1), new ProcessingData(1), new ProcessingData(1))), |
||||
|
|
||||
|
Arguments.of(BURST, SKIP_ALL_FAILURES_AND_TIMED_OUT, 3, List.of(new ProcessingData(1), new ProcessingData(1), new ProcessingData(1))), |
||||
|
Arguments.of(BURST, SKIP_ALL_FAILURES_AND_TIMED_OUT, 3, List.of(new ProcessingData(true, false, 1), new ProcessingData(1), new ProcessingData(1))), |
||||
|
Arguments.of(BURST, SKIP_ALL_FAILURES_AND_TIMED_OUT, 3, List.of(new ProcessingData(false, true, 1), new ProcessingData(1), new ProcessingData(1))), |
||||
|
|
||||
|
Arguments.of(BURST, RETRY_ALL, 3, List.of(new ProcessingData(1), new ProcessingData(1), new ProcessingData(1))), |
||||
|
Arguments.of(BURST, RETRY_ALL, 3, List.of(new ProcessingData(true, false, 2), new ProcessingData(2), new ProcessingData(2))), |
||||
|
Arguments.of(BURST, RETRY_ALL, 3, List.of(new ProcessingData(false, true, 2), new ProcessingData(2), new ProcessingData(2))), |
||||
|
|
||||
|
Arguments.of(BURST, RETRY_FAILED, 3, List.of(new ProcessingData(1), new ProcessingData(1), new ProcessingData(1))), |
||||
|
Arguments.of(BURST, RETRY_FAILED, 3, List.of(new ProcessingData(true, false, 2), new ProcessingData(1), new ProcessingData(1))), |
||||
|
Arguments.of(BURST, RETRY_FAILED, 3, List.of(new ProcessingData(false, true, 1), new ProcessingData(1), new ProcessingData(1))), |
||||
|
|
||||
|
Arguments.of(BURST, RETRY_TIMED_OUT, 3, List.of(new ProcessingData(1), new ProcessingData(1), new ProcessingData(1))), |
||||
|
Arguments.of(BURST, RETRY_TIMED_OUT, 3, List.of(new ProcessingData(true, false, 1), new ProcessingData(1), new ProcessingData(1))), |
||||
|
Arguments.of(BURST, RETRY_TIMED_OUT, 3, List.of(new ProcessingData(false, true, 2), new ProcessingData(1), new ProcessingData(1))), |
||||
|
|
||||
|
Arguments.of(BURST, RETRY_FAILED_AND_TIMED_OUT, 3, List.of(new ProcessingData(1), new ProcessingData(1), new ProcessingData(1))), |
||||
|
Arguments.of(BURST, RETRY_FAILED_AND_TIMED_OUT, 3, List.of(new ProcessingData(true, false, 2), new ProcessingData(1), new ProcessingData(1))), |
||||
|
Arguments.of(BURST, RETRY_FAILED_AND_TIMED_OUT, 3, List.of(new ProcessingData(false, true, 2), new ProcessingData(1), new ProcessingData(1))), |
||||
|
|
||||
|
//BATCH
|
||||
|
Arguments.of(BATCH, SKIP_ALL_FAILURES, 3, List.of(new ProcessingData(1), new ProcessingData(1), new ProcessingData(1))), |
||||
|
Arguments.of(BATCH, SKIP_ALL_FAILURES, 3, List.of(new ProcessingData(true, false, 1), new ProcessingData(1), new ProcessingData(0))), |
||||
|
Arguments.of(BATCH, SKIP_ALL_FAILURES, 3, List.of(new ProcessingData(false, true, 1), new ProcessingData(1), new ProcessingData(0))), |
||||
|
|
||||
|
Arguments.of(BATCH, SKIP_ALL_FAILURES_AND_TIMED_OUT, 3, List.of(new ProcessingData(1), new ProcessingData(1), new ProcessingData(1))), |
||||
|
Arguments.of(BATCH, SKIP_ALL_FAILURES_AND_TIMED_OUT, 3, List.of(new ProcessingData(true, false, 1), new ProcessingData(1), new ProcessingData(0))), |
||||
|
Arguments.of(BATCH, SKIP_ALL_FAILURES_AND_TIMED_OUT, 3, List.of(new ProcessingData(false, true, 1), new ProcessingData(1), new ProcessingData(0))), |
||||
|
|
||||
|
Arguments.of(BATCH, RETRY_ALL, 3, List.of(new ProcessingData(1), new ProcessingData(1), new ProcessingData(1))), |
||||
|
Arguments.of(BATCH, RETRY_ALL, 3, List.of(new ProcessingData(true, false, 2), new ProcessingData(2), new ProcessingData(1))), |
||||
|
Arguments.of(BATCH, RETRY_ALL, 3, List.of(new ProcessingData(false, true, 2), new ProcessingData(2), new ProcessingData(1))), |
||||
|
|
||||
|
Arguments.of(BATCH, RETRY_FAILED, 3, List.of(new ProcessingData(1), new ProcessingData(1), new ProcessingData(1))), |
||||
|
Arguments.of(BATCH, RETRY_FAILED, 3, List.of(new ProcessingData(true, false, 2), new ProcessingData(1), new ProcessingData(0))), |
||||
|
Arguments.of(BATCH, RETRY_FAILED, 3, List.of(new ProcessingData(false, true, 1), new ProcessingData(1), new ProcessingData(0))), |
||||
|
|
||||
|
Arguments.of(BATCH, RETRY_TIMED_OUT, 3, List.of(new ProcessingData(1), new ProcessingData(1), new ProcessingData(1))), |
||||
|
Arguments.of(BATCH, RETRY_TIMED_OUT, 3, List.of(new ProcessingData(true, false, 1), new ProcessingData(1), new ProcessingData(1))), |
||||
|
Arguments.of(BATCH, RETRY_TIMED_OUT, 3, List.of(new ProcessingData(false, true, 2), new ProcessingData(1), new ProcessingData(1))), |
||||
|
|
||||
|
Arguments.of(BATCH, RETRY_FAILED_AND_TIMED_OUT, 3, List.of(new ProcessingData(1), new ProcessingData(1), new ProcessingData(1))), |
||||
|
Arguments.of(BATCH, RETRY_FAILED_AND_TIMED_OUT, 3, List.of(new ProcessingData(true, false, 2), new ProcessingData(1), new ProcessingData(1))), |
||||
|
Arguments.of(BATCH, RETRY_FAILED_AND_TIMED_OUT, 3, List.of(new ProcessingData(false, true, 2), new ProcessingData(1), new ProcessingData(1))), |
||||
|
|
||||
|
//SEQUENTIAL_BY_ORIGINATOR
|
||||
|
Arguments.of(SEQUENTIAL_BY_ORIGINATOR, SKIP_ALL_FAILURES, 3, List.of(new ProcessingData(1), new ProcessingData(1), new ProcessingData(1))), |
||||
|
Arguments.of(SEQUENTIAL_BY_ORIGINATOR, SKIP_ALL_FAILURES, 3, List.of(new ProcessingData(true, false, 1), new ProcessingData(0), new ProcessingData(0))), |
||||
|
Arguments.of(SEQUENTIAL_BY_ORIGINATOR, SKIP_ALL_FAILURES, 3, List.of(new ProcessingData(false, true, 1), new ProcessingData(0), new ProcessingData(0))), |
||||
|
|
||||
|
Arguments.of(SEQUENTIAL_BY_ORIGINATOR, SKIP_ALL_FAILURES_AND_TIMED_OUT, 3, List.of(new ProcessingData(1), new ProcessingData(1), new ProcessingData(1))), |
||||
|
Arguments.of(SEQUENTIAL_BY_ORIGINATOR, SKIP_ALL_FAILURES_AND_TIMED_OUT, 3, List.of(new ProcessingData(true, false, 1), new ProcessingData(0), new ProcessingData(0))), |
||||
|
Arguments.of(SEQUENTIAL_BY_ORIGINATOR, SKIP_ALL_FAILURES_AND_TIMED_OUT, 3, List.of(new ProcessingData(false, true, 1), new ProcessingData(0), new ProcessingData(0))), |
||||
|
|
||||
|
Arguments.of(SEQUENTIAL_BY_ORIGINATOR, RETRY_ALL, 3, List.of(new ProcessingData(1), new ProcessingData(1), new ProcessingData(1))), |
||||
|
Arguments.of(SEQUENTIAL_BY_ORIGINATOR, RETRY_ALL, 3, List.of(new ProcessingData(true, false, 2), new ProcessingData(1), new ProcessingData(1))), |
||||
|
Arguments.of(SEQUENTIAL_BY_ORIGINATOR, RETRY_ALL, 3, List.of(new ProcessingData(false, true, 2), new ProcessingData(1), new ProcessingData(1))), |
||||
|
|
||||
|
Arguments.of(SEQUENTIAL_BY_ORIGINATOR, RETRY_FAILED, 3, List.of(new ProcessingData(1), new ProcessingData(1), new ProcessingData(1))), |
||||
|
Arguments.of(SEQUENTIAL_BY_ORIGINATOR, RETRY_FAILED, 3, List.of(new ProcessingData(true, false, 2), new ProcessingData(0), new ProcessingData(0))), |
||||
|
Arguments.of(SEQUENTIAL_BY_ORIGINATOR, RETRY_FAILED, 3, List.of(new ProcessingData(false, true, 1), new ProcessingData(0), new ProcessingData(0))), |
||||
|
|
||||
|
Arguments.of(SEQUENTIAL_BY_ORIGINATOR, RETRY_TIMED_OUT, 3, List.of(new ProcessingData(1), new ProcessingData(1), new ProcessingData(1))), |
||||
|
Arguments.of(SEQUENTIAL_BY_ORIGINATOR, RETRY_TIMED_OUT, 3, List.of(new ProcessingData(true, false, 1), new ProcessingData(1), new ProcessingData(1))), |
||||
|
Arguments.of(SEQUENTIAL_BY_ORIGINATOR, RETRY_TIMED_OUT, 3, List.of(new ProcessingData(false, true, 2), new ProcessingData(1), new ProcessingData(1))), |
||||
|
|
||||
|
Arguments.of(SEQUENTIAL_BY_ORIGINATOR, RETRY_FAILED_AND_TIMED_OUT, 3, List.of(new ProcessingData(1), new ProcessingData(1), new ProcessingData(1))), |
||||
|
Arguments.of(SEQUENTIAL_BY_ORIGINATOR, RETRY_FAILED_AND_TIMED_OUT, 3, List.of(new ProcessingData(true, false, 2), new ProcessingData(1), new ProcessingData(1))), |
||||
|
Arguments.of(SEQUENTIAL_BY_ORIGINATOR, RETRY_FAILED_AND_TIMED_OUT, 3, List.of(new ProcessingData(false, true, 2), new ProcessingData(1), new ProcessingData(1))) |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
@ParameterizedTest |
||||
|
@MethodSource("testData") |
||||
|
public void processMsgsTest(SubmitStrategyType submitStrategyType, ProcessingStrategyType processingStrategyType, int retries, List<ProcessingData> processingData) throws Exception { |
||||
|
var queue = new Queue(); |
||||
|
queue.setName("Test"); |
||||
|
queue.setPackProcessingTimeout(100); |
||||
|
SubmitStrategy submitStrategy = new SubmitStrategy(); |
||||
|
submitStrategy.setType(submitStrategyType); |
||||
|
submitStrategy.setBatchSize(2); |
||||
|
queue.setSubmitStrategy(submitStrategy); |
||||
|
ProcessingStrategy processingStrategy = new ProcessingStrategy(); |
||||
|
processingStrategy.setType(processingStrategyType); |
||||
|
processingStrategy.setRetries(retries); |
||||
|
queue.setProcessingStrategy(processingStrategy); |
||||
|
|
||||
|
QueueKey queueKey = new QueueKey(ServiceType.TB_RULE_ENGINE, queue); |
||||
|
var consumerManager = TbRuleEngineQueueConsumerManager.create() |
||||
|
.ctx(ruleEngineConsumerContext) |
||||
|
.queueKey(queueKey) |
||||
|
.build(); |
||||
|
|
||||
|
consumerManager.init(queue); |
||||
|
|
||||
|
Map<UUID, ProcessingData> msgsMap = processingData.stream().collect(Collectors.toMap(data -> data.tbMsg.getId(), data -> data)); |
||||
|
Map<UUID, AtomicInteger> attemptsMap = new HashMap<>(); |
||||
|
Map<UUID, AtomicBoolean> failedMap = new HashMap<>(); |
||||
|
Map<UUID, AtomicBoolean> timeoutMap = new HashMap<>(); |
||||
|
|
||||
|
doAnswer(inv -> { |
||||
|
QueueToRuleEngineMsg msg = inv.getArgument(0); |
||||
|
|
||||
|
UUID msgId = msg.getMsg().getId(); |
||||
|
var data = msgsMap.get(msgId); |
||||
|
|
||||
|
var attempts = attemptsMap.computeIfAbsent(msgId, key -> new AtomicInteger(0)); |
||||
|
Assertions.assertTrue(attempts.getAndIncrement() <= data.attempts); |
||||
|
|
||||
|
var callback = msg.getMsg().getCallback(); |
||||
|
|
||||
|
if (data.shouldFailed) { |
||||
|
var alreadyFailed = failedMap.computeIfAbsent(msgId, key -> new AtomicBoolean(false)); |
||||
|
if (!alreadyFailed.getAndSet(true)) { |
||||
|
callback.onFailure(new RuleEngineException("Failed to process test msg!")); |
||||
|
return null; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (data.shouldTimeout) { |
||||
|
var alreadyTimedOuted = timeoutMap.computeIfAbsent(msgId, key -> new AtomicBoolean(false)); |
||||
|
if (!alreadyTimedOuted.getAndSet(true)) { |
||||
|
return null; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
callback.onSuccess(); |
||||
|
return null; |
||||
|
}).when(actorContext).tell(any()); |
||||
|
|
||||
|
List<TbProtoQueueMsg<ToRuleEngineMsg>> protoMsgs = processingData.stream() |
||||
|
.map(data -> data.tbMsg) |
||||
|
.map(this::toProto) |
||||
|
.toList(); |
||||
|
|
||||
|
consumerManager.processMsgs(protoMsgs, consumer, queue); |
||||
|
|
||||
|
processingData.forEach(data -> { |
||||
|
verify(actorContext, times(data.attempts)).tell(argThat(msg -> |
||||
|
((QueueToRuleEngineMsg) msg).getMsg().getId().equals(data.tbMsg.getId()) |
||||
|
)); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
private static TbMsg createRandomMsg() { |
||||
|
return TbMsg.builder() |
||||
|
.id(UUID.randomUUID()) |
||||
|
.type("test type") |
||||
|
.originator(deviceId) |
||||
|
.dataType(TbMsgDataType.TEXT) |
||||
|
.data("test data") |
||||
|
.ctx(new TbMsgProcessingCtx()) |
||||
|
.build(); |
||||
|
} |
||||
|
|
||||
|
private TbProtoQueueMsg<ToRuleEngineMsg> toProto(TbMsg tbMsg) { |
||||
|
return new TbProtoQueueMsg<>(UUID.randomUUID(), ToRuleEngineMsg.newBuilder() |
||||
|
.setTenantIdMSB(tenantId.getMostSignificantBits()) |
||||
|
.setTenantIdLSB(tenantId.getLeastSignificantBits()) |
||||
|
.addRelationTypes("Success") |
||||
|
.setTbMsg(TbMsg.toByteString(tbMsg)) |
||||
|
.build()); |
||||
|
} |
||||
|
|
||||
|
@RequiredArgsConstructor |
||||
|
@EqualsAndHashCode |
||||
|
@ToString(exclude = "tbMsg") |
||||
|
private static class ProcessingData { |
||||
|
private final TbMsg tbMsg = createRandomMsg(); |
||||
|
private final boolean shouldFailed; |
||||
|
private final boolean shouldTimeout; |
||||
|
private final int attempts; |
||||
|
|
||||
|
public ProcessingData(int attempts) { |
||||
|
shouldFailed = false; |
||||
|
shouldTimeout = false; |
||||
|
this.attempts = attempts; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -0,0 +1,61 @@ |
|||||
|
/** |
||||
|
* 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.server.service.rpc; |
||||
|
|
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.junit.jupiter.api.extension.ExtendWith; |
||||
|
import org.mockito.InjectMocks; |
||||
|
import org.mockito.Mock; |
||||
|
import org.mockito.junit.jupiter.MockitoExtension; |
||||
|
import org.thingsboard.server.cluster.TbClusterService; |
||||
|
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 org.thingsboard.server.gen.transport.TransportProtos; |
||||
|
import java.util.UUID; |
||||
|
|
||||
|
import static org.mockito.BDDMockito.then; |
||||
|
|
||||
|
@ExtendWith(MockitoExtension.class) |
||||
|
class DefaultTbRuleEngineRpcServiceTest { |
||||
|
|
||||
|
@Mock |
||||
|
private TbClusterService tbClusterServiceMock; |
||||
|
|
||||
|
@InjectMocks |
||||
|
private DefaultTbRuleEngineRpcService tbRuleEngineRpcService; |
||||
|
|
||||
|
@Test |
||||
|
public void givenTbMsg_whenSendRestApiCallReply_thenPushNotificationToCore() { |
||||
|
// GIVEN
|
||||
|
String serviceId = "tb-core-0"; |
||||
|
UUID requestId = UUID.fromString("f64a20df-eb1e-46a3-ba6f-0b3ae053ee0a"); |
||||
|
DeviceId deviceId = new DeviceId(UUID.fromString("1d9f771a-7cdc-4ac7-838c-ba193d05a012")); |
||||
|
TbMsg msg = TbMsg.newMsg(TbMsgType.REST_API_REQUEST, deviceId, TbMsgMetaData.EMPTY, TbMsg.EMPTY_JSON_OBJECT); |
||||
|
var restApiCallResponseMsgProto = TransportProtos.RestApiCallResponseMsgProto.newBuilder() |
||||
|
.setRequestIdMSB(requestId.getMostSignificantBits()) |
||||
|
.setRequestIdLSB(requestId.getLeastSignificantBits()) |
||||
|
.setResponse(TbMsg.toByteString(msg)) |
||||
|
.build(); |
||||
|
|
||||
|
// WHEN
|
||||
|
tbRuleEngineRpcService.sendRestApiCallReply(serviceId, requestId, msg); |
||||
|
|
||||
|
// THEN
|
||||
|
then(tbClusterServiceMock).should().pushNotificationToCore(serviceId, restApiCallResponseMsgProto, null); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,141 @@ |
|||||
|
/** |
||||
|
* 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.server.service.ruleengine; |
||||
|
|
||||
|
import org.junit.jupiter.api.AfterEach; |
||||
|
import org.junit.jupiter.api.BeforeEach; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.junit.jupiter.api.extension.ExtendWith; |
||||
|
import org.mockito.Mock; |
||||
|
import org.mockito.junit.jupiter.MockitoExtension; |
||||
|
import org.springframework.test.util.ReflectionTestUtils; |
||||
|
import org.testcontainers.shaded.org.awaitility.Awaitility; |
||||
|
import org.thingsboard.common.util.ThingsBoardThreadFactory; |
||||
|
import org.thingsboard.server.cluster.TbClusterService; |
||||
|
import org.thingsboard.server.common.data.DataConstants; |
||||
|
import org.thingsboard.server.common.data.id.TenantId; |
||||
|
import org.thingsboard.server.common.data.msg.TbMsgType; |
||||
|
import org.thingsboard.server.common.msg.TbMsg; |
||||
|
import org.thingsboard.server.common.msg.TbMsgMetaData; |
||||
|
import org.thingsboard.server.common.msg.queue.TbCallback; |
||||
|
import org.thingsboard.server.gen.transport.TransportProtos; |
||||
|
|
||||
|
import java.util.HashMap; |
||||
|
import java.util.UUID; |
||||
|
import java.util.concurrent.ConcurrentHashMap; |
||||
|
import java.util.concurrent.ConcurrentMap; |
||||
|
import java.util.concurrent.Executors; |
||||
|
import java.util.concurrent.ScheduledExecutorService; |
||||
|
import java.util.concurrent.TimeUnit; |
||||
|
import java.util.function.Consumer; |
||||
|
|
||||
|
import static org.assertj.core.api.AssertionsForClassTypes.assertThat; |
||||
|
import static org.mockito.ArgumentMatchers.any; |
||||
|
import static org.mockito.ArgumentMatchers.anyBoolean; |
||||
|
import static org.mockito.Mockito.doAnswer; |
||||
|
import static org.mockito.Mockito.verify; |
||||
|
|
||||
|
@ExtendWith(MockitoExtension.class) |
||||
|
public class DefaultRuleEngineCallServiceTest { |
||||
|
|
||||
|
private static final TenantId TENANT_ID = TenantId.fromUUID(UUID.fromString("d7210c7f-a152-4e91-8186-19ae85499a6b")); |
||||
|
|
||||
|
private final ConcurrentMap<UUID, Consumer<TbMsg>> requests = new ConcurrentHashMap<>(); |
||||
|
|
||||
|
@Mock |
||||
|
private TbClusterService tbClusterServiceMock; |
||||
|
|
||||
|
private DefaultRuleEngineCallService ruleEngineCallService; |
||||
|
private ScheduledExecutorService executor; |
||||
|
|
||||
|
@BeforeEach |
||||
|
void setUp() { |
||||
|
executor = Executors.newSingleThreadScheduledExecutor(ThingsBoardThreadFactory.forName("re-rest-callback")); |
||||
|
ruleEngineCallService = new DefaultRuleEngineCallService(tbClusterServiceMock); |
||||
|
ReflectionTestUtils.setField(ruleEngineCallService, "executor", executor); |
||||
|
ReflectionTestUtils.setField(ruleEngineCallService, "requests", requests); |
||||
|
} |
||||
|
|
||||
|
@AfterEach |
||||
|
void tearDown() { |
||||
|
requests.clear(); |
||||
|
if (executor != null) { |
||||
|
executor.shutdownNow(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void givenRequest_whenProcessRestApiCallToRuleEngine_thenPushMsgToRuleEngineAndCheckRemovedDueTimeout() { |
||||
|
long timeout = 1L; |
||||
|
long expTime = System.currentTimeMillis() + timeout; |
||||
|
HashMap<String, String> metaData = new HashMap<>(); |
||||
|
UUID requestId = UUID.randomUUID(); |
||||
|
metaData.put("serviceId", "core"); |
||||
|
metaData.put("requestUUID", requestId.toString()); |
||||
|
metaData.put("expirationTime", Long.toString(expTime)); |
||||
|
TbMsg msg = TbMsg.newMsg(DataConstants.MAIN_QUEUE_NAME, TbMsgType.REST_API_REQUEST, TENANT_ID, new TbMsgMetaData(metaData), "{\"key\":\"value\"}"); |
||||
|
|
||||
|
Consumer<TbMsg> anyConsumer = TbMsg::getData; |
||||
|
doAnswer(invocation -> { |
||||
|
//check the presence of request in the map after pushMsgToRuleEngine()
|
||||
|
assertThat(requests.size()).isEqualTo(1); |
||||
|
assertThat(requests.get(requestId)).isEqualTo(anyConsumer); |
||||
|
return null; |
||||
|
}).when(tbClusterServiceMock).pushMsgToRuleEngine(any(), any(), any(), anyBoolean(), any()); |
||||
|
ruleEngineCallService.processRestApiCallToRuleEngine(TENANT_ID, requestId, msg, true, anyConsumer); |
||||
|
|
||||
|
verify(tbClusterServiceMock).pushMsgToRuleEngine(TENANT_ID, TENANT_ID, msg, true, null); |
||||
|
//check map is empty after scheduleTimeout()
|
||||
|
Awaitility.await("Await until request was deleted from map due to timeout") |
||||
|
.pollDelay(25, TimeUnit.MILLISECONDS) |
||||
|
.atMost(10, TimeUnit.SECONDS) |
||||
|
.until(requests::isEmpty); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void givenResponse_whenOnQueue_thenAcceptTbMsgResponse() { |
||||
|
long timeout = 10000L; |
||||
|
long expTime = System.currentTimeMillis() + timeout; |
||||
|
HashMap<String, String> metaData = new HashMap<>(); |
||||
|
UUID requestId = UUID.randomUUID(); |
||||
|
metaData.put("serviceId", "core"); |
||||
|
metaData.put("requestUUID", requestId.toString()); |
||||
|
metaData.put("expirationTime", Long.toString(expTime)); |
||||
|
TbMsg msg = TbMsg.newMsg(DataConstants.MAIN_QUEUE_NAME, TbMsgType.REST_API_REQUEST, TENANT_ID, new TbMsgMetaData(metaData), "{\"key\":\"value\"}"); |
||||
|
|
||||
|
Consumer<TbMsg> anyConsumer = TbMsg::getData; |
||||
|
doAnswer(invocation -> { |
||||
|
//check the presence of request in the map after pushMsgToRuleEngine()
|
||||
|
assertThat(requests.size()).isEqualTo(1); |
||||
|
assertThat(requests.get(requestId)).isEqualTo(anyConsumer); |
||||
|
ruleEngineCallService.onQueueMsg(getResponse(requestId, msg), TbCallback.EMPTY); |
||||
|
//check map is empty after onQueueMsg()
|
||||
|
assertThat(requests.size()).isEqualTo(0); |
||||
|
return null; |
||||
|
}).when(tbClusterServiceMock).pushMsgToRuleEngine(any(), any(), any(), anyBoolean(), any()); |
||||
|
ruleEngineCallService.processRestApiCallToRuleEngine(TENANT_ID, requestId, msg, true, anyConsumer); |
||||
|
|
||||
|
verify(tbClusterServiceMock).pushMsgToRuleEngine(TENANT_ID, TENANT_ID, msg, true, null); |
||||
|
} |
||||
|
|
||||
|
private TransportProtos.RestApiCallResponseMsgProto getResponse(UUID requestId, TbMsg msg) { |
||||
|
return TransportProtos.RestApiCallResponseMsgProto.newBuilder() |
||||
|
.setResponse(TbMsg.toByteString(msg)) |
||||
|
.setRequestIdMSB(requestId.getMostSignificantBits()) |
||||
|
.setRequestIdLSB(requestId.getLeastSignificantBits()) |
||||
|
.build(); |
||||
|
} |
||||
|
} |
||||
@ -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,66 @@ |
|||||
|
/** |
||||
|
* 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.rest; |
||||
|
|
||||
|
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.TbNode; |
||||
|
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.plugin.ComponentType; |
||||
|
import org.thingsboard.server.common.msg.TbMsg; |
||||
|
|
||||
|
import java.util.UUID; |
||||
|
|
||||
|
@Slf4j |
||||
|
@RuleNode( |
||||
|
type = ComponentType.ACTION, |
||||
|
name = "rest call reply", |
||||
|
configClazz = TbSendRestApiCallReplyNodeConfiguration.class, |
||||
|
nodeDescription = "Sends reply to REST API call to rule engine", |
||||
|
nodeDetails = "Expects messages with any message type. Forwards incoming message as a reply to REST API call sent to rule engine.", |
||||
|
uiResources = {"static/rulenode/rulenode-core-config.js"}, |
||||
|
configDirective = "tbActionNodeSendRestApiCallReplyConfig", |
||||
|
icon = "call_merge" |
||||
|
) |
||||
|
public class TbSendRestApiCallReplyNode implements TbNode { |
||||
|
|
||||
|
private TbSendRestApiCallReplyNodeConfiguration config; |
||||
|
|
||||
|
@Override |
||||
|
public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { |
||||
|
this.config = TbNodeUtils.convert(configuration, TbSendRestApiCallReplyNodeConfiguration.class); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public void onMsg(TbContext ctx, TbMsg msg) { |
||||
|
String serviceIdStr = msg.getMetaData().getValue(config.getServiceIdMetaDataAttribute()); |
||||
|
String requestIdStr = msg.getMetaData().getValue(config.getRequestIdMetaDataAttribute()); |
||||
|
if (StringUtils.isEmpty(requestIdStr)) { |
||||
|
ctx.tellFailure(msg, new RuntimeException("Request id is not present in the metadata!")); |
||||
|
} else if (StringUtils.isEmpty(serviceIdStr)) { |
||||
|
ctx.tellFailure(msg, new RuntimeException("Service id is not present in the metadata!")); |
||||
|
} else if (StringUtils.isEmpty(msg.getData())) { |
||||
|
ctx.tellFailure(msg, new RuntimeException("Request body is empty!")); |
||||
|
} else { |
||||
|
ctx.getRpcService().sendRestApiCallReply(serviceIdStr, UUID.fromString(requestIdStr), msg); |
||||
|
ctx.tellSuccess(msg); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,45 @@ |
|||||
|
/** |
||||
|
* 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.rest; |
||||
|
|
||||
|
import lombok.Data; |
||||
|
import org.thingsboard.rule.engine.api.NodeConfiguration; |
||||
|
import org.thingsboard.server.common.data.StringUtils; |
||||
|
|
||||
|
@Data |
||||
|
public class TbSendRestApiCallReplyNodeConfiguration implements NodeConfiguration<TbSendRestApiCallReplyNodeConfiguration> { |
||||
|
public static final String SERVICE_ID = "serviceId"; |
||||
|
public static final String REQUEST_UUID = "requestUUID"; |
||||
|
|
||||
|
private String serviceIdMetaDataAttribute; |
||||
|
private String requestIdMetaDataAttribute; |
||||
|
|
||||
|
@Override |
||||
|
public TbSendRestApiCallReplyNodeConfiguration defaultConfiguration() { |
||||
|
TbSendRestApiCallReplyNodeConfiguration configuration = new TbSendRestApiCallReplyNodeConfiguration(); |
||||
|
configuration.setRequestIdMetaDataAttribute(REQUEST_UUID); |
||||
|
configuration.setServiceIdMetaDataAttribute(SERVICE_ID); |
||||
|
return configuration; |
||||
|
} |
||||
|
|
||||
|
public String getServiceIdMetaDataAttribute() { |
||||
|
return !StringUtils.isEmpty(serviceIdMetaDataAttribute) ? serviceIdMetaDataAttribute : SERVICE_ID; |
||||
|
} |
||||
|
|
||||
|
public String getRequestIdMetaDataAttribute() { |
||||
|
return !StringUtils.isEmpty(requestIdMetaDataAttribute) ? requestIdMetaDataAttribute : REQUEST_UUID; |
||||
|
} |
||||
|
} |
||||
@ -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,133 @@ |
|||||
|
/** |
||||
|
* 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.rest; |
||||
|
|
||||
|
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.mockito.ArgumentCaptor; |
||||
|
import org.mockito.Mock; |
||||
|
import org.mockito.junit.jupiter.MockitoExtension; |
||||
|
import org.thingsboard.common.util.JacksonUtil; |
||||
|
import org.thingsboard.rule.engine.api.RuleEngineRpcService; |
||||
|
import org.thingsboard.rule.engine.api.TbContext; |
||||
|
import org.thingsboard.rule.engine.api.TbNodeConfiguration; |
||||
|
import org.thingsboard.rule.engine.api.TbNodeException; |
||||
|
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.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.assertThatNoException; |
||||
|
import static org.mockito.ArgumentMatchers.eq; |
||||
|
import static org.mockito.Mockito.verify; |
||||
|
import static org.mockito.Mockito.when; |
||||
|
|
||||
|
@ExtendWith(MockitoExtension.class) |
||||
|
public class TbSendRestApiCallReplyNodeTest { |
||||
|
|
||||
|
private final DeviceId DEVICE_ID = new DeviceId(UUID.fromString("212445ad-9852-4bfd-819d-6b01ab6ee6b6")); |
||||
|
|
||||
|
private TbSendRestApiCallReplyNode node; |
||||
|
private TbSendRestApiCallReplyNodeConfiguration config; |
||||
|
|
||||
|
@Mock |
||||
|
private TbContext ctxMock; |
||||
|
@Mock |
||||
|
private RuleEngineRpcService rpcServiceMock; |
||||
|
|
||||
|
@BeforeEach |
||||
|
public void setUp() throws TbNodeException { |
||||
|
node = new TbSendRestApiCallReplyNode(); |
||||
|
config = new TbSendRestApiCallReplyNodeConfiguration().defaultConfiguration(); |
||||
|
var configuration = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); |
||||
|
node.init(ctxMock, configuration); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenDefaultConfig_whenInit_thenDoesNotThrowException() { |
||||
|
var configuration = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); |
||||
|
assertThatNoException().isThrownBy(() -> node.init(ctxMock, configuration)); |
||||
|
} |
||||
|
|
||||
|
@ParameterizedTest |
||||
|
@MethodSource |
||||
|
public void givenValidRestApiRequest_whenOnMsg_thenTellSuccess(String requestIdAttribute, String serviceIdAttribute) throws TbNodeException { |
||||
|
config.setRequestIdMetaDataAttribute(requestIdAttribute); |
||||
|
config.setServiceIdMetaDataAttribute(serviceIdAttribute); |
||||
|
var configuration = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); |
||||
|
node.init(ctxMock, configuration); |
||||
|
when(ctxMock.getRpcService()).thenReturn(rpcServiceMock); |
||||
|
String requestUUIDStr = "80b7883b-7ec6-4872-9dd3-b2afd5660fa6"; |
||||
|
String serviceIdStr = "tb-core-0"; |
||||
|
String data = """ |
||||
|
{ |
||||
|
"temperature": 23, |
||||
|
} |
||||
|
"""; |
||||
|
Map<String, String> metadata = Map.of( |
||||
|
requestIdAttribute, requestUUIDStr, |
||||
|
serviceIdAttribute, serviceIdStr); |
||||
|
TbMsg msg = TbMsg.newMsg(TbMsgType.REST_API_REQUEST, DEVICE_ID, new TbMsgMetaData(metadata), data); |
||||
|
|
||||
|
node.onMsg(ctxMock, msg); |
||||
|
|
||||
|
UUID requestUUID = UUID.fromString(requestUUIDStr); |
||||
|
verify(rpcServiceMock).sendRestApiCallReply(serviceIdStr, requestUUID, msg); |
||||
|
verify(ctxMock).tellSuccess(msg); |
||||
|
} |
||||
|
|
||||
|
private static Stream<Arguments> givenValidRestApiRequest_whenOnMsg_thenTellSuccess() { |
||||
|
return Stream.of( |
||||
|
Arguments.of("requestId", "service"), |
||||
|
Arguments.of("requestUUID", "serviceId"), |
||||
|
Arguments.of("some_custom_request_id_field", "some_custom_service_id_field") |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
@ParameterizedTest |
||||
|
@MethodSource |
||||
|
public void givenInvalidRequest_whenOnMsg_thenTellFailure(TbMsgMetaData metaData, String data, String errorMsg) { |
||||
|
TbMsg msg = TbMsg.newMsg(TbMsgType.REST_API_REQUEST, DEVICE_ID, metaData, data); |
||||
|
|
||||
|
node.onMsg(ctxMock, msg); |
||||
|
|
||||
|
ArgumentCaptor<Throwable> captor = ArgumentCaptor.forClass(Throwable.class); |
||||
|
verify(ctxMock).tellFailure(eq(msg), captor.capture()); |
||||
|
Throwable throwable = captor.getValue(); |
||||
|
assertThat(throwable).isInstanceOf(RuntimeException.class).hasMessage(errorMsg); |
||||
|
} |
||||
|
|
||||
|
private static Stream<Arguments> givenInvalidRequest_whenOnMsg_thenTellFailure() { |
||||
|
return Stream.of( |
||||
|
Arguments.of(TbMsgMetaData.EMPTY, TbMsg.EMPTY_STRING, "Request id is not present in the metadata!"), |
||||
|
Arguments.of(new TbMsgMetaData(Map.of("requestUUID", "e1dd3985-efad-45a0-b0d2-0ff5dff2ccac")), |
||||
|
TbMsg.EMPTY_STRING, "Service id is not present in the metadata!"), |
||||
|
Arguments.of(new TbMsgMetaData(Map.of("serviceId", "tb-core-0")), |
||||
|
TbMsg.EMPTY_STRING, "Request id is not present in the metadata!"), |
||||
|
Arguments.of(new TbMsgMetaData(Map.of("requestUUID", "e1dd3985-efad-45a0-b0d2-0ff5dff2ccac", "serviceId", "tb-core-0")), |
||||
|
TbMsg.EMPTY_STRING, "Request body is empty!") |
||||
|
); |
||||
|
} |
||||
|
} |
||||
@ -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