From 7d6ae540796ae1aaa28cd9c560ac72b50783dc91 Mon Sep 17 00:00:00 2001 From: Philipp Ossler Date: Mon, 24 Apr 2023 06:50:34 +0200 Subject: [PATCH 1/2] fix: Enhanced deployment response Add a new API to deploy with resources. Compared to the existing API, it returns an enhanced response with all deployed resources. Keep the old API for backward compatibility with the Web Modeler. --- .../zeebe/play/rest/DeploymentsResource.kt | 128 +++++++++- .../community/zeebe/play/DeploymentTest.kt | 221 ++++++++++++++++++ src/test/kotlin/resources/rating.dmn | 89 +++++++ 3 files changed, 436 insertions(+), 2 deletions(-) create mode 100644 src/test/kotlin/org/camunda/community/zeebe/play/DeploymentTest.kt create mode 100644 src/test/kotlin/resources/rating.dmn diff --git a/src/main/kotlin/org/camunda/community/zeebe/play/rest/DeploymentsResource.kt b/src/main/kotlin/org/camunda/community/zeebe/play/rest/DeploymentsResource.kt index 9c93cdcd..1ce94d1c 100644 --- a/src/main/kotlin/org/camunda/community/zeebe/play/rest/DeploymentsResource.kt +++ b/src/main/kotlin/org/camunda/community/zeebe/play/rest/DeploymentsResource.kt @@ -1,19 +1,94 @@ package org.camunda.community.zeebe.play.rest import io.camunda.zeebe.client.ZeebeClient +import io.camunda.zeebe.client.api.response.DeploymentEvent +import io.zeebe.zeeqs.data.entity.DecisionRequirements +import io.zeebe.zeeqs.data.entity.Process +import io.zeebe.zeeqs.data.repository.DecisionRequirementsRepository +import io.zeebe.zeeqs.data.repository.ProcessRepository +import org.camunda.community.zeebe.play.services.ZeebeClockService +import org.springframework.data.repository.findByIdOrNull import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestMethod import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController import org.springframework.web.multipart.MultipartFile +import java.time.Duration +import java.util.concurrent.Callable +import java.util.concurrent.Executors +import java.util.concurrent.Future +import java.util.concurrent.TimeUnit + +private val RETRY_INTERVAL = Duration.ofMillis(100) @RestController @RequestMapping("/rest/deployments") -class DeploymentsResource(private val zeebeClient: ZeebeClient) { +class DeploymentsResource( + private val zeebeClient: ZeebeClient, + private val zeebeClockService: ZeebeClockService, + private val processRepository: ProcessRepository, + private val decisionRequirementsRepository: DecisionRequirementsRepository +) { + + private val executor = Executors.newSingleThreadExecutor() @RequestMapping(path = ["/"], method = [RequestMethod.POST]) fun deployResources(@RequestParam("resources") resources: Array): Long { + // keep this API for backward compatibility with Web Modeler + return deploy(resources).key; + } + + @RequestMapping(path = ["/deploy"], method = [RequestMethod.POST]) + fun deployResourcesWithMetadata(@RequestParam("resources") resources: Array): DeploymentResponse { + // Use the current time for the duplicate check. The isDuplicate property is not available. + val timeBeforeDeploy = zeebeClockService.getCurrentTime() + val deployment = deploy(resources) + + // Wait until the resources are imported + val deployedProcesses = deployment.processes.associate { + it.processDefinitionKey to findProcessByKeyAsync(it.processDefinitionKey) + } + val deployedDecisionRequirements = deployment.decisions.associate { + it.decisionRequirementsKey to findDecisionRequirementsByKeyAsync(it.decisionRequirementsKey) + } + + return DeploymentResponse( + deploymentKey = deployment.key, + deployedProcesses = deployment.processes.map { process -> + DeployedProcess( + processKey = process.processDefinitionKey, + bpmnProcessId = process.bpmnProcessId, + resourceName = process.resourceName, + isDuplicate = deployedProcesses[process.processDefinitionKey] + ?.let { + it.get( + 10, + TimeUnit.SECONDS + ).deployTime < timeBeforeDeploy.toEpochMilli() + } + ?: false + ) + }, + deployedDecisions = deployment.decisions.map { decision -> + val decisionRequirements = + deployedDecisionRequirements[decision.decisionRequirementsKey]?.get( + 10, + TimeUnit.SECONDS + ) + + DeployedDecision( + decisionKey = decision.decisionKey, + decisionId = decision.dmnDecisionId, + resourceName = decisionRequirements?.resourceName ?: "?", + isDuplicate = decisionRequirements + ?.let { it.deployTime < timeBeforeDeploy.toEpochMilli() } + ?: false + ) + } + ) + } + private fun deploy(resources: Array): DeploymentEvent { if (resources.isEmpty()) { throw RuntimeException("no resources to deploy") } @@ -32,7 +107,56 @@ class DeploymentsResource(private val zeebeClient: ZeebeClient) { return deployCommand .send() .join() - .key; } + private fun findProcessByKeyAsync(processKey: Long): Future = + executor.submit(Callable { + findProcessByKey(processKey = processKey) + }) + + private fun findProcessByKey(processKey: Long): Process { + return processRepository + .findByIdOrNull(processKey) + ?: run { + // wait and retry + Thread.sleep(RETRY_INTERVAL.toMillis()) + findProcessByKey(processKey) + } + } + + private fun findDecisionRequirementsByKeyAsync(decisionRequirementsKey: Long): Future = + executor.submit(Callable { + findDecisionRequirementsByKey(decisionRequirementsKey = decisionRequirementsKey) + }) + + private fun findDecisionRequirementsByKey(decisionRequirementsKey: Long): DecisionRequirements { + return decisionRequirementsRepository + .findByIdOrNull(decisionRequirementsKey) + ?: run { + // wait and retry + Thread.sleep(RETRY_INTERVAL.toMillis()) + findDecisionRequirementsByKey(decisionRequirementsKey) + } + } + + data class DeploymentResponse( + val deploymentKey: Long, + val deployedProcesses: List, + val deployedDecisions: List + ) + + data class DeployedProcess( + val processKey: Long, + val bpmnProcessId: String, + val resourceName: String, + val isDuplicate: Boolean + ) + + data class DeployedDecision( + val decisionKey: Long, + val decisionId: String, + val resourceName: String, + val isDuplicate: Boolean + ) + } \ No newline at end of file diff --git a/src/test/kotlin/org/camunda/community/zeebe/play/DeploymentTest.kt b/src/test/kotlin/org/camunda/community/zeebe/play/DeploymentTest.kt new file mode 100644 index 00000000..98491b45 --- /dev/null +++ b/src/test/kotlin/org/camunda/community/zeebe/play/DeploymentTest.kt @@ -0,0 +1,221 @@ +package org.camunda.community.zeebe.play + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.registerKotlinModule +import io.camunda.zeebe.model.bpmn.Bpmn +import io.zeebe.zeeqs.data.repository.DecisionRepository +import io.zeebe.zeeqs.data.repository.DecisionRequirementsRepository +import io.zeebe.zeeqs.data.repository.ProcessRepository +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.tuple +import org.awaitility.kotlin.await +import org.camunda.community.zeebe.play.rest.DeploymentsResource +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInfo +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.mock.web.MockMultipartFile +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +class DeploymentTest( + @Autowired private val mvc: MockMvc, + @Autowired private val processRepository: ProcessRepository, + @Autowired private val decisionRepository: DecisionRepository, + @Autowired private val decisionRequirementsRepository: DecisionRequirementsRepository +) { + + private val objectMapper = ObjectMapper().registerKotlinModule() + + private val process = Bpmn.createExecutableProcess("process") + .startEvent() + .endEvent() + .done() + + private val processAsBytes = Bpmn.convertToString(process).toByteArray() + + private val decisionAsBytes = javaClass.getResourceAsStream("/rating.dmn")?.readAllBytes()!! + + @BeforeEach + fun `clean database`() { + processRepository.deleteAll() + decisionRepository.deleteAll() + decisionRequirementsRepository.deleteAll() + } + + @Test + fun `should deploy process without metadata (old API)`(testInfo: TestInfo) { + // given + val resourceName = "process_${testInfo.displayName}.bpmn" + + // when + val response = mvc.perform( + multipart("/rest/deployments/") + .file( + MockMultipartFile( + "resources", + resourceName, + "application/bpmn", + processAsBytes + ) + ) + ) + .andExpect(status().isOk()) + .andReturn() + .response + .contentAsString; + + // then + assertThat(response.toLong()).isPositive() + + await.untilAsserted { + assertThat(processRepository.findAll()) + .hasSize(1) + .extracting { it.resourceName } + .contains(resourceName) + } + } + + @Test + fun `should deploy process`(testInfo: TestInfo) { + // given + val resourceName = "process_${testInfo.displayName}.bpmn" + + // when + val deploymentResponse = deployProcess(resourceName) + + // then + assertThat(deploymentResponse.deploymentKey).isPositive() + assertThat(deploymentResponse.deployedProcesses) + .hasSize(1) + .extracting({ it.bpmnProcessId }, { it.resourceName }, { it.isDuplicate }) + .contains(tuple("process", resourceName, false)) + assertThat(deploymentResponse.deployedDecisions).isEmpty() + + await.untilAsserted { + assertThat(processRepository.findAll()) + .hasSize(1) + .extracting({ it.key }, { it.resourceName }) + .contains( + tuple( + deploymentResponse.deployedProcesses[0].processKey, + resourceName + ) + ) + } + } + + @Test + fun `should re-deploy process`(testInfo: TestInfo) { + // given + val resourceName = "process_${testInfo.displayName}.bpmn" + + deployProcess(resourceName) + + // when + val deploymentResponse = deployProcess(resourceName) + + assertThat(deploymentResponse.deploymentKey).isPositive() + assertThat(deploymentResponse.deployedProcesses) + .hasSize(1) + .extracting({ it.bpmnProcessId }, { it.resourceName }, { it.isDuplicate }) + .contains(tuple("process", resourceName, true)) + assertThat(deploymentResponse.deployedDecisions).isEmpty() + } + + @Test + fun `should deploy decision`(testInfo: TestInfo) { + // given + val resourceName = "rating_${testInfo.displayName}.dmn" + + // when + val deploymentResponse = deployDecision(resourceName) + + // then + assertThat(deploymentResponse.deploymentKey).isPositive() + assertThat(deploymentResponse.deployedDecisions) + .hasSize(2) + .extracting({ it.decisionId }, { it.resourceName }, { it.isDuplicate }) + .contains( + tuple("decision_a", resourceName, false), + tuple("decision_b", resourceName, false) + ) + assertThat(deploymentResponse.deployedProcesses).isEmpty() + + await.untilAsserted { + assertThat(decisionRepository.findAll()) + .hasSize(2) + .extracting({ it.key }, { it.decisionId }) + .containsAll( + deploymentResponse.deployedDecisions.map { + tuple( + it.decisionKey, + it.decisionId + ) + } + ) + } + } + + @Test + fun `should re-deploy decision`(testInfo: TestInfo) { + // given + val resourceName = "rating_${testInfo.displayName}.dmn" + deployDecision(resourceName) + + // when + val deploymentResponse = deployDecision(resourceName) + + // then + assertThat(deploymentResponse.deploymentKey).isPositive() + assertThat(deploymentResponse.deployedDecisions) + .hasSize(2) + .extracting({ it.decisionId }, { it.resourceName }, { it.isDuplicate }) + .contains( + tuple("decision_a", resourceName, true), + tuple("decision_b", resourceName, true) + ) + assertThat(deploymentResponse.deployedProcesses).isEmpty() + } + + private fun deployProcess(resourceName: String): DeploymentsResource.DeploymentResponse { + return deploymentResource( + MockMultipartFile( + "resources", + resourceName, + "application/bpmn", + processAsBytes + ) + ) + } + + private fun deployDecision(resourceName: String): DeploymentsResource.DeploymentResponse { + return deploymentResource( + MockMultipartFile( + "resources", + resourceName, + "application/dmn", + decisionAsBytes + ) + ) + } + + private fun deploymentResource(multipartFile: MockMultipartFile): DeploymentsResource.DeploymentResponse { + val response = mvc.perform( + multipart("/rest/deployments/deploy/") + .file(multipartFile) + ) + .andExpect(status().isOk()) + .andReturn() + .response + .contentAsString + + return objectMapper.readValue(response, DeploymentsResource.DeploymentResponse::class.java) + } + +} diff --git a/src/test/kotlin/resources/rating.dmn b/src/test/kotlin/resources/rating.dmn new file mode 100644 index 00000000..d35cc481 --- /dev/null +++ b/src/test/kotlin/resources/rating.dmn @@ -0,0 +1,89 @@ + + + + + + + + + + decision_b + + + + + + "high" + + + "A++" + + + + + "mid" + + + "A+" + + + + + "low" + + + "A" + + + + + + + + + x + + + + + + > 10 + + + "high" + + + + + > 5 + + + "mid" + + + + + + + + "low" + + + + + + + + + + + + + + + + + + + + From 0251089bb0790e038f2344451211d7f64b9c1207 Mon Sep 17 00:00:00 2001 From: Philipp Ossler Date: Tue, 25 Apr 2023 09:16:06 +0200 Subject: [PATCH 2/2] refactor: use the new deployment API --- src/main/resources/public/js/rest-client.js | 2 +- src/main/resources/public/js/view-deployment.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/resources/public/js/rest-client.js b/src/main/resources/public/js/rest-client.js index 8482b894..6e9885d5 100644 --- a/src/main/resources/public/js/rest-client.js +++ b/src/main/resources/public/js/rest-client.js @@ -58,7 +58,7 @@ function sendTimeTravelRequestWithDateTime(dateTime) { function deployResources(resources) { return $.ajax({ type: "POST", - url: "/rest/deployments/", + url: "/rest/deployments/deploy", data: new FormData(resources), processData: false, contentType: false, diff --git a/src/main/resources/public/js/view-deployment.js b/src/main/resources/public/js/view-deployment.js index 4ccfccc7..5def867a 100644 --- a/src/main/resources/public/js/view-deployment.js +++ b/src/main/resources/public/js/view-deployment.js @@ -113,8 +113,8 @@ function deploymentModal() { let resources = $("#deploymentForm")[0]; deployResources(resources) - .done(function (deploymentKey) { - const toastId = "new-deployment-" + deploymentKey; + .done(function (deployment) { + const toastId = "new-deployment-" + deployment.deploymentKey; const content = "New resources deployed"; showNotificationSuccess(toastId, content);