From e757b09304d54109050c3460c03138467a49fcf8 Mon Sep 17 00:00:00 2001 From: Bernd Ruecker Date: Fri, 21 Oct 2022 10:05:08 +0200 Subject: [PATCH] Handle duplicate context path's (#27) Fixes https://github.com/camunda/connector-sdk-inbound-webhook/issues/25 Co-authored-by: Igor Petrov <108870003+igpetrov@users.noreply.github.com> --- .../inbound/feel/FeelConfiguration.java | 19 ++ .../inbound/feel/FeelEngineWrapper.java | 8 +- .../importer/ProcessDefinitionImporter.java | 5 +- .../inbound/operate/OperateClientFactory.java | 15 +- .../operate/OperateClientLifecycle.java | 181 ++++++++++++++++++ .../registry/InboundConnectorProperties.java | 7 + .../registry/InboundConnectorRegistry.java | 24 ++- .../webhook/InboundWebhookRestController.java | 146 +++++++------- .../webhook/WebhookConnectorProperties.java | 21 +- .../inbound/webhook/WebhookResponse.java | 49 +++++ ...nectorInboudPrototypeApplicationTests.java | 13 -- .../InboundConnectorTestConfiguration.java | 27 +++ .../WebhookControllerPlainJavaTests.java | 127 ++++++++++++ .../WebhookControllerTestZeebeTests.java | 92 +++++++++ 14 files changed, 613 insertions(+), 121 deletions(-) create mode 100644 src/main/java/io/camunda/connector/inbound/feel/FeelConfiguration.java create mode 100644 src/main/java/io/camunda/connector/inbound/operate/OperateClientLifecycle.java create mode 100644 src/main/java/io/camunda/connector/inbound/webhook/WebhookResponse.java delete mode 100644 src/test/java/io/camunda/connector/inbound/ConnectorInboudPrototypeApplicationTests.java create mode 100644 src/test/java/io/camunda/connector/inbound/InboundConnectorTestConfiguration.java create mode 100644 src/test/java/io/camunda/connector/inbound/WebhookControllerPlainJavaTests.java create mode 100644 src/test/java/io/camunda/connector/inbound/WebhookControllerTestZeebeTests.java diff --git a/src/main/java/io/camunda/connector/inbound/feel/FeelConfiguration.java b/src/main/java/io/camunda/connector/inbound/feel/FeelConfiguration.java new file mode 100644 index 0000000000..02370a8802 --- /dev/null +++ b/src/main/java/io/camunda/connector/inbound/feel/FeelConfiguration.java @@ -0,0 +1,19 @@ +package io.camunda.connector.inbound.feel; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class FeelConfiguration { + + /** + * Provides a {@link FeelEngineWrapper} unless already present in the Spring Context + * (as also used by other applications - as soon as we switch to use the one from util + */ + @Bean + //@ConditionalOnMissingBean(FeelEngineWrapper.class) + public FeelEngineWrapper feelEngine() { + return new FeelEngineWrapper(); + } +} diff --git a/src/main/java/io/camunda/connector/inbound/feel/FeelEngineWrapper.java b/src/main/java/io/camunda/connector/inbound/feel/FeelEngineWrapper.java index 253a34ee87..2086c68778 100644 --- a/src/main/java/io/camunda/connector/inbound/feel/FeelEngineWrapper.java +++ b/src/main/java/io/camunda/connector/inbound/feel/FeelEngineWrapper.java @@ -1,6 +1,7 @@ package io.camunda.connector.inbound.feel; import org.camunda.feel.FeelEngine; +import org.camunda.feel.impl.JavaValueMapper; import org.camunda.feel.impl.SpiServiceLoader; import org.springframework.stereotype.Service; import scala.jdk.javaapi.CollectionConverters; @@ -10,7 +11,9 @@ import java.util.Objects; import java.util.Optional; -@Service +/** + * Wait for https://github.com/camunda/connector-sdk/issues/178 to be solved - then delete this here. + */ public class FeelEngineWrapper { private final FeelEngine feelEngine; @@ -18,8 +21,7 @@ public class FeelEngineWrapper { public FeelEngineWrapper() { feelEngine = new FeelEngine.Builder() - .valueMapper(SpiServiceLoader.loadValueMapper()) - .functionProvider(SpiServiceLoader.loadFunctionProvider()) + .customValueMapper(new JavaValueMapper()) .build(); } diff --git a/src/main/java/io/camunda/connector/inbound/importer/ProcessDefinitionImporter.java b/src/main/java/io/camunda/connector/inbound/importer/ProcessDefinitionImporter.java index b5014981f1..21cb6324e8 100644 --- a/src/main/java/io/camunda/connector/inbound/importer/ProcessDefinitionImporter.java +++ b/src/main/java/io/camunda/connector/inbound/importer/ProcessDefinitionImporter.java @@ -17,6 +17,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; @@ -35,13 +36,11 @@ public class ProcessDefinitionImporter { private InboundConnectorRegistry registry; @Autowired - private OperateClientFactory operateClientFactory; + private CamundaOperateClient camundaOperateClient; @Scheduled(fixedDelay = 5000) public void scheduleImport() throws OperateException { LOG.trace("Query process deployments..."); - // Lazy initialize the client - could be replaced by some Spring tricks later - CamundaOperateClient camundaOperateClient = operateClientFactory.camundaOperateClient(); // TODO: Think about pagination if we really have more process definitions SearchQuery processDefinitionQuery = new SearchQuery.Builder() diff --git a/src/main/java/io/camunda/connector/inbound/operate/OperateClientFactory.java b/src/main/java/io/camunda/connector/inbound/operate/OperateClientFactory.java index 47e49cc86c..bf225ba8d0 100644 --- a/src/main/java/io/camunda/connector/inbound/operate/OperateClientFactory.java +++ b/src/main/java/io/camunda/connector/inbound/operate/OperateClientFactory.java @@ -9,6 +9,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration @@ -49,10 +50,6 @@ public class OperateClientFactory { @Value("${camunda.operate.client.keycloak-realm:#{null}}") private String operateKeycloakRealm; - // Cached client, which is lazy initialized - // TODO: Move lazy initialization to the Spring level - private CamundaOperateClient client; - private String getOperateUrl() { if (clusterId!=null) { String url = "https://" + region + ".operate.camunda.io/" + clusterId + "/"; @@ -92,16 +89,10 @@ public AuthInterface getAuthentication(String operateUrl) { } public CamundaOperateClient camundaOperateClient() throws OperateException { - if (client==null) { - client = createCamundaOperateClient(); - } - return client; - } - - private CamundaOperateClient createCamundaOperateClient() throws OperateException { String operateUrl = getOperateUrl(); return new CamundaOperateClient.Builder() .operateUrl(operateUrl) - .authentication(getAuthentication(operateUrl)).build(); + .authentication(getAuthentication(operateUrl)) + .build(); } } diff --git a/src/main/java/io/camunda/connector/inbound/operate/OperateClientLifecycle.java b/src/main/java/io/camunda/connector/inbound/operate/OperateClientLifecycle.java new file mode 100644 index 0000000000..840a7d7551 --- /dev/null +++ b/src/main/java/io/camunda/connector/inbound/operate/OperateClientLifecycle.java @@ -0,0 +1,181 @@ + package io.camunda.connector.inbound.operate; + +import io.camunda.operate.CamundaOperateClient; +import io.camunda.operate.dto.*; +import io.camunda.operate.exception.OperateException; +import io.camunda.operate.search.SearchQuery; +import io.camunda.zeebe.model.bpmn.BpmnModelInstance; +import org.apache.hc.core5.http.ClassicHttpRequest; +import org.apache.hc.core5.http.Header; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.SmartLifecycle; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.function.Supplier; + +/** + * Lifecycle implementation that also directly acts as a CamundaOperateClient by delegating all methods to the + * CamundaOperateClient that is controlled (and kept in the delegate field) + * + */ +@Component +public class OperateClientLifecycle extends CamundaOperateClient implements SmartLifecycle, Supplier { + + public static final int PHASE = 22222; + protected boolean autoStartup = true; + protected boolean running = false; + protected boolean runningInTestContext = false; + + protected final OperateClientFactory factory; + protected CamundaOperateClient delegate; + + @Autowired + public OperateClientLifecycle(final OperateClientFactory factory) { + this.factory = factory; + } + + /** + * Allows to set the delegate being used manually, helpful for test cases + */ + public OperateClientLifecycle(final CamundaOperateClient delegate) { + this.factory = null; + this.delegate = delegate; + } + + @Override + public void start() { + if (factory!=null) { + try { + delegate = factory.camundaOperateClient(); + } catch (OperateException e) { + throw new RuntimeException("Could not start Camunda Operate Client: "+ e.getMessage(), e); + } + this.running = true; + } else { + // in test cases we have injected a delegate already + runningInTestContext = true; + } + } + + @Override + public void stop() { + try { + delegate = null; + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + running = false; + } + } + + + @Override + public CamundaOperateClient get() { + if (!isRunning()) { + throw new IllegalStateException("CamundaOperateClient is not yet created!"); + } + return delegate; + } + + @Override + public boolean isAutoStartup() { + return autoStartup; + } + + + @Override + public boolean isRunning() { + return running; + } + + @Override + public int getPhase() { + return PHASE; + } + + @Override + public ProcessDefinition getProcessDefinition(Long key) throws OperateException { + return delegate.getProcessDefinition(key); + } + + @Override + public List searchProcessDefinitions(SearchQuery query) throws OperateException { + return delegate.searchProcessDefinitions(query); + } + + @Override + public String getProcessDefinitionXml(Long key) throws OperateException { + return delegate.getProcessDefinitionXml(key); + } + + @Override + public BpmnModelInstance getProcessDefinitionModel(Long key) throws OperateException { + return delegate.getProcessDefinitionModel(key); + } + + @Override + public ProcessInstance getProcessInstance(Long key) throws OperateException { + return delegate.getProcessInstance(key); + } + + @Override + public List searchProcessInstances(SearchQuery query) throws OperateException { + return delegate.searchProcessInstances(query); + } + + @Override + public FlownodeInstance getFlownodeInstance(Long key) throws OperateException { + return delegate.getFlownodeInstance(key); + } + + @Override + public List searchFlownodeInstances(SearchQuery query) throws OperateException { + return delegate.searchFlownodeInstances(query); + } + + @Override + public Incident getIncident(Long key) throws OperateException { + return delegate.getIncident(key); + } + + @Override + public List searchIncidents(SearchQuery query) throws OperateException { + return delegate.searchIncidents(query); + } + + @Override + public Variable getVariable(Long key) throws OperateException { + return delegate.getVariable(key); + } + + @Override + public List searchVariables(SearchQuery query) throws OperateException { + return delegate.searchVariables(query); + } + + @Override + public String getOperateUrl() { + return delegate.getOperateUrl(); + } + + @Override + public void setOperateUrl(String operateUrl) { + delegate.setOperateUrl(operateUrl); + } + + @Override + public Header getAuthHeader() { + return delegate.getAuthHeader(); + } + + @Override + public void setAuthHeader(Header authHeader) { + delegate.setAuthHeader(authHeader); + } + + @Override + public void setTokenExpiration(int tokenExpiration) { + delegate.setTokenExpiration(tokenExpiration); + } +} diff --git a/src/main/java/io/camunda/connector/inbound/registry/InboundConnectorProperties.java b/src/main/java/io/camunda/connector/inbound/registry/InboundConnectorProperties.java index 9846c3c439..2bd4681824 100644 --- a/src/main/java/io/camunda/connector/inbound/registry/InboundConnectorProperties.java +++ b/src/main/java/io/camunda/connector/inbound/registry/InboundConnectorProperties.java @@ -28,6 +28,13 @@ public InboundConnectorProperties(String bpmnProcessId, int version, long proces this.processDefinitionKey = processDefinitionKey; } + /** + * @return a string identifying this connector in log message or responses + */ + public String getConnectorIdentifier() { + return type + "-" + bpmnProcessId + "-" + version; + } + public String getBpmnProcessId() { return bpmnProcessId; } diff --git a/src/main/java/io/camunda/connector/inbound/registry/InboundConnectorRegistry.java b/src/main/java/io/camunda/connector/inbound/registry/InboundConnectorRegistry.java index bc6effb3d7..975fa85d84 100644 --- a/src/main/java/io/camunda/connector/inbound/registry/InboundConnectorRegistry.java +++ b/src/main/java/io/camunda/connector/inbound/registry/InboundConnectorRegistry.java @@ -3,18 +3,23 @@ import io.camunda.connector.inbound.webhook.WebhookConnectorProperties; import org.springframework.stereotype.Service; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Map; -import java.util.Set; +import java.util.*; @Service public class InboundConnectorRegistry { private Set registeredProcessDefinitionKeys = new HashSet<>(); - private Map registeredWebhookConnectorsByContextPath = new HashMap<>(); + private Map> registeredWebhookConnectorsByContextPath = new HashMap<>(); //private List registeredInboundConnectors = new ArrayList<>(); + /** + * Reset registry and forget about all connectors, especially useful in tests when the context needs to get cleared + */ + public void reset() { + registeredProcessDefinitionKeys = new HashSet<>(); + registeredWebhookConnectorsByContextPath = new HashMap<>(); + } + public boolean processDefinitionChecked(long processDefinitionKey) { return registeredProcessDefinitionKeys.contains(processDefinitionKey); } @@ -26,14 +31,19 @@ public void markProcessDefinitionChecked(long processDefinitionKey) { public void registerWebhookConnector(InboundConnectorProperties properties) { registeredProcessDefinitionKeys.add(properties.getProcessDefinitionKey()); WebhookConnectorProperties webhookConnectorProperties = new WebhookConnectorProperties(properties); - registeredWebhookConnectorsByContextPath.put(webhookConnectorProperties.getContext(), webhookConnectorProperties); + String context = webhookConnectorProperties.getContext(); + + if (!registeredWebhookConnectorsByContextPath.containsKey(context)) { + registeredWebhookConnectorsByContextPath.put(context, new ArrayList<>()); + } + registeredWebhookConnectorsByContextPath.get(context).add(webhookConnectorProperties); } public boolean containsContextPath(String context) { return registeredWebhookConnectorsByContextPath.containsKey(context); } - public WebhookConnectorProperties getWebhookConnectorByContextPath(String context) { + public Collection getWebhookConnectorByContextPath(String context) { return registeredWebhookConnectorsByContextPath.get(context); } diff --git a/src/main/java/io/camunda/connector/inbound/webhook/InboundWebhookRestController.java b/src/main/java/io/camunda/connector/inbound/webhook/InboundWebhookRestController.java index 9ae3cadc99..d81f7c0b3d 100644 --- a/src/main/java/io/camunda/connector/inbound/webhook/InboundWebhookRestController.java +++ b/src/main/java/io/camunda/connector/inbound/webhook/InboundWebhookRestController.java @@ -9,6 +9,7 @@ import io.camunda.zeebe.client.api.response.ProcessInstanceEvent; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PathVariable; @@ -18,6 +19,7 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; +import java.util.Collection; import java.io.IOException; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; @@ -33,6 +35,7 @@ public class InboundWebhookRestController { private final FeelEngineWrapper feelEngine; private final ObjectMapper jsonMapper; + @Autowired public InboundWebhookRestController( final InboundConnectorRegistry registry, final ZeebeClient zeebeClient, @@ -45,7 +48,7 @@ public InboundWebhookRestController( } @PostMapping("/inbound/{context}") - public ResponseEntity inbound( + public ResponseEntity inbound( @PathVariable String context, @RequestBody byte[] bodyAsByteArray, // it is important to get pure body in order to recalculate HMAC @RequestHeader Map headers) throws IOException { @@ -55,56 +58,53 @@ public ResponseEntity inbound( if (!registry.containsContextPath(context)) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No webhook found for context: " + context); } - WebhookConnectorProperties connectorProperties = registry.getWebhookConnectorByContextPath(context); // TODO(nikku): what context do we expose? // TODO(igpetrov): handling exceptions? Throw or fail? Maybe spring controller advice? + // TODO: Check if that always works (can we have an empty body for example?) Map bodyAsMap = jsonMapper.readValue(bodyAsByteArray, Map.class); - final Map webhookContext = - Map.of( - "request", - Map.of( + final Map webhookContext = Map.of( + "request", Map.of( "body", bodyAsMap, "headers", headers)); - final var valid = validateSecret(connectorProperties, webhookContext); - - if (!valid) { - LOG.debug("Failed validation {} :: {} {}", context, webhookContext); - return ResponseEntity.status(400).build(); - } - - try { - // TODO(igpetrov): currently in test mode. Don't enforce for now. - final var isHmacValid = isValidHmac(connectorProperties, bodyAsByteArray, headers); - LOG.debug("Test mode: validating HMAC. Was {}", isHmacValid); - } catch (NoSuchAlgorithmException e) { - LOG.error("Wasn't able to recognise HMAC algorithm {}", connectorProperties.getHMACAlgo()); - } catch (InvalidKeyException e) { - // FIXME: remove exposure of secret key when prototyping complit - LOG.error("Secret key '{}' was invalid", connectorProperties.getHMACSecret()); - } - - final var shouldActivate = checkActivation(connectorProperties, webhookContext); - if (!shouldActivate) { - LOG.debug("Should not activate {} :: {}", context, webhookContext); - return ResponseEntity.status(HttpStatus.OK).build(); + WebhookResponse response = new WebhookResponse(); + Collection connectors = registry.getWebhookConnectorByContextPath(context); + for (WebhookConnectorProperties connectorProperties : connectors) { + + try { + if (!validateSecret(connectorProperties, webhookContext)) { + LOG.debug("Failed validation {} :: {} {}", context, webhookContext); + response.addUnauthorizedConnector(connectorProperties); + } else { // Authorized + + try { + // TODO(igpetrov): currently in test mode. Don't enforce for now. + final var isHmacValid = isValidHmac(connectorProperties, bodyAsByteArray, headers); + LOG.debug("Test mode: validating HMAC. Was {}", isHmacValid); + } catch (NoSuchAlgorithmException e) { + LOG.error("Wasn't able to recognise HMAC algorithm {}", connectorProperties.getHMACAlgo()); + } catch (InvalidKeyException e) { + // FIXME: remove exposure of secret key when prototyping complit + LOG.error("Secret key '{}' was invalid", connectorProperties.getHMACSecret()); + } + + if (!checkActivation(connectorProperties, webhookContext)) { + LOG.debug("Should not activate {} :: {}", context, webhookContext); + response.addUnactivatedConnector(connectorProperties); + } else { + ProcessInstanceEvent processInstanceEvent = executeWebhookConnector(connectorProperties, webhookContext); + LOG.debug("Webhook {} created process instance {}", connectorProperties, processInstanceEvent); + response.addExecutedConnector(connectorProperties, processInstanceEvent); + } + } + } catch (Exception exception) { + LOG.error("Webhook {} failed to create process instance", connectorProperties, exception); + response.addException(connectorProperties, exception); + } } - final var variables = extractVariables(connectorProperties, webhookContext); - - final var processInstanceEvent = startInstance(connectorProperties, variables); - - LOG.debug( - "Webhook {} created process instance {} with variables {}", - connectorProperties, - processInstanceEvent.getProcessInstanceKey(), - variables); - - // TODO: how much context do we want to expose? - - // respond with 201 if execution triggered behavior - return ResponseEntity.status(HttpStatus.CREATED).body(processInstanceEvent); + return ResponseEntity.status(HttpStatus.OK).body(response); } private boolean isValidHmac(final WebhookConnectorProperties connectorProperties, @@ -126,25 +126,22 @@ private boolean isValidHmac(final WebhookConnectorProperties connectorProperties return validator.isRequestValid(); } - private ProcessInstanceEvent startInstance( - WebhookConnectorProperties connectorProperties, Map variables) { - try { - return zeebeClient - .newCreateInstanceCommand() - .bpmnProcessId(connectorProperties.getBpmnProcessId()) - .version(connectorProperties.getVersion()) - .variables(variables) - .send() - .join(); - } catch (Exception exception) { - throw fail("Failed to start process instance", connectorProperties, exception); - } - } - - private ResponseStatusException fail( - String message, WebhookConnectorProperties connectorProperties, Exception exception) { - LOG.error("Webhook {} failed to create process instance", connectorProperties, exception); - return new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, message); + /** + * This could be potentially moved to an interface? + * See https://github.com/camunda/connector-sdk-inbound-webhook/issues/26 + * @return + */ + private ProcessInstanceEvent executeWebhookConnector(WebhookConnectorProperties connectorProperties, Map webhookContext) { + final Map variables = extractVariables(connectorProperties, webhookContext); + + return zeebeClient + .newCreateInstanceCommand() + .bpmnProcessId(connectorProperties.getBpmnProcessId()) + .version(connectorProperties.getVersion()) + .variables(variables) + .send() + .join(); + //throw fail("Failed to start process instance", connectorProperties, exception); } private Map extractVariables( @@ -154,11 +151,8 @@ private Map extractVariables( if (variableMapping == null) { return context; } - try { - return feelEngine.evaluate(variableMapping, context); - } catch (Exception exception) { - throw fail("Failed to extract variables", connectorProperties, exception); - } + return feelEngine.evaluate(variableMapping, context); +// throw fail("Failed to extract variables", connectorProperties, exception); } private boolean checkActivation( @@ -169,12 +163,9 @@ private boolean checkActivation( if (activationCondition == null) { return true; } - try { - Object shouldActivate = feelEngine.evaluate(activationCondition, context); - return Boolean.TRUE.equals(shouldActivate); - } catch (Exception exception) { - throw fail("Failed to check activation", connectorProperties, exception); - } + Object shouldActivate = feelEngine.evaluate(activationCondition, context); + return Boolean.TRUE.equals(shouldActivate); +// throw fail("Failed to check activation", connectorProperties, exception); } private boolean validateSecret( @@ -183,11 +174,10 @@ private boolean validateSecret( // at this point we assume secrets exist / had been specified var secretExtractor = connectorProperties.getSecretExtractor(); var secret = connectorProperties.getSecret(); - try { - String providedSecret = feelEngine.evaluate(secretExtractor, context); - return secret.equals(providedSecret); - } catch (Exception exception) { - throw fail("Failed to validate secret", connectorProperties, exception); - } + + String providedSecret = feelEngine.evaluate(secretExtractor, context); + return secret.equals(providedSecret); +// throw fail("Failed to validate secret", connectorProperties, exception); + } } diff --git a/src/main/java/io/camunda/connector/inbound/webhook/WebhookConnectorProperties.java b/src/main/java/io/camunda/connector/inbound/webhook/WebhookConnectorProperties.java index 0cf43c56f7..079e3ab61e 100644 --- a/src/main/java/io/camunda/connector/inbound/webhook/WebhookConnectorProperties.java +++ b/src/main/java/io/camunda/connector/inbound/webhook/WebhookConnectorProperties.java @@ -10,20 +10,31 @@ public WebhookConnectorProperties(InboundConnectorProperties properties) { this.genericProperties = properties; } + public String getConnectorIdentifier() { + return "" + genericProperties.getType() + "-" + getContext() + "-" + genericProperties.getBpmnProcessId() + "-" + genericProperties.getVersion(); + } + public String readProperty(String propertyName) { + String result = genericProperties.getProperties().get(propertyName); + if (result==null) { + throw new IllegalArgumentException("Property '"+propertyName+"' must be set for connector"); + } + return result; + } + public String getContext() { - return genericProperties.getProperties().get("inbound.context"); + return readProperty("inbound.context"); } public String getSecretExtractor() { - return genericProperties.getProperties().get("inbound.secretExtractor"); + return readProperty("inbound.secretExtractor"); } public String getSecret() { - return genericProperties.getProperties().get("inbound.secret"); + return readProperty("inbound.secret"); } public String getActivationCondition() { - return genericProperties.getProperties().get("inbound.activationCondition"); + return readProperty("inbound.activationCondition"); } public String getVariableMapping() { - return genericProperties.getProperties().get("inbound.variableMapping"); + return readProperty("inbound.variableMapping"); } // Security / HMAC Validation diff --git a/src/main/java/io/camunda/connector/inbound/webhook/WebhookResponse.java b/src/main/java/io/camunda/connector/inbound/webhook/WebhookResponse.java new file mode 100644 index 0000000000..75e003bc41 --- /dev/null +++ b/src/main/java/io/camunda/connector/inbound/webhook/WebhookResponse.java @@ -0,0 +1,49 @@ +package io.camunda.connector.inbound.webhook; + +import io.camunda.zeebe.client.api.response.ProcessInstanceEvent; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +// TODO: Define how much information we want to expose as result +public class WebhookResponse { + + private List unauthorizedConnectors = new ArrayList<>(); + private List unactivatedConnectors = new ArrayList<>(); + private Map executedConnectors = new HashMap<>(); + private List errors = new ArrayList<>(); + + public void addUnauthorizedConnector(WebhookConnectorProperties connectorProperties) { + unauthorizedConnectors.add(connectorProperties.getConnectorIdentifier()); + } + + public void addUnactivatedConnector(WebhookConnectorProperties connectorProperties) { + unactivatedConnectors.add(connectorProperties.getConnectorIdentifier()); + } + + public void addExecutedConnector(WebhookConnectorProperties connectorProperties, ProcessInstanceEvent processInstanceEvent) { + executedConnectors.put(connectorProperties.getConnectorIdentifier(), processInstanceEvent); + } + + public void addException(WebhookConnectorProperties connectorProperties, Exception exception) { + errors.add(connectorProperties.getConnectorIdentifier() + ">" + exception.getMessage()); + } + + public List getUnauthorizedConnectors() { + return unauthorizedConnectors; + } + + public List getUnactivatedConnectors() { + return unactivatedConnectors; + } + + public Map getExecutedConnectors() { + return executedConnectors; + } + + public List getErrors() { + return errors; + } +} diff --git a/src/test/java/io/camunda/connector/inbound/ConnectorInboudPrototypeApplicationTests.java b/src/test/java/io/camunda/connector/inbound/ConnectorInboudPrototypeApplicationTests.java deleted file mode 100644 index 92aa6692b4..0000000000 --- a/src/test/java/io/camunda/connector/inbound/ConnectorInboudPrototypeApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package io.camunda.connector.inbound; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class ConnectorInboudPrototypeApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/src/test/java/io/camunda/connector/inbound/InboundConnectorTestConfiguration.java b/src/test/java/io/camunda/connector/inbound/InboundConnectorTestConfiguration.java new file mode 100644 index 0000000000..135f7955cc --- /dev/null +++ b/src/test/java/io/camunda/connector/inbound/InboundConnectorTestConfiguration.java @@ -0,0 +1,27 @@ +package io.camunda.connector.inbound; + +import io.camunda.connector.inbound.operate.OperateClientLifecycle; +import io.camunda.operate.CamundaOperateClient; +import io.camunda.operate.exception.OperateException; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; + +import java.util.ArrayList; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@Configuration +public class InboundConnectorTestConfiguration { + + @Bean + @Primary + public OperateClientLifecycle operateClientLifecycle() throws OperateException { + CamundaOperateClient camundaOperateClientMock = mock(CamundaOperateClient.class); + when(camundaOperateClientMock.searchProcessDefinitions(any())).thenReturn(new ArrayList<>()); + return new OperateClientLifecycle(camundaOperateClientMock); + } + +} diff --git a/src/test/java/io/camunda/connector/inbound/WebhookControllerPlainJavaTests.java b/src/test/java/io/camunda/connector/inbound/WebhookControllerPlainJavaTests.java new file mode 100644 index 0000000000..adf70f4d34 --- /dev/null +++ b/src/test/java/io/camunda/connector/inbound/WebhookControllerPlainJavaTests.java @@ -0,0 +1,127 @@ +package io.camunda.connector.inbound; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.camunda.connector.inbound.feel.FeelEngineWrapper; +import io.camunda.connector.inbound.registry.InboundConnectorProperties; +import io.camunda.connector.inbound.registry.InboundConnectorRegistry; +import io.camunda.connector.inbound.webhook.InboundWebhookRestController; +import io.camunda.connector.inbound.webhook.WebhookResponse; +import io.camunda.zeebe.client.ZeebeClient; +import io.camunda.zeebe.client.api.ZeebeFuture; +import io.camunda.zeebe.client.api.command.CreateProcessInstanceCommandStep1; +import io.camunda.zeebe.client.api.command.FinalCommandStep; +import io.camunda.zeebe.client.api.response.ProcessInstanceEvent; +import io.camunda.zeebe.client.impl.ZeebeClientFutureImpl; +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.http.ResponseEntity; + +import java.io.IOException; +import java.io.InputStream; +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class WebhookControllerPlainJavaTests { + + @Test + public void multipleWebhooksOnSameContextPath() throws IOException { + InboundConnectorRegistry registry = new InboundConnectorRegistry(); + ZeebeClient zeebeClient = mock(ZeebeClient.class); + when(zeebeClient.newCreateInstanceCommand()).thenReturn(new CreateCommandDummy()); + InboundWebhookRestController controller = new InboundWebhookRestController(registry, zeebeClient, new FeelEngineWrapper(), new ObjectMapper()); + + registry.reset(); + registry.registerWebhookConnector(webhookProperties("processA", "myPath")); + registry.registerWebhookConnector(webhookProperties("processB", "myPath"));; + + + ResponseEntity responseEntity = controller.inbound("myPath", "{}".getBytes(), new HashMap<>()); + + assertEquals(200, responseEntity.getStatusCode().value()); + assertTrue(responseEntity.getBody().getUnauthorizedConnectors().isEmpty()); + assertTrue(responseEntity.getBody().getUnactivatedConnectors().isEmpty()); + assertEquals(2, + responseEntity.getBody().getExecutedConnectors().size()); + assertEquals(Set.of("webhook-myPath-processA-1", "webhook-myPath-processB-1"), + responseEntity.getBody().getExecutedConnectors().keySet()); + + } + + + public static InboundConnectorProperties webhookProperties(String bpmnProcessId, String contextPath) { + return new InboundConnectorProperties(bpmnProcessId, 1, 123l, Map.of( + "inbound.type", "webhook", + "inbound.context", contextPath, + "inbound.secretExtractor", "=\"TEST\"", + "inbound.secret", "TEST", + "inbound.activationCondition", "=true", + "inbound.variableMapping", "={}" + )); + } + + public static class ProcessInstanceEventDummy implements ProcessInstanceEvent { + public long getProcessDefinitionKey () { + return 0; + } + public String getBpmnProcessId () { + return null; + } + public int getVersion () { + return 0; + } + public long getProcessInstanceKey () { + return 0; + } + } + + public static class CreateCommandDummy implements CreateProcessInstanceCommandStep1, CreateProcessInstanceCommandStep1.CreateProcessInstanceCommandStep2, CreateProcessInstanceCommandStep1.CreateProcessInstanceCommandStep3 { + public CreateProcessInstanceCommandStep2 bpmnProcessId(String bpmnProcessId) { + return this; + } + public CreateProcessInstanceCommandStep3 processDefinitionKey(long processDefinitionKey) { + return this; + } + public CreateProcessInstanceCommandStep3 version(int version) { + return this; + } + public CreateProcessInstanceCommandStep3 latestVersion() { + return this; + } + public CreateProcessInstanceCommandStep3 variables(InputStream variables) { + return this; + } + public CreateProcessInstanceCommandStep3 variables(String variables) { + return this; + } + public CreateProcessInstanceCommandStep3 variables(Map variables) { + return this; + } + public CreateProcessInstanceCommandStep3 variables(Object variables) { + return this; + } + public CreateProcessInstanceCommandStep3 startBeforeElement(String elementId) { + return this; + } + public CreateProcessInstanceWithResultCommandStep1 withResult() { + return null; + } + public FinalCommandStep requestTimeout(Duration requestTimeout) { + return null; + } + public ZeebeFuture send() { + ZeebeClientFutureImpl future = new ZeebeClientFutureImpl<>(); + future.complete( new ProcessInstanceEventDummy() ); + return future; + } + } +} diff --git a/src/test/java/io/camunda/connector/inbound/WebhookControllerTestZeebeTests.java b/src/test/java/io/camunda/connector/inbound/WebhookControllerTestZeebeTests.java new file mode 100644 index 0000000000..3150b5f7c0 --- /dev/null +++ b/src/test/java/io/camunda/connector/inbound/WebhookControllerTestZeebeTests.java @@ -0,0 +1,92 @@ +package io.camunda.connector.inbound; + +import io.camunda.connector.inbound.registry.InboundConnectorRegistry; +import io.camunda.connector.inbound.webhook.InboundWebhookRestController; +import io.camunda.connector.inbound.webhook.WebhookResponse; +import io.camunda.zeebe.client.ZeebeClient; +import io.camunda.zeebe.client.api.response.ProcessInstanceEvent; +import io.camunda.zeebe.model.bpmn.Bpmn; +import io.camunda.zeebe.spring.test.ZeebeSpringTest; +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.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.ResponseEntity; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Set; + +import static io.camunda.connector.inbound.WebhookControllerPlainJavaTests.webhookProperties; +import static io.camunda.zeebe.process.test.assertions.BpmnAssert.assertThat; +import static io.camunda.zeebe.spring.test.ZeebeTestThreadSupport.waitForProcessInstanceCompleted; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; + +@SpringBootTest(properties = {"spring.main.allow-bean-definition-overriding=true"}) +@ZeebeSpringTest +@ExtendWith(MockitoExtension.class) +class WebhookControllerTestZeebeTests { + + @Test + public void contextLoaded() { + } + + @Autowired + private InboundConnectorRegistry registry; + + @Autowired + private ZeebeClient zeebeClient; + + @Autowired + @InjectMocks + private InboundWebhookRestController controller; + + // This test is wired by Spring - but this is not really giving us any advantage + // Better move to plain Java as shown in InboundWebhookRestControllerTests + @Test + public void multipleWebhooksOnSameContextPath() throws IOException { + deployProcess("processA"); + deployProcess("processB"); + + registry.reset(); + registry.registerWebhookConnector(webhookProperties("processA", "myPath")); + registry.registerWebhookConnector(webhookProperties("processB", "myPath"));; + + ResponseEntity responseEntity = controller.inbound("myPath", "{}".getBytes(), new HashMap<>()); + + assertEquals(200, responseEntity.getStatusCode().value()); + assertTrue(responseEntity.getBody().getUnauthorizedConnectors().isEmpty()); + assertTrue(responseEntity.getBody().getUnactivatedConnectors().isEmpty()); + assertEquals(2, + responseEntity.getBody().getExecutedConnectors().size()); + assertEquals(Set.of("webhook-myPath-processA-1", "webhook-myPath-processB-1"), + responseEntity.getBody().getExecutedConnectors().keySet()); + + + ProcessInstanceEvent piA = responseEntity.getBody().getExecutedConnectors().get("webhook-myPath-processA-1"); + waitForProcessInstanceCompleted(piA); + assertThat(piA).isCompleted(); + + ProcessInstanceEvent piB = responseEntity.getBody().getExecutedConnectors().get("webhook-myPath-processB-1"); + waitForProcessInstanceCompleted(piB); + assertThat(piB).isCompleted(); + + } + + public void deployProcess(String bpmnProcessId) { + zeebeClient.newDeployResourceCommand().addProcessModel( + Bpmn.createExecutableProcess(bpmnProcessId) + .startEvent() + .endEvent() + .done(), + bpmnProcessId + ".bpmn") + .send().join(); + + } + +}