From bd786e50bfb0c1911c85ff92165c908035eccf8b Mon Sep 17 00:00:00 2001 From: Simon Date: Tue, 16 Apr 2024 16:07:39 +0200 Subject: [PATCH] feat(webhook): Support response expressions for Webhooks (#2309) * feat(webhook): Support response expressions for Webhooks Refactor verification and response body expression handling * Hide response body expression and bump version number * Remove response body expression from template --- .../webhook/InboundWebhookRestController.java | 53 ++++---- .../runtime/app/TestWebhookConnector.java | 7 -- .../WebhookControllerTestZeebeTests.java | 27 ++-- .../inbound/webhook/VerifiableWebhook.java | 30 ----- .../webhook/WebhookConnectorExecutable.java | 15 ++- .../inbound/webhook/WebhookHttpResponse.java | 7 +- .../api/inbound/webhook/WebhookResult.java | 11 +- .../SlackInboundWebhookExecutable.java | 10 +- .../model/SlackWebhookProcessingResult.java | 13 +- .../inbound/model/SlackWebhookProperties.java | 5 +- .../SlackInboundWebhookExecutableTest.java | 22 ++-- .../webhook-connector-boundary.json | 68 +++++----- .../webhook-connector-intermediate.json | 68 +++++----- .../webhook-connector-start-event.json | 68 +++++----- .../webhook-connector-start-message.json | 68 +++++----- .../inbound/HttpWebhookExecutable.java | 118 +++++++++++------- .../model/WebhookConnectorProperties.java | 15 +-- .../model/WebhookProcessingResultImpl.java | 60 ++------- .../inbound/HttpWebhookExecutableTest.java | 75 ++++++++--- 19 files changed, 368 insertions(+), 372 deletions(-) delete mode 100644 connector-sdk/core/src/main/java/io/camunda/connector/api/inbound/webhook/VerifiableWebhook.java diff --git a/connector-runtime/connector-runtime-spring/src/main/java/io/camunda/connector/runtime/inbound/webhook/InboundWebhookRestController.java b/connector-runtime/connector-runtime-spring/src/main/java/io/camunda/connector/runtime/inbound/webhook/InboundWebhookRestController.java index a648ee6322..1ab656ec76 100644 --- a/connector-runtime/connector-runtime-spring/src/main/java/io/camunda/connector/runtime/inbound/webhook/InboundWebhookRestController.java +++ b/connector-runtime/connector-runtime-spring/src/main/java/io/camunda/connector/runtime/inbound/webhook/InboundWebhookRestController.java @@ -27,10 +27,10 @@ import io.camunda.connector.api.inbound.CorrelationResult.Success.MessagePublished; import io.camunda.connector.api.inbound.CorrelationResult.Success.ProcessInstanceCreated; import io.camunda.connector.api.inbound.webhook.MappedHttpRequest; -import io.camunda.connector.api.inbound.webhook.VerifiableWebhook; import io.camunda.connector.api.inbound.webhook.WebhookConnectorException; import io.camunda.connector.api.inbound.webhook.WebhookConnectorException.WebhookSecurityException; import io.camunda.connector.api.inbound.webhook.WebhookConnectorExecutable; +import io.camunda.connector.api.inbound.webhook.WebhookHttpResponse; import io.camunda.connector.api.inbound.webhook.WebhookProcessingPayload; import io.camunda.connector.api.inbound.webhook.WebhookResult; import io.camunda.connector.api.inbound.webhook.WebhookResultContext; @@ -40,6 +40,7 @@ import io.camunda.connector.runtime.inbound.webhook.model.HttpServletRequestWebhookProcessingPayload; import jakarta.servlet.http.HttpServletRequest; import java.io.IOException; +import java.util.Collections; import java.util.Map; import java.util.Optional; import org.slf4j.Logger; @@ -117,23 +118,12 @@ private ResponseEntity processWebhook( } protected ResponseEntity verify( - WebhookConnectorExecutable connectorHook, WebhookProcessingPayload payload) throws Exception { + WebhookConnectorExecutable connectorHook, WebhookProcessingPayload payload) { + WebhookHttpResponse verificationResponse = connectorHook.verify(payload); ResponseEntity response = null; - - VerifiableWebhook.WebhookHttpVerificationResult verificationResult = null; - if (connectorHook instanceof VerifiableWebhook verifiableWebhook) { - verificationResult = verifiableWebhook.verify(payload); - } - - if (verificationResult != null) { - HttpHeaders headers = new HttpHeaders(); - Optional.of(verificationResult) - .map(VerifiableWebhook.WebhookHttpVerificationResult::headers) - .ifPresent(map -> map.forEach(headers::add)); - response = - new ResponseEntity<>(verificationResult.body(), headers, verificationResult.statusCode()); + if (verificationResponse != null) { + response = toResponseEntity(verificationResponse); } - return response; } @@ -176,27 +166,28 @@ private ResponseEntity buildErrorResponse(CorrelationResult.Failure failure) private ResponseEntity buildSuccessfulResponse( WebhookResult webhookResult, CorrelationResult.Success correlationResult) { ResponseEntity response; - var processVariablesContext = toWebhookResultContext(webhookResult, correlationResult); if (webhookResult.response() != null) { - // Step 3a: return response crafted by webhook itself - response = ResponseEntity.ok(webhookResult.response().body()); + var processVariablesContext = toWebhookResultContext(webhookResult, correlationResult); + var httpResponseData = webhookResult.response().apply(processVariablesContext); + if (httpResponseData != null) { + response = toResponseEntity(httpResponseData); + } else { + response = ResponseEntity.ok().build(); + } } else { - // Step 3b: response body expression was defined and evaluated, or 200 OK otherwise - response = buildBodyExpressionResponseOrOk(webhookResult, processVariablesContext); + response = ResponseEntity.ok().build(); } return response; } - protected ResponseEntity buildBodyExpressionResponseOrOk( - WebhookResult webhookResult, WebhookResultContext processVariablesContext) { - ResponseEntity response; - if (webhookResult.responseBodyExpression() != null) { - var httpResponseData = webhookResult.responseBodyExpression().apply(processVariablesContext); - response = ResponseEntity.ok(httpResponseData); - } else { - response = ResponseEntity.ok().build(); - } - return response; + protected static ResponseEntity toResponseEntity(WebhookHttpResponse webhookHttpResponse) { + int status = + Optional.ofNullable(webhookHttpResponse.statusCode()).orElse(HttpStatus.OK.value()); + HttpHeaders headers = new HttpHeaders(); + Optional.ofNullable(webhookHttpResponse.headers()) + .orElse(Collections.emptyMap()) + .forEach(headers::add); + return ResponseEntity.status(status).headers(headers).body(webhookHttpResponse.body()); } protected ResponseEntity buildErrorResponse(Exception e) { diff --git a/connector-runtime/spring-boot-starter-camunda-connectors/src/test/java/io/camunda/connector/runtime/app/TestWebhookConnector.java b/connector-runtime/spring-boot-starter-camunda-connectors/src/test/java/io/camunda/connector/runtime/app/TestWebhookConnector.java index 0531bc39b2..55d7a7e90c 100644 --- a/connector-runtime/spring-boot-starter-camunda-connectors/src/test/java/io/camunda/connector/runtime/app/TestWebhookConnector.java +++ b/connector-runtime/spring-boot-starter-camunda-connectors/src/test/java/io/camunda/connector/runtime/app/TestWebhookConnector.java @@ -17,7 +17,6 @@ package io.camunda.connector.runtime.app; import io.camunda.connector.api.annotation.InboundConnector; -import io.camunda.connector.api.inbound.InboundConnectorContext; import io.camunda.connector.api.inbound.webhook.MappedHttpRequest; import io.camunda.connector.api.inbound.webhook.WebhookConnectorExecutable; import io.camunda.connector.api.inbound.webhook.WebhookProcessingPayload; @@ -44,10 +43,4 @@ public Map connectorData() { } }; } - - @Override - public void activate(InboundConnectorContext context) throws Exception {} - - @Override - public void deactivate() throws Exception {} } diff --git a/connector-runtime/spring-boot-starter-camunda-connectors/src/test/java/io/camunda/connector/runtime/inbound/WebhookControllerTestZeebeTests.java b/connector-runtime/spring-boot-starter-camunda-connectors/src/test/java/io/camunda/connector/runtime/inbound/WebhookControllerTestZeebeTests.java index 8ded6381a2..6856f8ec4d 100644 --- a/connector-runtime/spring-boot-starter-camunda-connectors/src/test/java/io/camunda/connector/runtime/inbound/WebhookControllerTestZeebeTests.java +++ b/connector-runtime/spring-boot-starter-camunda-connectors/src/test/java/io/camunda/connector/runtime/inbound/WebhookControllerTestZeebeTests.java @@ -30,12 +30,10 @@ import io.camunda.connector.api.inbound.CorrelationResult; import io.camunda.connector.api.inbound.ProcessElement; import io.camunda.connector.api.inbound.webhook.MappedHttpRequest; -import io.camunda.connector.api.inbound.webhook.VerifiableWebhook; import io.camunda.connector.api.inbound.webhook.WebhookConnectorExecutable; import io.camunda.connector.api.inbound.webhook.WebhookHttpResponse; import io.camunda.connector.api.inbound.webhook.WebhookProcessingPayload; import io.camunda.connector.api.inbound.webhook.WebhookResult; -import io.camunda.connector.api.inbound.webhook.WebhookResultContext; import io.camunda.connector.feel.FeelEngineWrapperException; import io.camunda.connector.runtime.app.TestConnectorRuntimeApplication; import io.camunda.connector.runtime.core.inbound.DefaultProcessElementContextFactory; @@ -136,7 +134,8 @@ public void testSuccessfulProcessingWithActivationAndStrictResponse() throws Exc WebhookResult webhookResult = mock(WebhookResult.class); when(webhookResult.request()).thenReturn(new MappedHttpRequest(Map.of(), Map.of(), Map.of())); when(webhookResult.response()) - .thenReturn(new WebhookHttpResponse(Map.of("keyResponse", "valueResponse"), null)); + .thenReturn( + (c) -> new WebhookHttpResponse(Map.of("keyResponse", "valueResponse"), null, 201)); when(webhookConnectorExecutable.triggerWebhook(any(WebhookProcessingPayload.class))) .thenReturn(webhookResult); @@ -166,7 +165,7 @@ public void testSuccessfulProcessingWithActivationAndStrictResponse() throws Exc new HashMap<>(), new MockHttpServletRequest()); - assertEquals(200, responseEntity.getStatusCode().value()); + assertEquals(201, responseEntity.getStatusCode().value()); assertEquals("valueResponse", responseEntity.getBody().get("keyResponse")); var result = responseEntity.getBody(); @@ -178,7 +177,8 @@ public void testSuccessfulProcessingWithFailedActivation() throws Exception { WebhookConnectorExecutable webhookConnectorExecutable = mock(WebhookConnectorExecutable.class); WebhookResult webhookResult = mock(WebhookResult.class); when(webhookResult.request()).thenReturn(new MappedHttpRequest(Map.of(), Map.of(), Map.of())); - when(webhookResult.responseBodyExpression()).thenReturn((WebhookResultContext) -> Map.of()); + when(webhookResult.response()) + .thenReturn((WebhookResultContext) -> new WebhookHttpResponse(Map.of(), null, null)); when(webhookConnectorExecutable.triggerWebhook(any(WebhookProcessingPayload.class))) .thenReturn(webhookResult); @@ -218,7 +218,8 @@ public void testSuccessfulProcessingWithDuplicateMessage() throws Exception { WebhookConnectorExecutable webhookConnectorExecutable = mock(WebhookConnectorExecutable.class); WebhookResult webhookResult = mock(WebhookResult.class); when(webhookResult.request()).thenReturn(new MappedHttpRequest(Map.of(), Map.of(), Map.of())); - when(webhookResult.responseBodyExpression()).thenReturn((WebhookResultContext) -> Map.of()); + when(webhookResult.response()) + .thenReturn((WebhookResultContext) -> WebhookHttpResponse.ok(Map.of())); when(webhookConnectorExecutable.triggerWebhook(any(WebhookProcessingPayload.class))) .thenReturn(webhookResult); @@ -268,7 +269,7 @@ public void testSuccessfulProcessingWithActivationCorrelationHidden() throws Exc WebhookResult webhookResult = mock(WebhookResult.class); when(webhookResult.request()).thenReturn(new MappedHttpRequest(Map.of(), Map.of(), Map.of())); // default use-case, when result expression not set - when(webhookResult.responseBodyExpression()).thenReturn(webhookResultContext -> null); + when(webhookResult.response()).thenReturn(webhookResultContext -> null); when(webhookConnectorExecutable.triggerWebhook(any(WebhookProcessingPayload.class))) .thenReturn(webhookResult); @@ -409,7 +410,7 @@ public void testFeelExpressionErrorDuringProcessing() throws Exception { assertEquals("expression", responseEntity.getBody().expression()); } - interface MyVerifiableWebhook extends WebhookConnectorExecutable, VerifiableWebhook {} + interface MyVerifiableWebhook extends WebhookConnectorExecutable {} @Test @SuppressWarnings("unchecked") @@ -418,14 +419,12 @@ public void testSuccessfulProcessingWithVerification() throws Exception { WebhookResult webhookResult = mock(WebhookResult.class); when(webhookResult.request()).thenReturn(new MappedHttpRequest(Map.of(), Map.of(), Map.of())); when(webhookResult.response()) - .thenReturn(new WebhookHttpResponse(Map.of("keyResponse", "valueResponse"), null)); + .thenReturn((c) -> WebhookHttpResponse.ok(Map.of("keyResponse", "valueResponse"))); when(webhookConnectorExecutable.triggerWebhook(any(WebhookProcessingPayload.class))) .thenReturn(webhookResult); when(webhookConnectorExecutable.verify(any(WebhookProcessingPayload.class))) - .thenReturn( - new VerifiableWebhook.WebhookHttpVerificationResult( - Map.of("result", "GOOD"), Map.of(), 201)); + .thenReturn(new WebhookHttpResponse(Map.of("result", "GOOD"), Map.of(), null)); var webhookDef = webhookDefinition("processA", 1, "myPath"); var webhookContext = @@ -453,7 +452,7 @@ public void testSuccessfulProcessingWithVerification() throws Exception { new HashMap<>(), new MockHttpServletRequest()); - assertEquals(201, responseEntity.getStatusCode().value()); + assertEquals(200, responseEntity.getStatusCode().value()); assertNull(responseEntity.getBody().get("keyResponse")); assertEquals("GOOD", responseEntity.getBody().get("result")); @@ -481,7 +480,7 @@ public void testSuccessfulProcessingWithResponseBodyExpression() throws Exceptio WebhookResult webhookResult = mock(WebhookResult.class); when(webhookResult.request()).thenReturn(new MappedHttpRequest(Map.of(), Map.of(), Map.of())); - when(webhookResult.responseBodyExpression()).thenReturn((WebhookResultContext::correlation)); + when(webhookResult.response()).thenReturn((c) -> WebhookHttpResponse.ok(c.correlation())); when(webhookConnectorExecutable.triggerWebhook(any(WebhookProcessingPayload.class))) .thenReturn(webhookResult); diff --git a/connector-sdk/core/src/main/java/io/camunda/connector/api/inbound/webhook/VerifiableWebhook.java b/connector-sdk/core/src/main/java/io/camunda/connector/api/inbound/webhook/VerifiableWebhook.java deleted file mode 100644 index 4890179e2f..0000000000 --- a/connector-sdk/core/src/main/java/io/camunda/connector/api/inbound/webhook/VerifiableWebhook.java +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH - * under one or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information regarding copyright - * ownership. Camunda licenses this file to you under the Apache License, - * Version 2.0; 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 io.camunda.connector.api.inbound.webhook; - -import java.util.Map; - -public interface VerifiableWebhook { - WebhookHttpVerificationResult verify(WebhookProcessingPayload payload); - - record WebhookHttpVerificationResult( - Object body, Map headers, Integer statusCode) { - public WebhookHttpVerificationResult { - statusCode = statusCode == null || statusCode < 100 ? 200 : statusCode; - } - } -} diff --git a/connector-sdk/core/src/main/java/io/camunda/connector/api/inbound/webhook/WebhookConnectorExecutable.java b/connector-sdk/core/src/main/java/io/camunda/connector/api/inbound/webhook/WebhookConnectorExecutable.java index b99db8d3e5..81208f4dba 100644 --- a/connector-sdk/core/src/main/java/io/camunda/connector/api/inbound/webhook/WebhookConnectorExecutable.java +++ b/connector-sdk/core/src/main/java/io/camunda/connector/api/inbound/webhook/WebhookConnectorExecutable.java @@ -24,7 +24,20 @@ * supports HTTP webhooks uses this interface to control the execution of inbound webhook * Connectors. */ -public interface WebhookConnectorExecutable extends InboundConnectorExecutable { +public interface WebhookConnectorExecutable + extends InboundConnectorExecutable { + + /** + * Entry point for custom verification logic. The purpose of the method is to perform a + * verification for Webhook implementation that use an initial request to verify the existence and + * correctness of the webhook. Can also be used for one time validation purposes. + * + * @param payload + * @return The response returned by the webhook call. + */ + default WebhookHttpResponse verify(WebhookProcessingPayload payload) { + return null; + } /** * Entry-point method whenever webhook was triggered. The purpose of the method is to perform diff --git a/connector-sdk/core/src/main/java/io/camunda/connector/api/inbound/webhook/WebhookHttpResponse.java b/connector-sdk/core/src/main/java/io/camunda/connector/api/inbound/webhook/WebhookHttpResponse.java index 6b1b848916..6ec4730150 100644 --- a/connector-sdk/core/src/main/java/io/camunda/connector/api/inbound/webhook/WebhookHttpResponse.java +++ b/connector-sdk/core/src/main/java/io/camunda/connector/api/inbound/webhook/WebhookHttpResponse.java @@ -18,4 +18,9 @@ import java.util.Map; -public record WebhookHttpResponse(Object body, Map headers) {} +public record WebhookHttpResponse(Object body, Map headers, Integer statusCode) { + + public static WebhookHttpResponse ok(Object body) { + return new WebhookHttpResponse(body, null, 200); + } +} diff --git a/connector-sdk/core/src/main/java/io/camunda/connector/api/inbound/webhook/WebhookResult.java b/connector-sdk/core/src/main/java/io/camunda/connector/api/inbound/webhook/WebhookResult.java index 0dbea77d0a..18317b8cbd 100644 --- a/connector-sdk/core/src/main/java/io/camunda/connector/api/inbound/webhook/WebhookResult.java +++ b/connector-sdk/core/src/main/java/io/camunda/connector/api/inbound/webhook/WebhookResult.java @@ -33,10 +33,11 @@ public interface WebhookResult { MappedHttpRequest request(); /** - * @return strict response from the connector. May be useful to handle challenges, or special - * response cases. + * Returns a function that can be used to generate a response to the webhook request. + * + * @return */ - default WebhookHttpResponse response() { + default Function response() { return null; } @@ -49,8 +50,4 @@ default WebhookHttpResponse response() { default Map connectorData() { return Collections.emptyMap(); } - - default Function responseBodyExpression() { - return response -> null; - } } diff --git a/connectors/slack/src/main/java/io/camunda/connector/slack/inbound/SlackInboundWebhookExecutable.java b/connectors/slack/src/main/java/io/camunda/connector/slack/inbound/SlackInboundWebhookExecutable.java index 3071c59622..99d275b339 100644 --- a/connectors/slack/src/main/java/io/camunda/connector/slack/inbound/SlackInboundWebhookExecutable.java +++ b/connectors/slack/src/main/java/io/camunda/connector/slack/inbound/SlackInboundWebhookExecutable.java @@ -12,7 +12,6 @@ import io.camunda.connector.api.annotation.InboundConnector; import io.camunda.connector.api.inbound.InboundConnectorContext; import io.camunda.connector.api.inbound.webhook.MappedHttpRequest; -import io.camunda.connector.api.inbound.webhook.VerifiableWebhook; import io.camunda.connector.api.inbound.webhook.WebhookConnectorExecutable; import io.camunda.connector.api.inbound.webhook.WebhookHttpResponse; import io.camunda.connector.api.inbound.webhook.WebhookProcessingPayload; @@ -69,8 +68,7 @@ templateIdOverride = "io.camunda.connectors.inbound.Slack.BoundaryEvent.v1", templateNameOverride = "Slack Webhook Boundary Event Connector") }) -public class SlackInboundWebhookExecutable - implements WebhookConnectorExecutable, VerifiableWebhook { +public class SlackInboundWebhookExecutable implements WebhookConnectorExecutable { protected static final String HEADER_SLACK_REQUEST_TIMESTAMP = "x-slack-request-timestamp"; protected static final String HEADER_SLACK_SIGNATURE = "x-slack-signature"; @@ -106,14 +104,14 @@ public WebhookResult triggerWebhook(WebhookProcessingPayload webhookProcessingPa new MappedHttpRequest( bodyAsMap, Map.of(HttpHeaders.CONTENT_TYPE, MediaType.JSON_UTF_8.toString()), null), bodyAsMap, - new WebhookHttpResponse(defaultCommandResponse(), null)); + new WebhookHttpResponse(defaultCommandResponse(), null, 200)); } // Other requests, e.g. events return new SlackWebhookProcessingResult( new MappedHttpRequest(bodyAsMap, webhookProcessingPayload.headers(), null), null, - new WebhookHttpResponse(bodyAsMap, null)); + new WebhookHttpResponse(bodyAsMap, null, 200)); } @Override @@ -123,7 +121,7 @@ public void activate(InboundConnectorContext context) { } @Override - public WebhookHttpVerificationResult verify(WebhookProcessingPayload payload) { + public WebhookHttpResponse verify(WebhookProcessingPayload payload) { verifySlackRequestAuthentic(payload); return Optional.ofNullable(props.verificationExpression()) .orElse(stringObjectMap -> null) diff --git a/connectors/slack/src/main/java/io/camunda/connector/slack/inbound/model/SlackWebhookProcessingResult.java b/connectors/slack/src/main/java/io/camunda/connector/slack/inbound/model/SlackWebhookProcessingResult.java index fdce19008c..c3b8802023 100644 --- a/connectors/slack/src/main/java/io/camunda/connector/slack/inbound/model/SlackWebhookProcessingResult.java +++ b/connectors/slack/src/main/java/io/camunda/connector/slack/inbound/model/SlackWebhookProcessingResult.java @@ -9,14 +9,15 @@ import io.camunda.connector.api.inbound.webhook.MappedHttpRequest; import io.camunda.connector.api.inbound.webhook.WebhookHttpResponse; import io.camunda.connector.api.inbound.webhook.WebhookResult; +import io.camunda.connector.api.inbound.webhook.WebhookResultContext; import java.util.Map; +import java.util.function.Function; public class SlackWebhookProcessingResult implements WebhookResult { - private MappedHttpRequest request; + private final MappedHttpRequest request; private final Map connectorData; - - private WebhookHttpResponse response; + private final WebhookHttpResponse response; public SlackWebhookProcessingResult( MappedHttpRequest request, Map connectorData, WebhookHttpResponse response) { @@ -36,7 +37,11 @@ public Map connectorData() { } @Override - public WebhookHttpResponse response() { + public Function response() { + return (c) -> response; + } + + public WebhookHttpResponse getResponse() { return response; } } diff --git a/connectors/slack/src/main/java/io/camunda/connector/slack/inbound/model/SlackWebhookProperties.java b/connectors/slack/src/main/java/io/camunda/connector/slack/inbound/model/SlackWebhookProperties.java index 68705cb49d..9384625ac5 100644 --- a/connectors/slack/src/main/java/io/camunda/connector/slack/inbound/model/SlackWebhookProperties.java +++ b/connectors/slack/src/main/java/io/camunda/connector/slack/inbound/model/SlackWebhookProperties.java @@ -7,7 +7,7 @@ package io.camunda.connector.slack.inbound.model; import com.slack.api.app_backend.SlackSignature; -import io.camunda.connector.api.inbound.webhook.VerifiableWebhook; +import io.camunda.connector.api.inbound.webhook.WebhookHttpResponse; import io.camunda.connector.generator.dsl.Property; import io.camunda.connector.generator.java.annotation.TemplateProperty; import io.camunda.connector.generator.java.annotation.TemplateProperty.PropertyType; @@ -41,8 +41,7 @@ public record SlackWebhookProperties( optional = true, defaultValue = "=if (body.type != null and body.type = \"url_verification\") then {body:{\"challenge\":body.challenge}, statusCode: 200} else null") - Function, VerifiableWebhook.WebhookHttpVerificationResult> - verificationExpression) { + Function, WebhookHttpResponse> verificationExpression) { public SlackWebhookProperties(SlackConnectorPropertiesWrapper wrapper) { this( wrapper.inbound.context, diff --git a/connectors/slack/src/test/java/io/camunda/connector/slack/inbound/SlackInboundWebhookExecutableTest.java b/connectors/slack/src/test/java/io/camunda/connector/slack/inbound/SlackInboundWebhookExecutableTest.java index 50e8d07f0e..5734da847a 100644 --- a/connectors/slack/src/test/java/io/camunda/connector/slack/inbound/SlackInboundWebhookExecutableTest.java +++ b/connectors/slack/src/test/java/io/camunda/connector/slack/inbound/SlackInboundWebhookExecutableTest.java @@ -26,8 +26,9 @@ import com.google.common.net.MediaType; import com.slack.api.app_backend.SlackSignature; import io.camunda.connector.api.inbound.InboundConnectorContext; -import io.camunda.connector.api.inbound.webhook.VerifiableWebhook.WebhookHttpVerificationResult; +import io.camunda.connector.api.inbound.webhook.WebhookHttpResponse; import io.camunda.connector.api.inbound.webhook.WebhookProcessingPayload; +import io.camunda.connector.slack.inbound.model.SlackWebhookProcessingResult; import io.camunda.connector.slack.inbound.model.SlackWebhookProperties; import java.util.Map; import java.util.function.Function; @@ -49,10 +50,10 @@ class SlackInboundWebhookExecutableTest { private static final String ARBITRARY_SLACK_REQUEST = "{\"token\":\"qQqQqQqQqQqQqQqQqQ\",\"type\":\"myType\",\"event\":{\"user\":{\"id\":\"aAaAaAaAaAaAaA\"}}}"; - private static final Function, WebhookHttpVerificationResult> + private static final Function, WebhookHttpResponse> CHALLENGE_RESPONSE_VERIFICATION_FUNCTION = (ctx) -> - new WebhookHttpVerificationResult( + new WebhookHttpResponse( Map.of("challenge", ((Map) ctx.get("body")).get("challenge")), Map.of(), 200); private static final String SLASH_COMMAND = @@ -152,7 +153,6 @@ void triggerWebhook_UrlVerificationEvent_ReturnsChallengeBack() throws Exception final var result = testObject.verify(payload); assertNotNull(result); - assertThat(result).isInstanceOf(WebhookHttpVerificationResult.class); assertThat(result.body()).isInstanceOf(Map.class); assertThat((Map) result.body()).containsEntry(FIELD_CHALLENGE, "aAaAaAaAaAaAaAaAaAaA"); } @@ -174,13 +174,14 @@ void triggerWebhook_SlashCommand_HappyCase() throws Exception { when(payload.rawBody()).thenReturn(SLASH_COMMAND.getBytes(UTF_8)); testObject.activate(ctx); - final var result = testObject.triggerWebhook(payload); + final var result = (SlackWebhookProcessingResult) testObject.triggerWebhook(payload); assertNotNull(result); assertThat(result.request().body()).isInstanceOf(Map.class); - assertThat((Map) result.response().body()) + + assertThat((Map) result.getResponse().body()) .containsEntry(COMMAND_RESPONSE_TYPE_KEY, COMMAND_RESPONSE_TYPE_DEFAULT_VALUE); - assertThat((Map) result.response().body()) + assertThat((Map) result.getResponse().body()) .containsEntry(COMMAND_RESPONSE_TEXT_KEY, COMMAND_RESPONSE_TEXT_DEFAULT_VALUE); assertThat((Map) result.connectorData()).containsEntry(FORM_VALUE_COMMAND, "/test123"); assertThat((Map) result.connectorData()).containsEntry("text", "hello world"); @@ -203,13 +204,14 @@ void triggerWebhook_SlashCommandMalformedContentType_HappyCase() throws Exceptio when(payload.rawBody()).thenReturn(SLASH_COMMAND.getBytes(UTF_8)); testObject.activate(ctx); - final var result = testObject.triggerWebhook(payload); + final var result = (SlackWebhookProcessingResult) testObject.triggerWebhook(payload); assertNotNull(result); assertThat(result.request().body()).isInstanceOf(Map.class); - assertThat((Map) result.response().body()) + + assertThat((Map) result.getResponse().body()) .containsEntry(COMMAND_RESPONSE_TYPE_KEY, COMMAND_RESPONSE_TYPE_DEFAULT_VALUE); - assertThat((Map) result.response().body()) + assertThat((Map) result.getResponse().body()) .containsEntry(COMMAND_RESPONSE_TEXT_KEY, COMMAND_RESPONSE_TEXT_DEFAULT_VALUE); assertThat((Map) result.connectorData()).containsEntry(FORM_VALUE_COMMAND, "/test123"); assertThat((Map) result.connectorData()).containsEntry("text", "hello world"); diff --git a/connectors/webhook/element-templates/webhook-connector-boundary.json b/connectors/webhook/element-templates/webhook-connector-boundary.json index aed27a54f9..f36ce49395 100644 --- a/connectors/webhook/element-templates/webhook-connector-boundary.json +++ b/connectors/webhook/element-templates/webhook-connector-boundary.json @@ -4,7 +4,7 @@ "id" : "io.camunda.connectors.webhook.WebhookConnectorBoundary.v1", "description" : "Configure webhook to receive callbacks", "documentationRef" : "https://docs.camunda.io/docs/components/connectors/out-of-the-box-connectors/http-webhook/", - "version" : 10, + "version" : 11, "category" : { "id" : "connectors", "name" : "Connectors" @@ -198,87 +198,87 @@ }, "type" : "Dropdown", "choices" : [ { + "name" : "API key", + "value" : "APIKEY" + }, { "name" : "None", "value" : "NONE" }, { "name" : "Basic", "value" : "BASIC" - }, { - "name" : "API key", - "value" : "APIKEY" }, { "name" : "JWT", "value" : "JWT" } ] }, { - "id" : "inbound.auth.username", - "label" : "Username", - "description" : "Username for basic authentication", + "id" : "inbound.auth.apiKey", + "label" : "API key", + "description" : "Expected API key", "optional" : false, "feel" : "optional", "group" : "authorization", "binding" : { - "name" : "inbound.auth.username", + "name" : "inbound.auth.apiKey", "type" : "zeebe:property" }, "condition" : { "property" : "inbound.auth.type", - "equals" : "BASIC", + "equals" : "APIKEY", "type" : "simple" }, "type" : "String" }, { - "id" : "inbound.auth.password", - "label" : "Password", - "description" : "Password for basic authentication", + "id" : "inbound.auth.apiKeyLocator", + "label" : "API key locator", + "description" : "A FEEL expression that extracts API key from the request. See documentation", "optional" : false, - "feel" : "optional", + "value" : "=split(request.headers.authorization, \" \")[2]", + "constraints" : { + "notEmpty" : true + }, + "feel" : "required", "group" : "authorization", "binding" : { - "name" : "inbound.auth.password", + "name" : "inbound.auth.apiKeyLocator", "type" : "zeebe:property" }, "condition" : { "property" : "inbound.auth.type", - "equals" : "BASIC", + "equals" : "APIKEY", "type" : "simple" }, "type" : "String" }, { - "id" : "inbound.auth.apiKey", - "label" : "API key", - "description" : "Expected API key", + "id" : "inbound.auth.username", + "label" : "Username", + "description" : "Username for basic authentication", "optional" : false, "feel" : "optional", "group" : "authorization", "binding" : { - "name" : "inbound.auth.apiKey", + "name" : "inbound.auth.username", "type" : "zeebe:property" }, "condition" : { "property" : "inbound.auth.type", - "equals" : "APIKEY", + "equals" : "BASIC", "type" : "simple" }, "type" : "String" }, { - "id" : "inbound.auth.apiKeyLocator", - "label" : "API key locator", - "description" : "A FEEL expression that extracts API key from the request. See documentation", + "id" : "inbound.auth.password", + "label" : "Password", + "description" : "Password for basic authentication", "optional" : false, - "value" : "=split(request.headers.authorization, \" \")[2]", - "constraints" : { - "notEmpty" : true - }, - "feel" : "required", + "feel" : "optional", "group" : "authorization", "binding" : { - "name" : "inbound.auth.apiKeyLocator", + "name" : "inbound.auth.password", "type" : "zeebe:property" }, "condition" : { "property" : "inbound.auth.type", - "equals" : "APIKEY", + "equals" : "BASIC", "type" : "simple" }, "type" : "String" @@ -334,14 +334,14 @@ }, "type" : "String" }, { - "id" : "inbound.responseBodyExpression", - "label" : "Response body expression", - "description" : "Specify condition and response", + "id" : "inbound.responseExpression", + "label" : "Response expression", + "description" : "Expression used to generate the HTTP response", "optional" : true, "feel" : "required", "group" : "webhookResponse", "binding" : { - "name" : "inbound.responseBodyExpression", + "name" : "inbound.responseExpression", "type" : "zeebe:property" }, "type" : "Text" diff --git a/connectors/webhook/element-templates/webhook-connector-intermediate.json b/connectors/webhook/element-templates/webhook-connector-intermediate.json index fa3074ca9a..83ba5bbfaf 100644 --- a/connectors/webhook/element-templates/webhook-connector-intermediate.json +++ b/connectors/webhook/element-templates/webhook-connector-intermediate.json @@ -4,7 +4,7 @@ "id" : "io.camunda.connectors.webhook.WebhookConnectorIntermediate.v1", "description" : "Configure webhook to receive callbacks", "documentationRef" : "https://docs.camunda.io/docs/components/connectors/out-of-the-box-connectors/http-webhook/", - "version" : 10, + "version" : 11, "category" : { "id" : "connectors", "name" : "Connectors" @@ -198,87 +198,87 @@ }, "type" : "Dropdown", "choices" : [ { + "name" : "API key", + "value" : "APIKEY" + }, { "name" : "None", "value" : "NONE" }, { "name" : "Basic", "value" : "BASIC" - }, { - "name" : "API key", - "value" : "APIKEY" }, { "name" : "JWT", "value" : "JWT" } ] }, { - "id" : "inbound.auth.username", - "label" : "Username", - "description" : "Username for basic authentication", + "id" : "inbound.auth.apiKey", + "label" : "API key", + "description" : "Expected API key", "optional" : false, "feel" : "optional", "group" : "authorization", "binding" : { - "name" : "inbound.auth.username", + "name" : "inbound.auth.apiKey", "type" : "zeebe:property" }, "condition" : { "property" : "inbound.auth.type", - "equals" : "BASIC", + "equals" : "APIKEY", "type" : "simple" }, "type" : "String" }, { - "id" : "inbound.auth.password", - "label" : "Password", - "description" : "Password for basic authentication", + "id" : "inbound.auth.apiKeyLocator", + "label" : "API key locator", + "description" : "A FEEL expression that extracts API key from the request. See documentation", "optional" : false, - "feel" : "optional", + "value" : "=split(request.headers.authorization, \" \")[2]", + "constraints" : { + "notEmpty" : true + }, + "feel" : "required", "group" : "authorization", "binding" : { - "name" : "inbound.auth.password", + "name" : "inbound.auth.apiKeyLocator", "type" : "zeebe:property" }, "condition" : { "property" : "inbound.auth.type", - "equals" : "BASIC", + "equals" : "APIKEY", "type" : "simple" }, "type" : "String" }, { - "id" : "inbound.auth.apiKey", - "label" : "API key", - "description" : "Expected API key", + "id" : "inbound.auth.username", + "label" : "Username", + "description" : "Username for basic authentication", "optional" : false, "feel" : "optional", "group" : "authorization", "binding" : { - "name" : "inbound.auth.apiKey", + "name" : "inbound.auth.username", "type" : "zeebe:property" }, "condition" : { "property" : "inbound.auth.type", - "equals" : "APIKEY", + "equals" : "BASIC", "type" : "simple" }, "type" : "String" }, { - "id" : "inbound.auth.apiKeyLocator", - "label" : "API key locator", - "description" : "A FEEL expression that extracts API key from the request. See documentation", + "id" : "inbound.auth.password", + "label" : "Password", + "description" : "Password for basic authentication", "optional" : false, - "value" : "=split(request.headers.authorization, \" \")[2]", - "constraints" : { - "notEmpty" : true - }, - "feel" : "required", + "feel" : "optional", "group" : "authorization", "binding" : { - "name" : "inbound.auth.apiKeyLocator", + "name" : "inbound.auth.password", "type" : "zeebe:property" }, "condition" : { "property" : "inbound.auth.type", - "equals" : "APIKEY", + "equals" : "BASIC", "type" : "simple" }, "type" : "String" @@ -334,14 +334,14 @@ }, "type" : "String" }, { - "id" : "inbound.responseBodyExpression", - "label" : "Response body expression", - "description" : "Specify condition and response", + "id" : "inbound.responseExpression", + "label" : "Response expression", + "description" : "Expression used to generate the HTTP response", "optional" : true, "feel" : "required", "group" : "webhookResponse", "binding" : { - "name" : "inbound.responseBodyExpression", + "name" : "inbound.responseExpression", "type" : "zeebe:property" }, "type" : "Text" diff --git a/connectors/webhook/element-templates/webhook-connector-start-event.json b/connectors/webhook/element-templates/webhook-connector-start-event.json index 40c372be58..1bebd23db5 100644 --- a/connectors/webhook/element-templates/webhook-connector-start-event.json +++ b/connectors/webhook/element-templates/webhook-connector-start-event.json @@ -4,7 +4,7 @@ "id" : "io.camunda.connectors.webhook.WebhookConnector.v1", "description" : "Configure webhook to receive callbacks", "documentationRef" : "https://docs.camunda.io/docs/components/connectors/out-of-the-box-connectors/http-webhook/", - "version" : 10, + "version" : 11, "category" : { "id" : "connectors", "name" : "Connectors" @@ -194,87 +194,87 @@ }, "type" : "Dropdown", "choices" : [ { + "name" : "API key", + "value" : "APIKEY" + }, { "name" : "None", "value" : "NONE" }, { "name" : "Basic", "value" : "BASIC" - }, { - "name" : "API key", - "value" : "APIKEY" }, { "name" : "JWT", "value" : "JWT" } ] }, { - "id" : "inbound.auth.username", - "label" : "Username", - "description" : "Username for basic authentication", + "id" : "inbound.auth.apiKey", + "label" : "API key", + "description" : "Expected API key", "optional" : false, "feel" : "optional", "group" : "authorization", "binding" : { - "name" : "inbound.auth.username", + "name" : "inbound.auth.apiKey", "type" : "zeebe:property" }, "condition" : { "property" : "inbound.auth.type", - "equals" : "BASIC", + "equals" : "APIKEY", "type" : "simple" }, "type" : "String" }, { - "id" : "inbound.auth.password", - "label" : "Password", - "description" : "Password for basic authentication", + "id" : "inbound.auth.apiKeyLocator", + "label" : "API key locator", + "description" : "A FEEL expression that extracts API key from the request. See documentation", "optional" : false, - "feel" : "optional", + "value" : "=split(request.headers.authorization, \" \")[2]", + "constraints" : { + "notEmpty" : true + }, + "feel" : "required", "group" : "authorization", "binding" : { - "name" : "inbound.auth.password", + "name" : "inbound.auth.apiKeyLocator", "type" : "zeebe:property" }, "condition" : { "property" : "inbound.auth.type", - "equals" : "BASIC", + "equals" : "APIKEY", "type" : "simple" }, "type" : "String" }, { - "id" : "inbound.auth.apiKey", - "label" : "API key", - "description" : "Expected API key", + "id" : "inbound.auth.username", + "label" : "Username", + "description" : "Username for basic authentication", "optional" : false, "feel" : "optional", "group" : "authorization", "binding" : { - "name" : "inbound.auth.apiKey", + "name" : "inbound.auth.username", "type" : "zeebe:property" }, "condition" : { "property" : "inbound.auth.type", - "equals" : "APIKEY", + "equals" : "BASIC", "type" : "simple" }, "type" : "String" }, { - "id" : "inbound.auth.apiKeyLocator", - "label" : "API key locator", - "description" : "A FEEL expression that extracts API key from the request. See documentation", + "id" : "inbound.auth.password", + "label" : "Password", + "description" : "Password for basic authentication", "optional" : false, - "value" : "=split(request.headers.authorization, \" \")[2]", - "constraints" : { - "notEmpty" : true - }, - "feel" : "required", + "feel" : "optional", "group" : "authorization", "binding" : { - "name" : "inbound.auth.apiKeyLocator", + "name" : "inbound.auth.password", "type" : "zeebe:property" }, "condition" : { "property" : "inbound.auth.type", - "equals" : "APIKEY", + "equals" : "BASIC", "type" : "simple" }, "type" : "String" @@ -330,14 +330,14 @@ }, "type" : "String" }, { - "id" : "inbound.responseBodyExpression", - "label" : "Response body expression", - "description" : "Specify condition and response", + "id" : "inbound.responseExpression", + "label" : "Response expression", + "description" : "Expression used to generate the HTTP response", "optional" : true, "feel" : "required", "group" : "webhookResponse", "binding" : { - "name" : "inbound.responseBodyExpression", + "name" : "inbound.responseExpression", "type" : "zeebe:property" }, "type" : "Text" diff --git a/connectors/webhook/element-templates/webhook-connector-start-message.json b/connectors/webhook/element-templates/webhook-connector-start-message.json index 8b62f11144..a80fd38f26 100644 --- a/connectors/webhook/element-templates/webhook-connector-start-message.json +++ b/connectors/webhook/element-templates/webhook-connector-start-message.json @@ -4,7 +4,7 @@ "id" : "io.camunda.connectors.webhook.WebhookConnectorStartMessage.v1", "description" : "Configure webhook to receive callbacks", "documentationRef" : "https://docs.camunda.io/docs/components/connectors/out-of-the-box-connectors/http-webhook/", - "version" : 10, + "version" : 11, "category" : { "id" : "connectors", "name" : "Connectors" @@ -198,87 +198,87 @@ }, "type" : "Dropdown", "choices" : [ { + "name" : "API key", + "value" : "APIKEY" + }, { "name" : "None", "value" : "NONE" }, { "name" : "Basic", "value" : "BASIC" - }, { - "name" : "API key", - "value" : "APIKEY" }, { "name" : "JWT", "value" : "JWT" } ] }, { - "id" : "inbound.auth.username", - "label" : "Username", - "description" : "Username for basic authentication", + "id" : "inbound.auth.apiKey", + "label" : "API key", + "description" : "Expected API key", "optional" : false, "feel" : "optional", "group" : "authorization", "binding" : { - "name" : "inbound.auth.username", + "name" : "inbound.auth.apiKey", "type" : "zeebe:property" }, "condition" : { "property" : "inbound.auth.type", - "equals" : "BASIC", + "equals" : "APIKEY", "type" : "simple" }, "type" : "String" }, { - "id" : "inbound.auth.password", - "label" : "Password", - "description" : "Password for basic authentication", + "id" : "inbound.auth.apiKeyLocator", + "label" : "API key locator", + "description" : "A FEEL expression that extracts API key from the request. See documentation", "optional" : false, - "feel" : "optional", + "value" : "=split(request.headers.authorization, \" \")[2]", + "constraints" : { + "notEmpty" : true + }, + "feel" : "required", "group" : "authorization", "binding" : { - "name" : "inbound.auth.password", + "name" : "inbound.auth.apiKeyLocator", "type" : "zeebe:property" }, "condition" : { "property" : "inbound.auth.type", - "equals" : "BASIC", + "equals" : "APIKEY", "type" : "simple" }, "type" : "String" }, { - "id" : "inbound.auth.apiKey", - "label" : "API key", - "description" : "Expected API key", + "id" : "inbound.auth.username", + "label" : "Username", + "description" : "Username for basic authentication", "optional" : false, "feel" : "optional", "group" : "authorization", "binding" : { - "name" : "inbound.auth.apiKey", + "name" : "inbound.auth.username", "type" : "zeebe:property" }, "condition" : { "property" : "inbound.auth.type", - "equals" : "APIKEY", + "equals" : "BASIC", "type" : "simple" }, "type" : "String" }, { - "id" : "inbound.auth.apiKeyLocator", - "label" : "API key locator", - "description" : "A FEEL expression that extracts API key from the request. See documentation", + "id" : "inbound.auth.password", + "label" : "Password", + "description" : "Password for basic authentication", "optional" : false, - "value" : "=split(request.headers.authorization, \" \")[2]", - "constraints" : { - "notEmpty" : true - }, - "feel" : "required", + "feel" : "optional", "group" : "authorization", "binding" : { - "name" : "inbound.auth.apiKeyLocator", + "name" : "inbound.auth.password", "type" : "zeebe:property" }, "condition" : { "property" : "inbound.auth.type", - "equals" : "APIKEY", + "equals" : "BASIC", "type" : "simple" }, "type" : "String" @@ -334,14 +334,14 @@ }, "type" : "String" }, { - "id" : "inbound.responseBodyExpression", - "label" : "Response body expression", - "description" : "Specify condition and response", + "id" : "inbound.responseExpression", + "label" : "Response expression", + "description" : "Expression used to generate the HTTP response", "optional" : true, "feel" : "required", "group" : "webhookResponse", "binding" : { - "name" : "inbound.responseBodyExpression", + "name" : "inbound.responseExpression", "type" : "zeebe:property" }, "type" : "Text" diff --git a/connectors/webhook/src/main/java/io/camunda/connector/inbound/HttpWebhookExecutable.java b/connectors/webhook/src/main/java/io/camunda/connector/inbound/HttpWebhookExecutable.java index 25a77bbb6e..ea8eff4e4f 100644 --- a/connectors/webhook/src/main/java/io/camunda/connector/inbound/HttpWebhookExecutable.java +++ b/connectors/webhook/src/main/java/io/camunda/connector/inbound/HttpWebhookExecutable.java @@ -14,13 +14,14 @@ import io.camunda.connector.api.inbound.InboundConnectorContext; import io.camunda.connector.api.inbound.Severity; import io.camunda.connector.api.inbound.webhook.MappedHttpRequest; -import io.camunda.connector.api.inbound.webhook.VerifiableWebhook; import io.camunda.connector.api.inbound.webhook.WebhookConnectorException; import io.camunda.connector.api.inbound.webhook.WebhookConnectorException.WebhookSecurityException; import io.camunda.connector.api.inbound.webhook.WebhookConnectorException.WebhookSecurityException.Reason; import io.camunda.connector.api.inbound.webhook.WebhookConnectorExecutable; +import io.camunda.connector.api.inbound.webhook.WebhookHttpResponse; import io.camunda.connector.api.inbound.webhook.WebhookProcessingPayload; import io.camunda.connector.api.inbound.webhook.WebhookResult; +import io.camunda.connector.api.inbound.webhook.WebhookResultContext; import io.camunda.connector.generator.dsl.BpmnType; import io.camunda.connector.generator.java.annotation.ElementTemplate; import io.camunda.connector.generator.java.annotation.ElementTemplate.ConnectorElementType; @@ -42,6 +43,8 @@ import java.security.NoSuchAlgorithmException; import java.util.Map; import java.util.Optional; +import java.util.function.Function; +import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -50,7 +53,7 @@ id = "io.camunda.connectors.webhook", name = "Webhook Connector", icon = "icon.svg", - version = 10, + version = 11, inputDataClass = WebhookConnectorPropertiesWrapper.class, description = "Configure webhook to receive callbacks", documentationRef = @@ -83,65 +86,99 @@ templateIdOverride = "io.camunda.connectors.webhook.WebhookConnectorBoundary.v1", templateNameOverride = "Webhook Boundary Event Connector") }) -public class HttpWebhookExecutable implements WebhookConnectorExecutable, VerifiableWebhook { +public class HttpWebhookExecutable implements WebhookConnectorExecutable { private static final Logger LOGGER = LoggerFactory.getLogger(HttpWebhookExecutable.class); private WebhookConnectorProperties props; private WebhookAuthorizationHandler authChecker; - private InboundConnectorContext context; + private Function responseExpression; @Override - public WebhookResult triggerWebhook(WebhookProcessingPayload payload) - throws NoSuchAlgorithmException, InvalidKeyException, IOException { - LOGGER.trace("Triggered webhook with context " + props.context() + " and payload " + payload); + public void activate(InboundConnectorContext context) { + this.context = context; + var wrappedProps = context.bindProperties(WebhookConnectorPropertiesWrapper.class); + props = new WebhookConnectorProperties(wrappedProps); + authChecker = WebhookAuthorizationHandler.getHandlerForAuth(props.auth()); + responseExpression = mapResponseExpression(); + } + + @Override + public WebhookResult triggerWebhook(WebhookProcessingPayload payload) { + LOGGER.trace("Triggered webhook with context {} and payload {}", props.context(), payload); + this.context.log( Activity.level(Severity.INFO) .tag(payload.method()) .message("Url: " + payload.requestURL())); + + validateHttpMethod(payload); + verifySignature(payload); + + var authResult = authChecker.checkAuthorization(payload); + if (authResult instanceof Failure failureResult) { + throw failureResult.toException(); + } + + var mappedRequest = mapRequest(payload); + return new WebhookProcessingResultImpl(mappedRequest, responseExpression, null); + } + + private void validateHttpMethod(WebhookProcessingPayload payload) { if (!HttpMethods.any.name().equalsIgnoreCase(props.method()) && !payload.method().equalsIgnoreCase(props.method())) { throw new WebhookConnectorException( HttpResponseStatus.METHOD_NOT_ALLOWED.code(), "Method " + payload.method() + " not supported"); } + } + + private static MappedHttpRequest mapRequest(WebhookProcessingPayload payload) { + return new MappedHttpRequest( + HttpWebhookUtil.transformRawBodyToMap( + payload.rawBody(), HttpWebhookUtil.extractContentType(payload.headers())), + payload.headers(), + payload.params()); + } - WebhookProcessingResultImpl response = new WebhookProcessingResultImpl(); + @Nullable + private Function mapResponseExpression() { + Function responseExpression = null; + if (props.responseExpression() != null) { + responseExpression = props.responseExpression(); + } else if (props.responseBodyExpression() != null) { + // To be backwards compatible we need to wrap the responseBodyExpression into a + // responseExpression + // and only use the body in the final response + responseExpression = + (context) -> { + Object responseBody = props.responseBodyExpression().apply(context); + return WebhookHttpResponse.ok(responseBody); + }; + } + return responseExpression; + } + private void verifySignature(WebhookProcessingPayload payload) { if (!webhookSignatureIsValid(payload)) { throw new WebhookSecurityException( HttpResponseStatus.UNAUTHORIZED.code(), Reason.INVALID_SIGNATURE, "HMAC signature check didn't pass"); } - - var authResult = authChecker.checkAuthorization(payload); - if (authResult instanceof Failure failureResult) { - throw failureResult.toException(); - } - - response.setRequest( - new MappedHttpRequest( - HttpWebhookUtil.transformRawBodyToMap( - payload.rawBody(), HttpWebhookUtil.extractContentType(payload.headers())), - payload.headers(), - payload.params())); - - if (props.responseBodyExpression() != null) { - response.setResponseBodyExpression(props.responseBodyExpression()); - } - - return response; } - private boolean webhookSignatureIsValid(WebhookProcessingPayload payload) - throws NoSuchAlgorithmException, InvalidKeyException, IOException { - if (shouldValidateHmac()) { - HMACEncodingStrategy strategy = - HMACEncodingStrategyFactory.getStrategy(props.hmacScopes(), payload.method()); - byte[] bytesToSign = strategy.getBytesToSign(payload); - return validateHmacSignature(bytesToSign, payload); + private boolean webhookSignatureIsValid(WebhookProcessingPayload payload) { + try { + if (shouldValidateHmac()) { + HMACEncodingStrategy strategy = + HMACEncodingStrategyFactory.getStrategy(props.hmacScopes(), payload.method()); + byte[] bytesToSign = strategy.getBytesToSign(payload); + return validateHmacSignature(bytesToSign, payload); + } + } catch (Exception e) { + throw new RuntimeException(e); } return true; } @@ -165,19 +202,8 @@ private boolean validateHmacSignature(byte[] signatureData, WebhookProcessingPay } @Override - public void activate(InboundConnectorContext context) { - this.context = context; - var wrappedProps = context.bindProperties(WebhookConnectorPropertiesWrapper.class); - props = new WebhookConnectorProperties(wrappedProps); - authChecker = WebhookAuthorizationHandler.getHandlerForAuth(props.auth()); - } - - @Override - public void deactivate() {} - - @Override - public WebhookHttpVerificationResult verify(final WebhookProcessingPayload payload) { - WebhookHttpVerificationResult result = null; + public WebhookHttpResponse verify(WebhookProcessingPayload payload) { + WebhookHttpResponse result = null; if (props.verificationExpression() != null) { result = props diff --git a/connectors/webhook/src/main/java/io/camunda/connector/inbound/model/WebhookConnectorProperties.java b/connectors/webhook/src/main/java/io/camunda/connector/inbound/model/WebhookConnectorProperties.java index a4d875aee6..c745774931 100644 --- a/connectors/webhook/src/main/java/io/camunda/connector/inbound/model/WebhookConnectorProperties.java +++ b/connectors/webhook/src/main/java/io/camunda/connector/inbound/model/WebhookConnectorProperties.java @@ -6,8 +6,7 @@ */ package io.camunda.connector.inbound.model; -import static io.camunda.connector.api.inbound.webhook.VerifiableWebhook.WebhookHttpVerificationResult; - +import io.camunda.connector.api.inbound.webhook.WebhookHttpResponse; import io.camunda.connector.api.inbound.webhook.WebhookResultContext; import io.camunda.connector.feel.annotation.FEEL; import io.camunda.connector.generator.dsl.Property.FeelMode; @@ -112,14 +111,15 @@ public record WebhookConnectorProperties( HMACScope[] hmacScopes, WebhookAuthorization auth, @TemplateProperty( - id = "responseBodyExpression", - label = "Response body expression", + id = "responseExpression", + label = "Response expression", type = PropertyType.Text, group = "webhookResponse", - description = "Specify condition and response", + description = "Expression used to generate the HTTP response", feel = FeelMode.required, optional = true) - Function responseBodyExpression, + Function responseExpression, + @TemplateProperty(ignore = true) Function responseBodyExpression, @TemplateProperty( id = "verificationExpression", label = "One time verification response expression", @@ -129,7 +129,7 @@ public record WebhookConnectorProperties( group = "webhookResponse", feel = FeelMode.required, optional = true) - Function, WebhookHttpVerificationResult> verificationExpression) { + Function, WebhookHttpResponse> verificationExpression) { public WebhookConnectorProperties(WebhookConnectorPropertiesWrapper wrapper) { this( @@ -142,6 +142,7 @@ public WebhookConnectorProperties(WebhookConnectorPropertiesWrapper wrapper) { // default to BODY if no scopes are provided getOrDefault(wrapper.inbound.hmacScopes, new HMACScope[] {HMACScope.BODY}), getOrDefault(wrapper.inbound.auth, new WebhookAuthorization.None()), + wrapper.inbound.responseExpression, wrapper.inbound.responseBodyExpression, wrapper.inbound.verificationExpression); } diff --git a/connectors/webhook/src/main/java/io/camunda/connector/inbound/model/WebhookProcessingResultImpl.java b/connectors/webhook/src/main/java/io/camunda/connector/inbound/model/WebhookProcessingResultImpl.java index 70009d85be..abcc11bafc 100644 --- a/connectors/webhook/src/main/java/io/camunda/connector/inbound/model/WebhookProcessingResultImpl.java +++ b/connectors/webhook/src/main/java/io/camunda/connector/inbound/model/WebhookProcessingResultImpl.java @@ -7,20 +7,21 @@ package io.camunda.connector.inbound.model; import io.camunda.connector.api.inbound.webhook.MappedHttpRequest; +import io.camunda.connector.api.inbound.webhook.WebhookHttpResponse; import io.camunda.connector.api.inbound.webhook.WebhookResult; import io.camunda.connector.api.inbound.webhook.WebhookResultContext; import java.util.Collections; import java.util.Map; -import java.util.Objects; import java.util.Optional; import java.util.function.Function; -public class WebhookProcessingResultImpl implements WebhookResult { +public record WebhookProcessingResultImpl( + MappedHttpRequest request, + Function responseExpression, + Map connectorData) + implements WebhookResult { - private MappedHttpRequest request; - private Map connectorData; - - private Function responseBodyExpression; + public WebhookProcessingResultImpl {} @Override public MappedHttpRequest request() { @@ -33,50 +34,7 @@ public Map connectorData() { } @Override - public Function responseBodyExpression() { - if (responseBodyExpression != null) { - return responseBodyExpression; - } - return (response) -> null; - } - - public void setRequest(MappedHttpRequest request) { - this.request = request; - } - - public void setConnectorData(Map connectorData) { - this.connectorData = connectorData; - } - - public void setResponseBodyExpression( - Function responseBodyExpression) { - this.responseBodyExpression = responseBodyExpression; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - WebhookProcessingResultImpl that = (WebhookProcessingResultImpl) o; - return Objects.equals(request, that.request) - && Objects.equals(connectorData, that.connectorData) - && Objects.equals(responseBodyExpression, that.responseBodyExpression); - } - - @Override - public int hashCode() { - return Objects.hash(request, connectorData, responseBodyExpression); - } - - @Override - public String toString() { - return "WebhookProcessingResultImpl{" - + "request=" - + request - + ", connectorData=" - + connectorData - + ", responseBodyExpression=" - + responseBodyExpression - + '}'; + public Function response() { + return responseExpression; } } diff --git a/connectors/webhook/src/test/java/io/camunda/connector/inbound/HttpWebhookExecutableTest.java b/connectors/webhook/src/test/java/io/camunda/connector/inbound/HttpWebhookExecutableTest.java index 87d9b0273f..039c472553 100644 --- a/connectors/webhook/src/test/java/io/camunda/connector/inbound/HttpWebhookExecutableTest.java +++ b/connectors/webhook/src/test/java/io/camunda/connector/inbound/HttpWebhookExecutableTest.java @@ -10,13 +10,18 @@ import static io.camunda.connector.inbound.signature.HMACSwitchCustomerChoice.enabled; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.catchException; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import com.google.common.net.HttpHeaders; import com.google.common.net.MediaType; import io.camunda.connector.api.inbound.InboundConnectorContext; +import io.camunda.connector.api.inbound.webhook.MappedHttpRequest; import io.camunda.connector.api.inbound.webhook.WebhookConnectorException; import io.camunda.connector.api.inbound.webhook.WebhookProcessingPayload; +import io.camunda.connector.api.inbound.webhook.WebhookResultContext; import io.camunda.connector.inbound.signature.HMACAlgoCustomerChoice; import io.camunda.connector.inbound.utils.HttpMethods; import io.camunda.connector.test.inbound.InboundConnectorContextBuilder; @@ -37,7 +42,7 @@ void beforeEach() { } @Test - void triggerWebhook_JsonBody_HappyCase() throws Exception { + void triggerWebhook_JsonBody_HappyCase() { InboundConnectorContext ctx = InboundConnectorContextBuilder.create() .properties( @@ -59,11 +64,49 @@ void triggerWebhook_JsonBody_HappyCase() throws Exception { testObject.activate(ctx); var result = testObject.triggerWebhook(payload); + assertNull(result.response()); assertThat((Map) result.request().body()).containsEntry("key", "value"); } @Test - void triggerWebhook_FormDataBody_HappyCase() throws Exception { + void triggerWebhook_ResponseExpression_HappyCase() { + InboundConnectorContext ctx = + InboundConnectorContextBuilder.create() + .properties( + Map.of( + "inbound", + Map.of( + "context", + "webhookContext", + "method", + "any", + "auth", + Map.of("type", "NONE"), + "responseExpression", + "=if request.body.key != null then {body: request.body.key} else null"))) + .build(); + + WebhookProcessingPayload payload = Mockito.mock(WebhookProcessingPayload.class); + Mockito.when(payload.method()).thenReturn(HttpMethods.any.name()); + Mockito.when(payload.headers()) + .thenReturn(Map.of(HttpHeaders.CONTENT_TYPE, MediaType.JSON_UTF_8.toString())); + Mockito.when(payload.rawBody()) + .thenReturn("{\"key\": \"value\"}".getBytes(StandardCharsets.UTF_8)); + + testObject.activate(ctx); + var result = testObject.triggerWebhook(payload); + + assertNotNull(result.response()); + assertThat((Map) result.request().body()).containsEntry("key", "value"); + + var request = new MappedHttpRequest(Map.of("key", "value"), null, null); + var context = new WebhookResultContext(request, null, null); + var response = result.response().apply(context); + assertEquals("value", response.body()); + } + + @Test + void triggerWebhook_FormDataBody_HappyCase() { InboundConnectorContext ctx = InboundConnectorContextBuilder.create() .properties( @@ -89,7 +132,7 @@ void triggerWebhook_FormDataBody_HappyCase() throws Exception { } @Test - void triggerWebhook_UnknownJsonLikeBody_HappyCase() throws Exception { + void triggerWebhook_UnknownJsonLikeBody_HappyCase() { InboundConnectorContext ctx = InboundConnectorContextBuilder.create() .properties( @@ -114,7 +157,7 @@ void triggerWebhook_UnknownJsonLikeBody_HappyCase() throws Exception { } @Test - void triggerWebhook_BinaryData_RaisesException() throws Exception { + void triggerWebhook_BinaryData_RaisesException() { InboundConnectorContext ctx = InboundConnectorContextBuilder.create() .properties( @@ -138,7 +181,7 @@ void triggerWebhook_BinaryData_RaisesException() throws Exception { } @Test - void triggerWebhook_HttpMethodNotAllowed_RaisesException() throws Exception { + void triggerWebhook_HttpMethodNotAllowed_RaisesException() { InboundConnectorContext ctx = InboundConnectorContextBuilder.create() .properties( @@ -165,7 +208,7 @@ void triggerWebhook_HttpMethodNotAllowed_RaisesException() throws Exception { } @Test - void triggerWebhook_HmacSignatureMatches_HappyCase() throws Exception { + void triggerWebhook_HmacSignatureMatches_HappyCase() { InboundConnectorContext ctx = InboundConnectorContextBuilder.create() .properties( @@ -199,7 +242,7 @@ void triggerWebhook_HmacSignatureMatches_HappyCase() throws Exception { } @Test - void triggerWebhook_HmacSignatureDidntMatch_RaisesException() throws Exception { + void triggerWebhook_HmacSignatureDidntMatch_RaisesException() { InboundConnectorContext ctx = InboundConnectorContextBuilder.create() .properties( @@ -235,7 +278,7 @@ void triggerWebhook_HmacSignatureDidntMatch_RaisesException() throws Exception { } @Test - void triggerWebhook_BadApiKey_RaisesException() throws Exception { + void triggerWebhook_BadApiKey_RaisesException() { InboundConnectorContext ctx = InboundConnectorContextBuilder.create() .properties( @@ -276,7 +319,7 @@ void triggerWebhook_BadApiKey_RaisesException() throws Exception { } @Test - void triggerWebhook_MissingApiKey_RaisesException() throws Exception { + void triggerWebhook_MissingApiKey_RaisesException() { InboundConnectorContext ctx = InboundConnectorContextBuilder.create() .properties( @@ -312,7 +355,7 @@ void triggerWebhook_MissingApiKey_RaisesException() throws Exception { } @Test - void triggerWebhook_VerificationExpression_ReturnsChallenge() throws Exception { + void triggerWebhook_VerificationExpression_ReturnsChallenge() { final var verificationExpression = "=if request.body.challenge != null then {\"body\": {\"challenge\":request.body.challenge}} else null"; InboundConnectorContext ctx = @@ -341,13 +384,12 @@ void triggerWebhook_VerificationExpression_ReturnsChallenge() throws Exception { testObject.activate(ctx); var result = testObject.verify(payload); - assertThat(result.statusCode()).isEqualTo(200); assertThat(result.body()).isInstanceOf(Map.class); assertThat((Map) result.body()).containsEntry("challenge", "12345"); } @Test - void triggerWebhook_VerificationExpressionWithModifiedBody_ReturnsChallenge() throws Exception { + void triggerWebhook_VerificationExpressionWithModifiedBody_ReturnsChallenge() { final var verificationExpression = "=if request.body.challenge != null then {\"body\": {\"challenge123\":request.body.challenge + \"QQQ\"}} else null"; InboundConnectorContext ctx = @@ -376,13 +418,12 @@ void triggerWebhook_VerificationExpressionWithModifiedBody_ReturnsChallenge() th testObject.activate(ctx); var result = testObject.verify(payload); - assertThat(result.statusCode()).isEqualTo(200); assertThat(result.body()).isInstanceOf(Map.class); assertThat((Map) result.body()).containsEntry("challenge123", "12345QQQ"); } @Test - void triggerWebhook_VerificationExpressionWithFoldedBody_ReturnsChallenge() throws Exception { + void triggerWebhook_VerificationExpressionWithFoldedBody_ReturnsChallenge() { final var verificationExpression = "=if request.body.event_type = \"verification\" then {\"body\": {\"challenge\":request.body.event.challenge}} else null"; InboundConnectorContext ctx = @@ -413,13 +454,12 @@ void triggerWebhook_VerificationExpressionWithFoldedBody_ReturnsChallenge() thro testObject.activate(ctx); var result = testObject.verify(payload); - assertThat(result.statusCode()).isEqualTo(200); assertThat(result.body()).isInstanceOf(Map.class); assertThat((Map) result.body()).containsEntry("challenge", "12345"); } @Test - void triggerWebhook_VerificationExpressionWithStatusCode_ReturnsChallenge() throws Exception { + void triggerWebhook_VerificationExpressionWithStatusCode_ReturnsChallenge() { final var verificationExpression = "=if request.body.challenge != null then {\"body\": {\"challenge\":request.body.challenge}, \"statusCode\": 409} else null"; InboundConnectorContext ctx = @@ -454,7 +494,7 @@ void triggerWebhook_VerificationExpressionWithStatusCode_ReturnsChallenge() thro } @Test - void triggerWebhook_VerificationExpressionWithCustomHeaders_ReturnsChallenge() throws Exception { + void triggerWebhook_VerificationExpressionWithCustomHeaders_ReturnsChallenge() { final var verificationExpression = "=if request.body.challenge != null then {\"body\": {\"challenge\":request.body.challenge}, \"headers\":{\"Content-Type\":\"application/camunda-bin\"}} else null"; InboundConnectorContext ctx = @@ -483,7 +523,6 @@ void triggerWebhook_VerificationExpressionWithCustomHeaders_ReturnsChallenge() t testObject.activate(ctx); var result = testObject.verify(payload); - assertThat(result.statusCode()).isEqualTo(200); assertThat(result.body()).isInstanceOf(Map.class); assertThat((Map) result.body()).containsEntry("challenge", "12345"); assertThat(result.headers()).containsEntry("Content-Type", "application/camunda-bin");