From 7e19201f287c39322dac935be1f60ef14687760c Mon Sep 17 00:00:00 2001 From: Jonathan Date: Tue, 7 Jan 2025 15:52:39 +0100 Subject: [PATCH 1/3] feat(rest): Add support for storing GraphQL/REST Connector's response as a document (#3746) * feat(rest): Add support for storing REST Connector's response as a document * feat(rest): Clarify code * feat(rest): Add inputVariable for storeResponse param * feat(rest): Remove useless FEEL annotation * feat(rest): Replace DocumentReference by Document * feat(rest): Replace DocumentReference by Document * feat(rest): Fix UTs * feat(rest): update README * feat(rest): update README * feat(rest): PR comments fixes * feat(rest): update after merge conflict * feat(rest): spotless * feat(rest): fix UTs * feat(rest): fix UTs --- .../src/test/resources/application.properties | 14 +-- .../src/test/resources/application.properties | 16 ++- .../document/store/InMemoryDocumentStore.java | 4 + .../src/main/resources/application.properties | 6 +- .../graphql-outbound-connector.json | 14 ++- .../graphql-outbound-connector-hybrid.json | 14 ++- .../http/graphql/GraphQLFunction.java | 4 +- .../http/graphql/model/GraphQLRequest.java | 8 ++ .../graphql/utils/GraphQLRequestMapper.java | 1 + .../http/base/ExecutionEnvironment.java | 65 ++++++++++++ .../connector/http/base/HttpService.java | 24 +++-- .../http/base/client/HttpClient.java | 11 ++- .../client/apache/CustomApacheHttpClient.java | 13 ++- .../HttpCommonResultResponseHandler.java | 61 ++++++++++-- .../cloudfunction/CloudFunctionService.java | 23 ++++- .../base/document/FileResponseHandler.java | 86 ++++++++++++++++ .../exception/ConnectorExceptionMapper.java | 2 +- .../http/base/model/HttpCommonRequest.java | 26 ++++- .../http/base/model/HttpCommonResult.java | 59 ++++++++++- .../http/base/DocumentOutboundContext.java | 50 ++++++++++ .../connector/http/base/HttpServiceTest.java | 58 ++++++++--- .../apache/CustomApacheHttpClientTest.java | 78 +++++++++------ .../HttpCommonResultResponseHandlerTest.java | 14 ++- .../CloudFunctionResponseTransformer.java | 18 +++- .../ConnectorExceptionMapperTest.java | 8 +- .../src/test/resources/__files/fileName.jpg | Bin 0 -> 59151 bytes connectors/http/rest/README.md | 93 ++++++++++++------ .../http-json-connector.json | 14 ++- .../hybrid/http-json-connector-hybrid.json | 14 ++- .../connector/http/rest/HttpJsonFunction.java | 7 +- 30 files changed, 655 insertions(+), 150 deletions(-) create mode 100644 connectors/http/http-base/src/main/java/io/camunda/connector/http/base/ExecutionEnvironment.java create mode 100644 connectors/http/http-base/src/main/java/io/camunda/connector/http/base/document/FileResponseHandler.java create mode 100644 connectors/http/http-base/src/test/java/io/camunda/connector/http/base/DocumentOutboundContext.java create mode 100644 connectors/http/http-base/src/test/resources/__files/fileName.jpg diff --git a/bundle/camunda-saas-bundle/src/test/resources/application.properties b/bundle/camunda-saas-bundle/src/test/resources/application.properties index 0ba0b90bba..ec2e7a7db3 100644 --- a/bundle/camunda-saas-bundle/src/test/resources/application.properties +++ b/bundle/camunda-saas-bundle/src/test/resources/application.properties @@ -1,7 +1,7 @@ -logging.level.root: info -logging.level.io.camunda.process.test: info - -logging.level.org.testcontainers: warn -logging.level.tc: warn - -logging.level.io.camunda.connector: debug \ No newline at end of file +logging.level.root:info +logging.level.io.camunda.process.test:info +logging.level.org.testcontainers:warn +logging.level.tc:warn +logging.level.io.camunda.connector:debug +camunda.rest.query.enabled=true +io.camunda.process.test.camunda-env-vars.CAMUNDA_DATABASE_URL=http://elasticsearch:9200 \ No newline at end of file diff --git a/connector-runtime/spring-boot-starter-camunda-connectors/src/test/resources/application.properties b/connector-runtime/spring-boot-starter-camunda-connectors/src/test/resources/application.properties index a5848525c6..5ec907ab47 100644 --- a/connector-runtime/spring-boot-starter-camunda-connectors/src/test/resources/application.properties +++ b/connector-runtime/spring-boot-starter-camunda-connectors/src/test/resources/application.properties @@ -1,11 +1,9 @@ # test secret property secrets.test.secret=test secret value - - -logging.level.root: info -logging.level.io.camunda.process.test: info - -logging.level.org.testcontainers: warn -logging.level.tc: warn - -logging.level.io.camunda.connector: debug +logging.level.root:info +logging.level.io.camunda.process.test:info +logging.level.org.testcontainers:warn +logging.level.tc:warn +logging.level.io.camunda.connector:debug +camunda.rest.query.enabled=true +io.camunda.process.test.camunda-env-vars.CAMUNDA_DATABASE_URL=http://elasticsearch:9200 \ No newline at end of file diff --git a/connector-sdk/document/src/main/java/io/camunda/document/store/InMemoryDocumentStore.java b/connector-sdk/document/src/main/java/io/camunda/document/store/InMemoryDocumentStore.java index fed6dab297..42b5659cc0 100644 --- a/connector-sdk/document/src/main/java/io/camunda/document/store/InMemoryDocumentStore.java +++ b/connector-sdk/document/src/main/java/io/camunda/document/store/InMemoryDocumentStore.java @@ -103,6 +103,10 @@ public void clear() { documents.clear(); } + public Map getDocuments() { + return documents; + } + public void logWarning() { LOGGER.warning( "In-memory document store is used. This store is not suitable for production use."); diff --git a/connectors-e2e-test/connectors-e2e-test-base/src/main/resources/application.properties b/connectors-e2e-test/connectors-e2e-test-base/src/main/resources/application.properties index 86a1872a66..ca8aa672ea 100644 --- a/connectors-e2e-test/connectors-e2e-test-base/src/main/resources/application.properties +++ b/connectors-e2e-test/connectors-e2e-test-base/src/main/resources/application.properties @@ -1,10 +1,8 @@ logging.level.root=info logging.level.io.camunda.process.test=info - logging.level.org.testcontainers=warn logging.level.tc=warn - logging.level.io.camunda.connector=debug - camunda.rest.query.enabled=true -io.camunda.process.test.camundaVersion: 8.7.0-alpha2 \ No newline at end of file +io.camunda.process.test.camundaVersion=8.7.0-alpha2 +io.camunda.process.test.camunda-env-vars.CAMUNDA_DATABASE_URL=http://elasticsearch:9200 \ No newline at end of file diff --git a/connectors/http/graphql/element-templates/graphql-outbound-connector.json b/connectors/http/graphql/element-templates/graphql-outbound-connector.json index e7ef2f39f5..c8ab4bf2cf 100644 --- a/connectors/http/graphql/element-templates/graphql-outbound-connector.json +++ b/connectors/http/graphql/element-templates/graphql-outbound-connector.json @@ -7,7 +7,7 @@ "keywords" : [ ] }, "documentationRef" : "https://docs.camunda.io/docs/components/connectors/protocol/graphql/", - "version" : 6, + "version" : 7, "category" : { "id" : "connectors", "name" : "Connectors" @@ -369,6 +369,18 @@ "type" : "zeebe:input" }, "type" : "String" + }, { + "id" : "graphql.storeResponse", + "label" : "Store response", + "description" : "Store the response as a document in the document store", + "optional" : false, + "value" : false, + "group" : "endpoint", + "binding" : { + "name" : "graphql.storeResponse", + "type" : "zeebe:input" + }, + "type" : "Boolean" }, { "id" : "graphql.query", "label" : "Query/Mutation", diff --git a/connectors/http/graphql/element-templates/hybrid/graphql-outbound-connector-hybrid.json b/connectors/http/graphql/element-templates/hybrid/graphql-outbound-connector-hybrid.json index 057eae837e..3692ae3cac 100644 --- a/connectors/http/graphql/element-templates/hybrid/graphql-outbound-connector-hybrid.json +++ b/connectors/http/graphql/element-templates/hybrid/graphql-outbound-connector-hybrid.json @@ -7,7 +7,7 @@ "keywords" : [ ] }, "documentationRef" : "https://docs.camunda.io/docs/components/connectors/protocol/graphql/", - "version" : 6, + "version" : 7, "category" : { "id" : "connectors", "name" : "Connectors" @@ -374,6 +374,18 @@ "type" : "zeebe:input" }, "type" : "String" + }, { + "id" : "graphql.storeResponse", + "label" : "Store response", + "description" : "Store the response as a document in the document store", + "optional" : false, + "value" : false, + "group" : "endpoint", + "binding" : { + "name" : "graphql.storeResponse", + "type" : "zeebe:input" + }, + "type" : "Boolean" }, { "id" : "graphql.query", "label" : "Query/Mutation", diff --git a/connectors/http/graphql/src/main/java/io/camunda/connector/http/graphql/GraphQLFunction.java b/connectors/http/graphql/src/main/java/io/camunda/connector/http/graphql/GraphQLFunction.java index 44512e661d..4920c3b3cc 100644 --- a/connectors/http/graphql/src/main/java/io/camunda/connector/http/graphql/GraphQLFunction.java +++ b/connectors/http/graphql/src/main/java/io/camunda/connector/http/graphql/GraphQLFunction.java @@ -28,7 +28,7 @@ name = "GraphQL Outbound Connector", description = "Execute GraphQL query", inputDataClass = GraphQLRequest.class, - version = 6, + version = 7, propertyGroups = { @ElementTemplate.PropertyGroup(id = "authentication", label = "Authentication"), @ElementTemplate.PropertyGroup(id = "endpoint", label = "HTTP Endpoint"), @@ -59,6 +59,6 @@ public Object execute(OutboundConnectorContext context) { var graphQLRequest = context.bindVariables(GraphQLRequest.class); HttpCommonRequest commonRequest = graphQLRequestMapper.toHttpCommonRequest(graphQLRequest); LOGGER.debug("Executing graphql connector with request {}", commonRequest); - return httpService.executeConnectorRequest(commonRequest); + return httpService.executeConnectorRequest(commonRequest, context); } } diff --git a/connectors/http/graphql/src/main/java/io/camunda/connector/http/graphql/model/GraphQLRequest.java b/connectors/http/graphql/src/main/java/io/camunda/connector/http/graphql/model/GraphQLRequest.java index 03f25cf68f..27809d3213 100644 --- a/connectors/http/graphql/src/main/java/io/camunda/connector/http/graphql/model/GraphQLRequest.java +++ b/connectors/http/graphql/src/main/java/io/camunda/connector/http/graphql/model/GraphQLRequest.java @@ -72,6 +72,14 @@ public record GraphQL( optional = true, description = "Map of HTTP headers to add to the request") Map headers, + @TemplateProperty( + group = "endpoint", + type = TemplateProperty.PropertyType.Boolean, + feel = Property.FeelMode.disabled, + defaultValueType = TemplateProperty.DefaultValueType.Boolean, + defaultValue = "false", + description = "Store the response as a document in the document store") + boolean storeResponse, @TemplateProperty( group = "timeout", defaultValue = "20", diff --git a/connectors/http/graphql/src/main/java/io/camunda/connector/http/graphql/utils/GraphQLRequestMapper.java b/connectors/http/graphql/src/main/java/io/camunda/connector/http/graphql/utils/GraphQLRequestMapper.java index 3a282b571f..3d85524dd4 100644 --- a/connectors/http/graphql/src/main/java/io/camunda/connector/http/graphql/utils/GraphQLRequestMapper.java +++ b/connectors/http/graphql/src/main/java/io/camunda/connector/http/graphql/utils/GraphQLRequestMapper.java @@ -33,6 +33,7 @@ public HttpCommonRequest toHttpCommonRequest(GraphQLRequest graphQLRequest) { httpCommonRequest.setQueryParameters(mapQueryAndVariablesToQueryParams(queryAndVariablesMap)); } + httpCommonRequest.setStoreResponse(graphQLRequest.graphql().storeResponse()); httpCommonRequest.setHeaders(graphQLRequest.graphql().headers()); httpCommonRequest.setAuthentication(graphQLRequest.authentication()); httpCommonRequest.setUrl(graphQLRequest.graphql().url()); diff --git a/connectors/http/http-base/src/main/java/io/camunda/connector/http/base/ExecutionEnvironment.java b/connectors/http/http-base/src/main/java/io/camunda/connector/http/base/ExecutionEnvironment.java new file mode 100644 index 0000000000..e893dc2f67 --- /dev/null +++ b/connectors/http/http-base/src/main/java/io/camunda/connector/http/base/ExecutionEnvironment.java @@ -0,0 +1,65 @@ +/* + * 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.http.base; + +import io.camunda.connector.api.outbound.OutboundConnectorContext; + +public sealed interface ExecutionEnvironment + permits ExecutionEnvironment.SaaSCluster, + ExecutionEnvironment.SaaSCloudFunction, + ExecutionEnvironment.SelfManaged { + + /** + * The connector is executed in the context of the cloud function. This is where the + * HttpCommonRequest will be executed. + */ + record SaaSCloudFunction() implements ExecutionEnvironment {} + + /** + * The connector is executed in the context of the caller, i.e. in the C8 Cluster. When executed + * here, the initial HttpCommonRequest will be serialized as JSON and passed to the Cloud + * Function. + */ + record SaaSCluster(OutboundConnectorContext context) + implements ExecutionEnvironment, StoresDocument {} + + record SelfManaged(OutboundConnectorContext context) + implements ExecutionEnvironment, StoresDocument {} + + /** + * Factory method to create an ExecutionEnvironment based on the given parameters. + * + * @param cloudFunctionEnabled whether the connector is executed in the context of a cloud + * @param isRunningInCloudFunction whether the connector is executed in the cloud function + */ + static ExecutionEnvironment from( + boolean cloudFunctionEnabled, + boolean isRunningInCloudFunction, + OutboundConnectorContext context) { + if (cloudFunctionEnabled) { + return new SaaSCluster(context); + } + if (isRunningInCloudFunction) { + return new SaaSCloudFunction(); + } + return new SelfManaged(context); + } + + interface StoresDocument { + OutboundConnectorContext context(); + } +} diff --git a/connectors/http/http-base/src/main/java/io/camunda/connector/http/base/HttpService.java b/connectors/http/http-base/src/main/java/io/camunda/connector/http/base/HttpService.java index df25408868..621ca20c21 100644 --- a/connectors/http/http-base/src/main/java/io/camunda/connector/http/base/HttpService.java +++ b/connectors/http/http-base/src/main/java/io/camunda/connector/http/base/HttpService.java @@ -17,6 +17,7 @@ package io.camunda.connector.http.base; import io.camunda.connector.api.error.ConnectorException; +import io.camunda.connector.api.outbound.OutboundConnectorContext; import io.camunda.connector.http.base.blocklist.DefaultHttpBlocklistManager; import io.camunda.connector.http.base.blocklist.HttpBlockListManager; import io.camunda.connector.http.base.client.HttpClient; @@ -24,6 +25,7 @@ import io.camunda.connector.http.base.cloudfunction.CloudFunctionService; import io.camunda.connector.http.base.model.HttpCommonRequest; import io.camunda.connector.http.base.model.HttpCommonResult; +import javax.annotation.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -45,25 +47,35 @@ public HttpService(CloudFunctionService cloudFunctionService) { } public HttpCommonResult executeConnectorRequest(HttpCommonRequest request) { + return executeConnectorRequest(request, null); + } + + public HttpCommonResult executeConnectorRequest( + HttpCommonRequest request, @Nullable OutboundConnectorContext context) { // Will throw ConnectorInputException if URL is blocked httpBlocklistManager.validateUrlAgainstBlocklist(request.getUrl()); - boolean cloudFunctionEnabled = cloudFunctionService.isCloudFunctionEnabled(); + ExecutionEnvironment executionEnvironment = + ExecutionEnvironment.from( + cloudFunctionService.isCloudFunctionEnabled(), + cloudFunctionService.isRunningInCloudFunction(), + context); - if (cloudFunctionEnabled) { + if (executionEnvironment instanceof ExecutionEnvironment.SaaSCluster) { // Wrap the request in a proxy request request = cloudFunctionService.toCloudFunctionRequest(request); } - return executeRequest(request, cloudFunctionEnabled); + return executeRequest(request, executionEnvironment); } - private HttpCommonResult executeRequest(HttpCommonRequest request, boolean cloudFunctionEnabled) { + private HttpCommonResult executeRequest( + HttpCommonRequest request, @Nullable ExecutionEnvironment executionEnvironment) { try { - HttpCommonResult jsonResult = httpClient.execute(request, cloudFunctionEnabled); + HttpCommonResult jsonResult = httpClient.execute(request, executionEnvironment); LOGGER.debug("Connector returned result: {}", jsonResult); return jsonResult; } catch (ConnectorException e) { LOGGER.debug("Failed to execute request {}", request, e); - if (cloudFunctionEnabled) { + if (executionEnvironment instanceof ExecutionEnvironment.SaaSCluster) { throw cloudFunctionService.parseCloudFunctionError(e); } throw e; diff --git a/connectors/http/http-base/src/main/java/io/camunda/connector/http/base/client/HttpClient.java b/connectors/http/http-base/src/main/java/io/camunda/connector/http/base/client/HttpClient.java index 22ec5a04a2..e9464065d5 100644 --- a/connectors/http/http-base/src/main/java/io/camunda/connector/http/base/client/HttpClient.java +++ b/connectors/http/http-base/src/main/java/io/camunda/connector/http/base/client/HttpClient.java @@ -16,8 +16,10 @@ */ package io.camunda.connector.http.base.client; +import io.camunda.connector.http.base.ExecutionEnvironment; import io.camunda.connector.http.base.model.HttpCommonRequest; import io.camunda.connector.http.base.model.HttpCommonResult; +import javax.annotation.Nullable; public interface HttpClient { @@ -26,11 +28,11 @@ public interface HttpClient { * HttpCommonResult}. * * @param request the {@link HttpCommonRequest} to execute - * @param remoteExecutionEnabled whether to use the internal Google Function to execute the - * request remotely + * @param executionEnvironment the {@link ExecutionEnvironment} to use for the execution. * @return the result of the request as a {@link HttpCommonResult} */ - HttpCommonResult execute(HttpCommonRequest request, boolean remoteExecutionEnabled); + HttpCommonResult execute( + HttpCommonRequest request, @Nullable ExecutionEnvironment executionEnvironment); /** * Executes the given {@link HttpCommonRequest} and returns the result as a {@link @@ -38,8 +40,9 @@ public interface HttpClient { * * @param request the {@link HttpCommonRequest} to execute * @return the result of the request as a {@link HttpCommonResult} + * @see #execute(HttpCommonRequest, ExecutionEnvironment) */ default HttpCommonResult execute(HttpCommonRequest request) { - return execute(request, false); + return execute(request, null); } } diff --git a/connectors/http/http-base/src/main/java/io/camunda/connector/http/base/client/apache/CustomApacheHttpClient.java b/connectors/http/http-base/src/main/java/io/camunda/connector/http/base/client/apache/CustomApacheHttpClient.java index d9afdf5907..addbe27f91 100644 --- a/connectors/http/http-base/src/main/java/io/camunda/connector/http/base/client/apache/CustomApacheHttpClient.java +++ b/connectors/http/http-base/src/main/java/io/camunda/connector/http/base/client/apache/CustomApacheHttpClient.java @@ -17,12 +17,14 @@ package io.camunda.connector.http.base.client.apache; import io.camunda.connector.api.error.ConnectorException; +import io.camunda.connector.http.base.ExecutionEnvironment; import io.camunda.connector.http.base.client.HttpClient; import io.camunda.connector.http.base.client.HttpStatusHelper; import io.camunda.connector.http.base.exception.ConnectorExceptionMapper; import io.camunda.connector.http.base.model.HttpCommonRequest; import io.camunda.connector.http.base.model.HttpCommonResult; import java.io.IOException; +import javax.annotation.Nullable; import org.apache.hc.client5.http.ClientProtocolException; import org.apache.hc.client5.http.config.RequestConfig; import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; @@ -89,12 +91,12 @@ private static PoolingHttpClientConnectionManager createConnectionManager() { * org.apache.hc.core5.http.ClassicHttpRequest} and executes it. * * @param request the request to execute - * @param remoteExecutionEnabled whether to use the internal Google Function to execute the - * request remotely + * @param executionEnvironment the {@link ExecutionEnvironment} we are in * @return the {@link HttpCommonResult} */ @Override - public HttpCommonResult execute(HttpCommonRequest request, boolean remoteExecutionEnabled) { + public HttpCommonResult execute( + HttpCommonRequest request, @Nullable ExecutionEnvironment executionEnvironment) { var apacheRequest = ApacheRequestFactory.get().createHttpRequest(request); try { var result = @@ -104,7 +106,10 @@ public HttpCommonResult execute(HttpCommonRequest request, boolean remoteExecuti // (http.proxyHost, http.proxyPort, etc) .useSystemProperties() .build() - .execute(apacheRequest, new HttpCommonResultResponseHandler(remoteExecutionEnabled)); + .execute( + apacheRequest, + new HttpCommonResultResponseHandler( + executionEnvironment, request.isStoreResponse())); if (HttpStatusHelper.isError(result.status())) { throw ConnectorExceptionMapper.from(result); } diff --git a/connectors/http/http-base/src/main/java/io/camunda/connector/http/base/client/apache/HttpCommonResultResponseHandler.java b/connectors/http/http-base/src/main/java/io/camunda/connector/http/base/client/apache/HttpCommonResultResponseHandler.java index d7786af96a..b989167ce4 100644 --- a/connectors/http/http-base/src/main/java/io/camunda/connector/http/base/client/apache/HttpCommonResultResponseHandler.java +++ b/connectors/http/http-base/src/main/java/io/camunda/connector/http/base/client/apache/HttpCommonResultResponseHandler.java @@ -19,15 +19,20 @@ import static io.camunda.connector.http.base.utils.JsonHelper.isJsonStringValid; import io.camunda.connector.api.json.ConnectorsObjectMapperSupplier; +import io.camunda.connector.http.base.ExecutionEnvironment; import io.camunda.connector.http.base.client.HttpStatusHelper; +import io.camunda.connector.http.base.document.FileResponseHandler; import io.camunda.connector.http.base.model.ErrorResponse; import io.camunda.connector.http.base.model.HttpCommonResult; +import io.camunda.document.Document; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.util.Arrays; +import java.util.Base64; import java.util.Map; import java.util.stream.Collectors; +import javax.annotation.Nullable; import org.apache.commons.lang3.StringUtils; import org.apache.hc.core5.http.ClassicHttpResponse; import org.apache.hc.core5.http.Header; @@ -40,27 +45,42 @@ public class HttpCommonResultResponseHandler private static final Logger LOGGER = LoggerFactory.getLogger(HttpCommonResultResponseHandler.class); - boolean cloudFunctionEnabled; + private final FileResponseHandler fileResponseHandler; - public HttpCommonResultResponseHandler(boolean cloudFunctionEnabled) { - this.cloudFunctionEnabled = cloudFunctionEnabled; + private final ExecutionEnvironment executionEnvironment; + + private final boolean isStoreResponseSelected; + + public HttpCommonResultResponseHandler( + @Nullable ExecutionEnvironment executionEnvironment, boolean isStoreResponseSelected) { + this.executionEnvironment = executionEnvironment; + this.isStoreResponseSelected = isStoreResponseSelected; + this.fileResponseHandler = + new FileResponseHandler(executionEnvironment, isStoreResponseSelected); } @Override public HttpCommonResult handleResponse(ClassicHttpResponse response) { int code = response.getCode(); String reason = response.getReasonPhrase(); - Map headers = + Map headers = Arrays.stream(response.getHeaders()) .collect( // Collect the headers into a map ignoring duplicates (Set Cookies for instance) Collectors.toMap(Header::getName, Header::getValue, (first, second) -> first)); if (response.getEntity() != null) { try (InputStream content = response.getEntity().getContent()) { - if (cloudFunctionEnabled) { + if (executionEnvironment instanceof ExecutionEnvironment.SaaSCluster) { return getResultForCloudFunction(code, content, headers, reason); } - return new HttpCommonResult(code, headers, extractBody(content), reason); + var bytes = content.readAllBytes(); + var documentReference = handleFileResponse(headers, bytes); + return new HttpCommonResult( + code, + headers, + documentReference == null ? extractBody(bytes) : null, + reason, + documentReference); } catch (final Exception e) { LOGGER.error("Failed to parse external response: {}", response, e); } @@ -68,12 +88,18 @@ public HttpCommonResult handleResponse(ClassicHttpResponse response) { return new HttpCommonResult(code, headers, null, reason); } + private Document handleFileResponse(Map headers, byte[] content) { + var document = fileResponseHandler.handle(headers, content); + LOGGER.debug("Stored response as document. Document reference: {}", document); + return document; + } + /** * Will parse the response as a Cloud Function response. If the response is an error, it will be * unwrapped as an ErrorResponse. Otherwise, it will be unwrapped as a HttpCommonResult. */ private HttpCommonResult getResultForCloudFunction( - int code, InputStream content, Map headers, String reason) + int code, InputStream content, Map headers, String reason) throws IOException { if (HttpStatusHelper.isError(code)) { // unwrap as ErrorResponse @@ -82,17 +108,32 @@ private HttpCommonResult getResultForCloudFunction( return new HttpCommonResult(code, headers, errorResponse, reason); } // Unwrap the response as a HttpCommonResult directly - return ConnectorsObjectMapperSupplier.getCopy().readValue(content, HttpCommonResult.class); + var result = + ConnectorsObjectMapperSupplier.getCopy().readValue(content, HttpCommonResult.class); + Document document = fileResponseHandler.handleCloudFunctionResult(result); + return new HttpCommonResult( + result.status(), + result.headers(), + document == null ? result.body() : null, + result.reason(), + document); } /** * Extracts the body from the response content. Tries to parse the body as JSON, if it fails, * returns the body as a string. + * + * @param content the response content */ - private Object extractBody(InputStream content) throws IOException { + private Object extractBody(byte[] content) throws IOException { + if (executionEnvironment instanceof ExecutionEnvironment.SaaSCloudFunction + && isStoreResponseSelected) { + return Base64.getEncoder().encodeToString(content); + } + String bodyString = null; if (content != null) { - bodyString = new String(content.readAllBytes(), StandardCharsets.UTF_8); + bodyString = new String(content, StandardCharsets.UTF_8); } if (StringUtils.isNotBlank(bodyString)) { diff --git a/connectors/http/http-base/src/main/java/io/camunda/connector/http/base/cloudfunction/CloudFunctionService.java b/connectors/http/http-base/src/main/java/io/camunda/connector/http/base/cloudfunction/CloudFunctionService.java index b7056e43f6..3d11f868d2 100644 --- a/connectors/http/http-base/src/main/java/io/camunda/connector/http/base/cloudfunction/CloudFunctionService.java +++ b/connectors/http/http-base/src/main/java/io/camunda/connector/http/base/cloudfunction/CloudFunctionService.java @@ -16,6 +16,7 @@ */ package io.camunda.connector.http.base.cloudfunction; +import com.fasterxml.jackson.core.JsonProcessingException; import io.camunda.connector.api.error.ConnectorException; import io.camunda.connector.api.error.ConnectorExceptionBuilder; import io.camunda.connector.api.json.ConnectorsObjectMapperSupplier; @@ -35,6 +36,13 @@ public class CloudFunctionService { private static final Logger LOG = LoggerFactory.getLogger(CloudFunctionService.class); private static final String PROXY_FUNCTION_URL_ENV_NAME = "CAMUNDA_CONNECTOR_HTTP_PROXY_URL"; private static final int NO_TIMEOUT = 0; + + /** + * Environment variable to check if the current execution is running in a Google Cloud Function. + */ + private static final String CLOUD_FUNCTION_MARKER_VARIABLE = + "CAMUNDA_CONNECTOR_HTTP_BLOCK_URL_GCP_META_DATA_INTERNAL"; + private final String proxyFunctionUrl = System.getenv(PROXY_FUNCTION_URL_ENV_NAME); private final CloudFunctionCredentials credentials; @@ -55,11 +63,8 @@ public CloudFunctionService(CloudFunctionCredentials credentials) { */ public HttpCommonRequest toCloudFunctionRequest(final HttpCommonRequest request) { try { - // Using the JsonHttpContent cannot work with an element on the root content, - // hence write it ourselves: - String contentAsJson = ConnectorsObjectMapperSupplier.getCopy().writeValueAsString(request); String token = credentials.getOAuthToken(getProxyFunctionUrl()); - return createCloudFunctionRequest(contentAsJson, token); + return createCloudFunctionRequest(request, token); } catch (IOException e) { LOG.error("Failed to serialize the request to JSON: {}", request, e); throw new ConnectorException("Failed to serialize the request to JSON: " + request, e); @@ -102,15 +107,23 @@ public boolean isCloudFunctionEnabled() { return proxyFunctionUrl != null; } + /** Check if the current execution is running in a Google Cloud Function. */ + public boolean isRunningInCloudFunction() { + return System.getenv(CLOUD_FUNCTION_MARKER_VARIABLE) != null; + } + public String getProxyFunctionUrl() { return proxyFunctionUrl; } - private HttpCommonRequest createCloudFunctionRequest(String contentAsJson, String token) { + private HttpCommonRequest createCloudFunctionRequest(HttpCommonRequest request, String token) + throws JsonProcessingException { + String contentAsJson = ConnectorsObjectMapperSupplier.getCopy().writeValueAsString(request); HttpCommonRequest cloudFunctionRequest = new HttpCommonRequest(); cloudFunctionRequest.setMethod(HttpMethod.POST); cloudFunctionRequest.setUrl(getProxyFunctionUrl()); cloudFunctionRequest.setBody(contentAsJson); + cloudFunctionRequest.setStoreResponse(request.isStoreResponse()); cloudFunctionRequest.setReadTimeoutInSeconds(NO_TIMEOUT); cloudFunctionRequest.setHeaders( Map.of(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON.getMimeType())); diff --git a/connectors/http/http-base/src/main/java/io/camunda/connector/http/base/document/FileResponseHandler.java b/connectors/http/http-base/src/main/java/io/camunda/connector/http/base/document/FileResponseHandler.java new file mode 100644 index 0000000000..4d5281f684 --- /dev/null +++ b/connectors/http/http-base/src/main/java/io/camunda/connector/http/base/document/FileResponseHandler.java @@ -0,0 +1,86 @@ +/* + * 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.http.base.document; + +import io.camunda.connector.http.base.ExecutionEnvironment; +import io.camunda.connector.http.base.model.HttpCommonResult; +import io.camunda.document.Document; +import io.camunda.document.store.DocumentCreationRequest; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Map; +import javax.annotation.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class FileResponseHandler { + private static final Logger LOGGER = LoggerFactory.getLogger(FileResponseHandler.class); + public static final String CONTENT_TYPE = "Content-Type"; + private final ExecutionEnvironment executionEnvironment; + private final boolean isStoreResponseSelected; + + public FileResponseHandler( + @Nullable ExecutionEnvironment executionEnvironment, boolean isStoreResponseSelected) { + this.executionEnvironment = executionEnvironment; + this.isStoreResponseSelected = isStoreResponseSelected; + } + + public Document handleCloudFunctionResult(HttpCommonResult result) { + if (!storeResponseSelected()) return null; + + var body = result.body(); + if (body instanceof String stringBody) { + LOGGER.debug("Storing document from Cloud Function Result body"); + return handle( + result.headers(), + Base64.getDecoder().decode(stringBody.getBytes(StandardCharsets.UTF_8))); + } + LOGGER.warn("Cannot store document from body of type {} (expected String)", body.getClass()); + return null; + } + + public Document handle(Map headers, byte[] content) { + if (storeResponseSelected() + && executionEnvironment instanceof ExecutionEnvironment.StoresDocument env) { + try (var byteArrayInputStream = new ByteArrayInputStream(content)) { + return env.context() + .createDocument( + DocumentCreationRequest.from(byteArrayInputStream) + .contentType(getContentType(headers)) + .build()); + } catch (IOException e) { + LOGGER.error("Failed to create document", e); + throw new RuntimeException(e); + } + } + return null; + } + + private String getContentType(Map headers) { + return headers.entrySet().stream() + .filter(e -> e.getKey().equalsIgnoreCase(CONTENT_TYPE)) + .map(Map.Entry::getValue) + .findFirst() + .orElse(null); + } + + private boolean storeResponseSelected() { + return executionEnvironment != null && isStoreResponseSelected; + } +} diff --git a/connectors/http/http-base/src/main/java/io/camunda/connector/http/base/exception/ConnectorExceptionMapper.java b/connectors/http/http-base/src/main/java/io/camunda/connector/http/base/exception/ConnectorExceptionMapper.java index 7d4e3632c9..68f5286d72 100644 --- a/connectors/http/http-base/src/main/java/io/camunda/connector/http/base/exception/ConnectorExceptionMapper.java +++ b/connectors/http/http-base/src/main/java/io/camunda/connector/http/base/exception/ConnectorExceptionMapper.java @@ -28,7 +28,7 @@ public class ConnectorExceptionMapper { public static ConnectorException from(HttpCommonResult result) { String status = String.valueOf(result.status()); String reason = Optional.ofNullable(result.reason()).orElse("[no reason]"); - Map headers = result.headers(); + Map headers = result.headers(); Object body = result.body(); Map response = new HashMap<>(); response.put("headers", headers); diff --git a/connectors/http/http-base/src/main/java/io/camunda/connector/http/base/model/HttpCommonRequest.java b/connectors/http/http-base/src/main/java/io/camunda/connector/http/base/model/HttpCommonRequest.java index e334f892cd..8a9e582758 100644 --- a/connectors/http/http-base/src/main/java/io/camunda/connector/http/base/model/HttpCommonRequest.java +++ b/connectors/http/http-base/src/main/java/io/camunda/connector/http/base/model/HttpCommonRequest.java @@ -17,6 +17,7 @@ package io.camunda.connector.http.base.model; import io.camunda.connector.feel.annotation.FEEL; +import io.camunda.connector.generator.dsl.Property; import io.camunda.connector.generator.dsl.Property.FeelMode; import io.camunda.connector.generator.java.annotation.TemplateProperty; import io.camunda.connector.generator.java.annotation.TemplateProperty.PropertyCondition; @@ -101,6 +102,15 @@ public class HttpCommonRequest { description = "Map of query parameters to add to the request URL") private Map queryParameters; + @TemplateProperty( + group = "endpoint", + type = PropertyType.Boolean, + feel = Property.FeelMode.disabled, + defaultValueType = TemplateProperty.DefaultValueType.Boolean, + defaultValue = "false", + description = "Store the response as a document in the document store") + private boolean storeResponse; + public Object getBody() { return body; } @@ -189,6 +199,14 @@ public void setHeaders(final Map headers) { this.headers = headers; } + public boolean isStoreResponse() { + return storeResponse; + } + + public void setStoreResponse(boolean storeResponse) { + this.storeResponse = storeResponse; + } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -201,7 +219,8 @@ public boolean equals(Object o) { && Objects.equals(readTimeoutInSeconds, that.readTimeoutInSeconds) && Objects.equals(headers, that.headers) && Objects.equals(body, that.body) - && Objects.equals(queryParameters, that.queryParameters); + && Objects.equals(queryParameters, that.queryParameters) + && storeResponse == that.storeResponse; } @Override @@ -214,7 +233,8 @@ public int hashCode() { readTimeoutInSeconds, headers, body, - queryParameters); + queryParameters, + storeResponse); } @Override @@ -240,6 +260,8 @@ public String toString() { + body + ", queryParameters=" + queryParameters + + ", storeResponse=" + + storeResponse + '}'; } } diff --git a/connectors/http/http-base/src/main/java/io/camunda/connector/http/base/model/HttpCommonResult.java b/connectors/http/http-base/src/main/java/io/camunda/connector/http/base/model/HttpCommonResult.java index 75ad09238b..d7137136ad 100644 --- a/connectors/http/http-base/src/main/java/io/camunda/connector/http/base/model/HttpCommonResult.java +++ b/connectors/http/http-base/src/main/java/io/camunda/connector/http/base/model/HttpCommonResult.java @@ -17,19 +17,68 @@ package io.camunda.connector.http.base.model; import io.camunda.connector.generator.java.annotation.DataExample; +import io.camunda.document.CamundaDocument; +import io.camunda.document.Document; +import io.camunda.document.reference.CamundaDocumentReferenceImpl; +import io.camunda.document.reference.DocumentReference; +import io.camunda.document.store.InMemoryDocumentStore; +import io.camunda.zeebe.client.api.response.DocumentMetadata; +import java.time.OffsetDateTime; import java.util.Map; public record HttpCommonResult( - int status, Map headers, Object body, String reason) { + int status, Map headers, Object body, String reason, Document document) { - public HttpCommonResult(int status, Map headers, Object body) { - this(status, headers, body, null); + public HttpCommonResult(int status, Map headers, Object body, String reason) { + this(status, headers, body, reason, null); + } + + public HttpCommonResult( + int status, Map headers, Object body, Document documentReference) { + this(status, headers, body, null, documentReference); + } + + public HttpCommonResult(int status, Map headers, Object body) { + this(status, headers, body, null, null); } @DataExample(id = "basic", feel = "= body.order.id") public static HttpCommonResult exampleResult() { - Map headers = Map.of("Content-Type", "application/json"); + Map headers = Map.of("Content-Type", "application/json"); + DocumentReference.CamundaDocumentReference documentReference = + new CamundaDocumentReferenceImpl( + "theStoreId", + "977c5cbf-0f19-4a76-a8e1-60902216a07b", + new DocumentMetadata() { + @Override + public String getContentType() { + return "application/pdf"; + } + + @Override + public OffsetDateTime getExpiresAt() { + return null; + } + + @Override + public Long getSize() { + return 516554L; + } + + @Override + public String getFileName() { + return "theFileName.pdf"; + } + + @Override + public Map getCustomProperties() { + return Map.of("key", "value"); + } + }); + CamundaDocument doc = + new CamundaDocument( + documentReference.metadata(), documentReference, InMemoryDocumentStore.INSTANCE); var body = Map.of("order", Map.of("id", "123", "total", "100.00€")); - return new HttpCommonResult(200, headers, body); + return new HttpCommonResult(200, headers, body, doc); } } diff --git a/connectors/http/http-base/src/test/java/io/camunda/connector/http/base/DocumentOutboundContext.java b/connectors/http/http-base/src/test/java/io/camunda/connector/http/base/DocumentOutboundContext.java new file mode 100644 index 0000000000..50a3c48910 --- /dev/null +++ b/connectors/http/http-base/src/test/java/io/camunda/connector/http/base/DocumentOutboundContext.java @@ -0,0 +1,50 @@ +/* + * 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.http.base; + +import io.camunda.connector.api.outbound.JobContext; +import io.camunda.connector.api.outbound.OutboundConnectorContext; +import io.camunda.document.CamundaDocument; +import io.camunda.document.Document; +import io.camunda.document.store.DocumentCreationRequest; +import io.camunda.document.store.InMemoryDocumentStore; +import io.camunda.zeebe.client.impl.response.DocumentMetadataImpl; +import io.camunda.zeebe.client.protocol.rest.DocumentMetadata; + +public class DocumentOutboundContext implements OutboundConnectorContext { + + public static final InMemoryDocumentStore store = InMemoryDocumentStore.INSTANCE; + + @Override + public JobContext getJobContext() { + return null; + } + + @Override + public T bindVariables(Class cls) { + return null; + } + + @Override + public Document createDocument(DocumentCreationRequest request) { + var reference = store.createDocument(request); + var metadata = new DocumentMetadata(); + metadata.setContentType(reference.metadata().getContentType()); + metadata.setFileName(reference.metadata().getFileName()); + return new CamundaDocument(new DocumentMetadataImpl(metadata), reference, store); + } +} diff --git a/connectors/http/http-base/src/test/java/io/camunda/connector/http/base/HttpServiceTest.java b/connectors/http/http-base/src/test/java/io/camunda/connector/http/base/HttpServiceTest.java index 213216b3be..f4581d8af4 100644 --- a/connectors/http/http-base/src/test/java/io/camunda/connector/http/base/HttpServiceTest.java +++ b/connectors/http/http-base/src/test/java/io/camunda/connector/http/base/HttpServiceTest.java @@ -16,17 +16,7 @@ */ package io.camunda.connector.http.base; -import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; -import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; -import static com.github.tomakehurst.wiremock.client.WireMock.get; -import static com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath; -import static com.github.tomakehurst.wiremock.client.WireMock.ok; -import static com.github.tomakehurst.wiremock.client.WireMock.post; -import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; -import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; -import static com.github.tomakehurst.wiremock.client.WireMock.unauthorized; -import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; -import static com.github.tomakehurst.wiremock.client.WireMock.verify; +import static com.github.tomakehurst.wiremock.client.WireMock.*; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.anyString; @@ -45,8 +35,12 @@ import io.camunda.connector.http.base.model.HttpCommonRequest; import io.camunda.connector.http.base.model.HttpCommonResult; import io.camunda.connector.http.base.model.HttpMethod; +import io.camunda.document.reference.DocumentReference; +import io.camunda.document.store.InMemoryDocumentStore; import java.util.HashMap; import java.util.Map; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.HttpHeaders; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; @@ -67,6 +61,7 @@ public class HttpServiceTest { private final HttpService httpServiceWithoutCloudFunction = new HttpService(disabledCloudFunctionService); private final ObjectMapper objectMapper = ConnectorsObjectMapperSupplier.getCopy(); + private final InMemoryDocumentStore store = InMemoryDocumentStore.INSTANCE; @BeforeAll public static void setUp() { @@ -90,6 +85,47 @@ private void stubCloudFunction(WireMockRuntimeInfo wmRuntimeInfo) { CloudFunctionResponseTransformer.CLOUD_FUNCTION_TRANSFORMER))); } + @ParameterizedTest + @ValueSource(booleans = {true, false}) + public void shouldReturn200WithFileBody_whenGetFileRequest( + boolean cloudFunctionEnabled, WireMockRuntimeInfo wmRuntimeInfo) throws Exception { + stubCloudFunction(wmRuntimeInfo); + HttpService httpService = + cloudFunctionEnabled ? HttpServiceTest.this.httpService : httpServiceWithoutCloudFunction; + stubFor( + get("/download") + .willReturn( + ok().withHeader(HttpHeaders.CONTENT_TYPE, ContentType.IMAGE_JPEG.getMimeType()) + .withBodyFile("fileName.jpg"))); + + // given + HttpCommonRequest request = new HttpCommonRequest(); + request.setMethod(HttpMethod.GET); + request.setStoreResponse(true); + request.setUrl(getHostAndPort(wmRuntimeInfo) + "/download"); + + // when + HttpCommonResult result = + httpService.executeConnectorRequest(request, new DocumentOutboundContext()); + + // then + assertThat(result).isNotNull(); + assertThat(result.status()).isEqualTo(200); + assertThat(result.body()).isNull(); + assertThat(result.document()).isNotNull(); + var documentId = + ((DocumentReference.CamundaDocumentReference) (result.document().reference())).documentId(); + var content = store.getDocuments().get(documentId); + assertThat(content) + .isEqualTo(getClass().getResourceAsStream("/__files/fileName.jpg").readAllBytes()); + verify(getRequestedFor(urlEqualTo("/download"))); + if (cloudFunctionEnabled) { + verify( + postRequestedFor(urlEqualTo("/proxy")) + .withRequestBody(equalTo(objectMapper.writeValueAsString(request)))); + } + } + @ParameterizedTest @ValueSource(booleans = {true, false}) public void shouldReturn200WithBody_whenPostRequest( diff --git a/connectors/http/http-base/src/test/java/io/camunda/connector/http/base/client/apache/CustomApacheHttpClientTest.java b/connectors/http/http-base/src/test/java/io/camunda/connector/http/base/client/apache/CustomApacheHttpClientTest.java index a42c11f1da..c84c34c409 100644 --- a/connectors/http/http-base/src/test/java/io/camunda/connector/http/base/client/apache/CustomApacheHttpClientTest.java +++ b/connectors/http/http-base/src/test/java/io/camunda/connector/http/base/client/apache/CustomApacheHttpClientTest.java @@ -16,29 +16,7 @@ */ package io.camunda.connector.http.base.client.apache; -import static com.github.tomakehurst.wiremock.client.WireMock.aMultipart; -import static com.github.tomakehurst.wiremock.client.WireMock.and; -import static com.github.tomakehurst.wiremock.client.WireMock.any; -import static com.github.tomakehurst.wiremock.client.WireMock.containing; -import static com.github.tomakehurst.wiremock.client.WireMock.created; -import static com.github.tomakehurst.wiremock.client.WireMock.delete; -import static com.github.tomakehurst.wiremock.client.WireMock.deleteRequestedFor; -import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; -import static com.github.tomakehurst.wiremock.client.WireMock.get; -import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; -import static com.github.tomakehurst.wiremock.client.WireMock.noContent; -import static com.github.tomakehurst.wiremock.client.WireMock.notFound; -import static com.github.tomakehurst.wiremock.client.WireMock.ok; -import static com.github.tomakehurst.wiremock.client.WireMock.post; -import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; -import static com.github.tomakehurst.wiremock.client.WireMock.put; -import static com.github.tomakehurst.wiremock.client.WireMock.putRequestedFor; -import static com.github.tomakehurst.wiremock.client.WireMock.serverError; -import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; -import static com.github.tomakehurst.wiremock.client.WireMock.unauthorized; -import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; -import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; -import static com.github.tomakehurst.wiremock.client.WireMock.verify; +import static com.github.tomakehurst.wiremock.client.WireMock.*; import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -50,6 +28,8 @@ import com.github.tomakehurst.wiremock.matching.MultipartValuePatternBuilder; import io.camunda.connector.api.error.ConnectorException; import io.camunda.connector.api.json.ConnectorsObjectMapperSupplier; +import io.camunda.connector.http.base.DocumentOutboundContext; +import io.camunda.connector.http.base.ExecutionEnvironment; import io.camunda.connector.http.base.authentication.OAuthConstants; import io.camunda.connector.http.base.model.HttpCommonRequest; import io.camunda.connector.http.base.model.HttpCommonResult; @@ -60,9 +40,10 @@ import io.camunda.connector.http.base.model.auth.BearerAuthentication; import io.camunda.connector.http.base.model.auth.OAuthAuthentication; import io.camunda.document.CamundaDocument; -import io.camunda.document.store.CamundaDocumentStore; +import io.camunda.document.reference.DocumentReference; import io.camunda.document.store.DocumentCreationRequest; import io.camunda.document.store.InMemoryDocumentStore; +import java.io.IOException; import java.util.HashMap; import java.util.Map; import org.apache.commons.text.StringEscapeUtils; @@ -70,11 +51,7 @@ import org.apache.hc.core5.http.ContentType; import org.apache.hc.core5.http.HttpHeaders; import org.apache.hc.core5.http.HttpStatus; -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.*; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EnumSource; import org.junit.jupiter.params.provider.ValueSource; @@ -92,7 +69,47 @@ public class CustomApacheHttpClientTest { private final CustomApacheHttpClient customApacheHttpClient = CustomApacheHttpClient.getDefault(); private final ObjectMapper objectMapper = ConnectorsObjectMapperSupplier.getCopy(); - private final CamundaDocumentStore store = InMemoryDocumentStore.INSTANCE; + private final InMemoryDocumentStore store = InMemoryDocumentStore.INSTANCE; + + @BeforeEach + public void setUp() { + store.clear(); + } + + @Nested + class DocumentDownloadTests { + + @Test + public void shouldStoreDocument_whenStoreResponseEnabled(WireMockRuntimeInfo wmRuntimeInfo) + throws IOException { + stubFor( + get("/download") + .willReturn( + ok().withHeader(HttpHeaders.CONTENT_TYPE, ContentType.IMAGE_JPEG.getMimeType()) + .withBodyFile("fileName.jpg"))); + HttpCommonRequest request = new HttpCommonRequest(); + request.setMethod(HttpMethod.GET); + request.setStoreResponse(true); + request.setUrl(wmRuntimeInfo.getHttpBaseUrl() + "/download"); + HttpCommonResult result = + customApacheHttpClient.execute( + request, new ExecutionEnvironment.SelfManaged(new DocumentOutboundContext())); + assertThat(result).isNotNull(); + assertThat(result.status()).isEqualTo(200); + assertThat(result.headers().get(HttpHeaders.CONTENT_TYPE)) + .isEqualTo(ContentType.IMAGE_JPEG.getMimeType()); + assertThat(result.body()).isNull(); + var documents = store.getDocuments(); + assertThat(documents).hasSize(1); + var responseDocument = (CamundaDocument) result.document(); + DocumentReference.CamundaDocumentReference responseDocumentReference = + (DocumentReference.CamundaDocumentReference) responseDocument.reference(); + assertThat(responseDocumentReference).isNotNull(); + var documentContent = documents.get(responseDocumentReference.documentId()); + assertThat(documentContent) + .isEqualTo(getClass().getResourceAsStream("/__files/fileName.jpg").readAllBytes()); + } + } @Nested class DocumentUploadTests { @@ -133,7 +150,6 @@ public void shouldReturn201_whenUploadDocument(WireMockRuntimeInfo wmRuntimeInfo .withName("document") .withBody(equalTo("The content of this file")) .build())); - store.deleteDocument(ref); } } diff --git a/connectors/http/http-base/src/test/java/io/camunda/connector/http/base/client/apache/HttpCommonResultResponseHandlerTest.java b/connectors/http/http-base/src/test/java/io/camunda/connector/http/base/client/apache/HttpCommonResultResponseHandlerTest.java index eae4dd0c3f..e612211e0f 100644 --- a/connectors/http/http-base/src/test/java/io/camunda/connector/http/base/client/apache/HttpCommonResultResponseHandlerTest.java +++ b/connectors/http/http-base/src/test/java/io/camunda/connector/http/base/client/apache/HttpCommonResultResponseHandlerTest.java @@ -19,6 +19,7 @@ import static org.assertj.core.api.Assertions.assertThat; import io.camunda.connector.api.json.ConnectorsObjectMapperSupplier; +import io.camunda.connector.http.base.ExecutionEnvironment; import io.camunda.connector.http.base.model.ErrorResponse; import io.camunda.connector.http.base.model.HttpCommonResult; import java.util.Map; @@ -34,7 +35,7 @@ public class HttpCommonResultResponseHandlerTest { @Test public void shouldHandleJsonResponse_whenCloudFunctionDisabled() throws Exception { // given - HttpCommonResultResponseHandler handler = new HttpCommonResultResponseHandler(false); + HttpCommonResultResponseHandler handler = new HttpCommonResultResponseHandler(null, false); ClassicHttpResponse response = new BasicClassicHttpResponse(200); Header[] headers = new Header[] {new BasicHeader("Content-Type", "application/json")}; response.setHeaders(headers); @@ -54,7 +55,7 @@ public void shouldHandleJsonResponse_whenCloudFunctionDisabled() throws Exceptio @Test public void shouldHandleTextResponse_whenCloudFunctionDisabled() throws Exception { // given - HttpCommonResultResponseHandler handler = new HttpCommonResultResponseHandler(false); + HttpCommonResultResponseHandler handler = new HttpCommonResultResponseHandler(null, false); ClassicHttpResponse response = new BasicClassicHttpResponse(200); Header[] headers = new Header[] {new BasicHeader("Content-Type", "text/plain")}; response.setHeaders(headers); @@ -74,7 +75,8 @@ public void shouldHandleTextResponse_whenCloudFunctionDisabled() throws Exceptio @Test public void shouldHandleJsonResponse_whenCloudFunctionEnabled() throws Exception { // given - HttpCommonResultResponseHandler handler = new HttpCommonResultResponseHandler(true); + HttpCommonResultResponseHandler handler = + new HttpCommonResultResponseHandler(new ExecutionEnvironment.SaaSCluster(null), false); ClassicHttpResponse response = new BasicClassicHttpResponse(201); Header[] headers = new Header[] {new BasicHeader("Content-Type", "application/json")}; response.setHeaders(headers); @@ -98,7 +100,8 @@ public void shouldHandleJsonResponse_whenCloudFunctionEnabled() throws Exception @Test public void shouldHandleError_whenCloudFunctionEnabled() throws Exception { // given - HttpCommonResultResponseHandler handler = new HttpCommonResultResponseHandler(true); + HttpCommonResultResponseHandler handler = + new HttpCommonResultResponseHandler(new ExecutionEnvironment.SaaSCluster(null), false); ClassicHttpResponse response = new BasicClassicHttpResponse(500); Header[] headers = new Header[] { @@ -126,7 +129,8 @@ public void shouldHandleError_whenCloudFunctionEnabled() throws Exception { @Test public void shouldHandleJsonAsTextResponse_whenCloudFunctionEnabled() throws Exception { // given - HttpCommonResultResponseHandler handler = new HttpCommonResultResponseHandler(true); + HttpCommonResultResponseHandler handler = + new HttpCommonResultResponseHandler(new ExecutionEnvironment.SaaSCluster(null), false); ClassicHttpResponse response = new BasicClassicHttpResponse(201); Header[] headers = new Header[] {new BasicHeader("Content-Type", "application/json")}; response.setHeaders(headers); diff --git a/connectors/http/http-base/src/test/java/io/camunda/connector/http/base/cloudfunction/CloudFunctionResponseTransformer.java b/connectors/http/http-base/src/test/java/io/camunda/connector/http/base/cloudfunction/CloudFunctionResponseTransformer.java index f6961cda9e..1e2ea658fa 100644 --- a/connectors/http/http-base/src/test/java/io/camunda/connector/http/base/cloudfunction/CloudFunctionResponseTransformer.java +++ b/connectors/http/http-base/src/test/java/io/camunda/connector/http/base/cloudfunction/CloudFunctionResponseTransformer.java @@ -16,6 +16,9 @@ */ package io.camunda.connector.http.base.cloudfunction; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + import com.fasterxml.jackson.core.JsonProcessingException; import com.github.tomakehurst.wiremock.extension.ResponseTransformerV2; import com.github.tomakehurst.wiremock.http.HttpHeader; @@ -24,16 +27,21 @@ import com.github.tomakehurst.wiremock.stubbing.ServeEvent; import io.camunda.connector.api.error.ConnectorException; import io.camunda.connector.api.json.ConnectorsObjectMapperSupplier; +import io.camunda.connector.http.base.DocumentOutboundContext; import io.camunda.connector.http.base.HttpService; import io.camunda.connector.http.base.model.ErrorResponse; import io.camunda.connector.http.base.model.HttpCommonRequest; +import io.camunda.connector.http.base.model.HttpCommonResult; public class CloudFunctionResponseTransformer implements ResponseTransformerV2 { public static final String CLOUD_FUNCTION_TRANSFORMER = "cloud-function-transformer"; - private final HttpService httpService = new HttpService(); + private final CloudFunctionService cloudFunctionService = mock(CloudFunctionService.class); + private final HttpService httpService = new HttpService(cloudFunctionService); - public CloudFunctionResponseTransformer() {} + public CloudFunctionResponseTransformer() { + when(cloudFunctionService.isRunningInCloudFunction()).thenReturn(true); + } @Override public Response transform(Response response, ServeEvent serveEvent) { @@ -41,12 +49,16 @@ public Response transform(Response response, ServeEvent serveEvent) { try { HttpCommonRequest request = ConnectorsObjectMapperSupplier.getCopy().readValue(body, HttpCommonRequest.class); + HttpCommonResult value = + httpService.executeConnectorRequest(request, new DocumentOutboundContext()); return Response.Builder.like(response) .but() .status(200) .body( ConnectorsObjectMapperSupplier.getCopy() - .writeValueAsString(httpService.executeConnectorRequest(request))) + .writeValueAsString( + new HttpCommonResult( + value.status(), value.headers(), value.body(), value.reason(), null))) .build(); } catch (ConnectorException e) { try { diff --git a/connectors/http/http-base/src/test/java/io/camunda/connector/http/base/exception/ConnectorExceptionMapperTest.java b/connectors/http/http-base/src/test/java/io/camunda/connector/http/base/exception/ConnectorExceptionMapperTest.java index 39cb5b7eaa..5d9cbb17cb 100644 --- a/connectors/http/http-base/src/test/java/io/camunda/connector/http/base/exception/ConnectorExceptionMapperTest.java +++ b/connectors/http/http-base/src/test/java/io/camunda/connector/http/base/exception/ConnectorExceptionMapperTest.java @@ -28,7 +28,7 @@ public class ConnectorExceptionMapperTest { @Test public void shouldMapResultToException_whehOnlyStatusCode() { // given - HttpCommonResult result = new HttpCommonResult(200, null, null, null); + HttpCommonResult result = new HttpCommonResult(200, null, null, null, null); // when var exception = ConnectorExceptionMapper.from(result); @@ -66,7 +66,7 @@ public void shouldMapResultToException_whenStatusCodeAndHeaders() { // given HttpCommonResult result = new HttpCommonResult( - 200, Map.of("Content-Type", "text/plain", "X-Custom", "value"), null, null); + 200, Map.of("Content-Type", "text/plain", "X-Custom", "value"), null, null, null); // when var exception = ConnectorExceptionMapper.from(result); @@ -84,7 +84,7 @@ public void shouldMapResultToException_whenStatusCodeAndHeaders() { @Test public void shouldMapResultToException_whenStatusCodeAndBody() { // given - HttpCommonResult result = new HttpCommonResult(400, null, "text", null); + HttpCommonResult result = new HttpCommonResult(400, null, "text", null, null); // when var exception = ConnectorExceptionMapper.from(result); @@ -122,7 +122,7 @@ public void shouldMapResultToException_whenStatusCodeAndBodyAndHeaders() { // given HttpCommonResult result = new HttpCommonResult( - 400, Map.of("Content-Type", "text/plain", "X-Custom", "value"), "text", null); + 400, Map.of("Content-Type", "text/plain", "X-Custom", "value"), "text", null, null); // when var exception = ConnectorExceptionMapper.from(result); diff --git a/connectors/http/http-base/src/test/resources/__files/fileName.jpg b/connectors/http/http-base/src/test/resources/__files/fileName.jpg new file mode 100644 index 0000000000000000000000000000000000000000..3d9a00865cef5170978380b8b060090d37ce674b GIT binary patch literal 59151 zcmeFZ1yq#X_dg0l3Mwc90tzT84HD8rgMf6W(yeq2sUjr}l7ob_bT>+ONl8f$9YYLt zpLt*V`~BbF{ja;$U3cAeS!-q<&OC9>KKtzb*`K}71S&p}x`soFgMxx`O-5Q=83hF$ z8wCXvgpCQb6tP?EqoAPbScr)!%7}?kD%#tbT3DN)pwLIyge%B`ly0^5XFSc65bqAs zk_eXRy4saYqPh4i;p$^;&bOC-gZg%~qUOUq*nZe)$rSG;s)D#}CNU%iu&}i;Z)V6H z@y2KiRwYpc?6bt7Il@;%;`LElBYVq_{pyP2BO$Cip91tlp6c^35vh%$OY9UR7apR> zte$6#JKd@l=yX#?D~uRMOgJtr+chk}O-f=#w6m^|hD`Zn;OAj+6L?As^sLc;Gir8= zWAqzlG2nVA!z}43C18H*;Og$f;z-;5eH&fQqtHUK&v0LB3rMKr8&mWvpYIu62_BIC zr1{?KqCX(<B@tZ+*l6VBV6pGJ9TLw@1ZR`M79w zFH!-lhHMW+MS$rQHs-BrJ*46t`_xkhH)9Dv8lBfoR=Fi(ts-7odI<3A> z9`k_G+;}<=-=ducNIS?xQ^r(Y9)$ro#zsL$B}Ksij!=P@5GvWvV@Xtc6tsWZK`1Bz z7AWZ7&v^oTBLBjH7xJ2aKhYvzqhJC5-2`5)sh}TcqhqI{{Wt~%0pFpBsEEnP0G}#G z_9iB_4(4`__@@d!K*N>i(pnBEC`2^K7pjah^$u|VF$+~qM@@M-J|jCDmZ!#c&rDcc zZJq;Xqo4@5@&Si7CXP=jU2Uvw9r#=Y@BecKA8?FpX1!1O&nb>pg7-D$6)DB+>`f@S zS=d6mxnBN_GYZ?yu7@uY#gi{ z9L&HO%nok0j!#{gZ5^ooy~&UJh?_VV*;_n!w6L?KMBex5Gdm|o!Ta}-5BmA{Z$C|3 zEq*=8*5UiGfB~{1zhPx(VPpMyZ{Sh^WGkPdg{z6RmbirtAT!_@Lfl+z0{@)4n<)O3^FOVC zpoMS*Sbt8M5RRQrUJo#jcPzveRDn-G%aDJl62Ke%zn{os{j=VycEKnpU=$f~5mi^z zjcKf}BjOjwyM;+0^0c%Tw;<`L`vR$cLrd?amE4tr(v;Ep?@LQXr(uKAlre5)(ko+N zWH3zIxA@cDmq#a54C*+?7dV7=o)c7>@JyI_2vrkTcJ>%^?Dq+6E!ReMVvyV1AVS9n zqoDr&_Po3a107oza3*?e2%zqN9r+Op5t{4t7u`UG?t??1at_1{{3Xq%WZ>#qGR zConL=?LQ>(4tn}Mp?vNbi@_Tde*SCyAOX}^9riMKD&2(;{Is5r9bDLo!gnGV3O_pifT`# zfL8E#VdFh1rR z>%Tkol@jReHM4l3`+rFmU(yFfy@}1tMi3(JZ zE>HPbt8-koy+0*N?ZH{T@kZu?S$}8lEiZMB!=c^M6=O9VVMY>tM zvkhLYn3n#>ZTjxHr#Ap|{cJngR*7Ns5{0S8Bwo&&8Gtc!X2G2$_+GJ`6&yRIt;Sl| z!m^|~B*zoaN|;_kX+jIGo>{YN5w-M3wy9%!RlH-uZJlDJ1RG9paQw&eqO`%l#?RQy z$u#U@BOJ;*2|8CDHKtc5@6D)VU`nL|90!p``qjdAHcHc5WN^p2ZR_xn*f2oS0 zfe(r=gz@p;DimG;+-c}=WQhH5t=quGr!2CQkomiPNF9*=WQsQ5>%Ufr4+K~thXwS3 zf3<@raquanr&MIy{}xFRE1=j7gfxHaue1(uCkqeDwcCHMsY5`7rgc04#!*6V#DBzHvQz4ND-GN0-g9f?c{8*J;t{Gx zRA4Ze`tuaOr+V%s>dWo-c2^MV>+}EnW=v?t3l$YKyIl0@FEOKlZ1E|sOTspcG_%9FYHzP6(iQY+z%mzqDu$6POs=N8JZE{#HCTK#f{ZB0~S_ zln)tT1gqwmhNb>iqZYslVxx(3|2>ocUmpZMK4nK&*N{tRM+bIkUcEK!e=tpaUWZBf zkLS&3j9BjqdpuFk+$oJLj(hvd;wd-@gV52%H|mF(A7>&cd?YHFqSgxkn`xRilQCjM z6V{Q7eYI1XQ5@q7Wv1;GJ=Pi;?`rBNiZXfcdI|s~CLKx4zb6Iz|F=Yci{$?iOZ30* z7En?B^7EM>+aV!WPs?A@%RqkGpc`kRbK@N?{g}#c?jOHuDvo#sy_SJ5vGYF}w@8>5 zIyUy3m#!WG{g2_oK0AU3%p%^9{}EvY3^Si41)apU_m6#ABl3FE>e=0n^o?^?OhJ*<2R|BjdOmui`&Eo zOmPNY@zJaX`dhZs%JwULcgw6fsJ-B$+-|#>O3L-xYHk~Y0R)p7bSWG6q~4N`PVFeh zJyEVzH|;nS;6HXdaUOGVj~`5z?BE*HB`B#=TmB%QxD=yd5XTSu68q#X#lZvTow<7t zG&Ue)HByd2DYDan0T!;8IU|xg+9J zRsngK)QGTA_1cHMy|%?G_M>VVBr-Da8s|;AJ#c&9P`(8L3BReT=#;aE>0RFEb6EA) z;IPq{PJ(uwij^?~@0ckDjkwE;qel1m7)GJUZ>_;vv3N1X#+?Cs;{?;0kkbkd>kbF^ z!CCK1J;WgeJ>8R}=*rmy)2$RaeaUp^?HMY;lr0+g`dE|{#hNMge$emm;tCOfb>|51 z!#p;Z)^sJ6Ecv*5Vg}**f*$ykY>KWDzvM}I1pqzN%`$JC2wE_(Ll!9b z9HlKs@~JGNn=8B_rf$Vn(JrTXHz=zX-q!5xwll(ST(dM5D_Yv}nCxsgpOsB%ywp4N zzJz=Qp*K%Pu5yCV1BKA;Q2c^izH_IP2g{zBIALOXzeOQ7lUS`FdeCzh^Yzs|XJl$Vg# zGYVUGUccCFr!eVFa8j<4eww3R9+W)~)2XoLsyt^D@;J=zN#<98YP>h;UN)^j5PFZo zxJr!L$MzP&gx5Zu=c(pUlbCcTn6wn!M)iA97(&EF;fr%iF8-ElqVHb5cEJaM=a-O@ zttbVP?HPy=s$T&V!ZK^&K%iQvB>$0BwPU5oa&|QDO9%W_;n<3vV*EwrZR?pj3853m z`Vc6a%yzr~kxRJftB+BnT)9?|eYo>3NWw^+*39#%Jc)cv3#V;v+QF?$u}>&pzfouo$uww zjj_!2)=_vPI)^_@L)>d%>(^+ySJ!h>o_;boNaA)TEK3P-|R7iAal2chytsV8Ubo<~U z4AIFXxg_^md?DCBD@$|z?xCOlR%p(!0Lc=gYtSvmwd!h78bNuIx|1(ch=!TVO7~vv z{(_j?1UT7>%H2Zy(J8^%jk7OIAE!&w;ykjas8URswG&TOtIC6<$lgD*C9{E8wec@E zHuo%*TQ&}|0W=6#uT~&ykCYkm>bj^Q~65xrgmuU=7;XY^X-_BC- zC^HBYT>k=jrqAhh(MoK30`(T!$Wbo%pzno9e0UIS>NNPs3FcVuMM$k^GV-l#%aFLP zt+jz$x!Kykc8E-J0h7>G5eUh=D%6E3YP` z6_DkD%WR?*bz&jxYhK-$KGg6-vET0wAP}HU!jB#B_lW>*&Pj=A14^nsQ$CfR693T5 zqc4e$V4!;st!CecCG0fx z&W(<>t-~#j&eqkaReTIvK6f_PEaf;zp+U-gj#y7uF3waaxV|-hI+~>zIL)%9V<*~1 zX)bJkOVYW_hzg4GBm6Bij(qGRgR>y8B6k1!ilEGVfGp9P{PBLjutcr5f#h3K=I+?S zaGc7o_Y2>W-DR7fvuix3)kXnd99A}VhGxoQ*<8%jdn_GzUr+@T^+lDK*xuT7Mpp{E z=$<^37}%3c8?Nb&>kcos5{|&)b0&h9`4k&TlBfl8TZ)+WyD79a3O(7f9tSIo*~Sb| z%vL$~wl6PwPuko>RZM~D9!)r|{xs~20P%6vwWyjXJ?I*09eh!SX zA7-xX%8b)zm}*3P9iR4GdVoob!T6g^r;Y(Qj|{%M#TFsJdQZ;`hFnj(Rv*_+zRg0x ztizJ|I40U{yD?8VsGq(}Bk_fS-pF!avQDDK1tt~ms#Rk{J)Dm4t9o?;x%qgNm(74Z zQre7aHN70*RMTaM3HG&J|~p4qqFa&al7)_vD~nC5FxS2PM5$myky1aXgu< zY`LY{QuRsz<~96mI*xpuT8>mN5IQO~^>6k#{0>NlE5*0%vv^ZgYZL4N<|OM0LhiuQ zygtEg#$%9NEqhi!)wH&dwuS6=#;fT1`OmKqFlgvD=t@Ak6#Ub2RSW5pN^;)09r zsqLPT{Tks>mHxfLsagobyHu8x`t|FCl!`|qc?*38MT*d?it^l5PV4H>BZsh*{#BEa zbS!^n%Zl@RVgXn-wG7+S4Th+G#f5dbV~ys!ScoKaOCikdR$*`|Y<<^}6#{jciC@xF zHSm7%Sz2&=upP>|e@5Yu%@KkkGQ3>ii6HCgewU$Gk6>k}N=wmv9|{^QRXq)9*Reg{ zaJc-I0w?2!ZK@FHk;-PH_~zPm3_L{$4Zh15IQ6{U-rvUvH<9W;+NSd0I=8dylRCV$ z`>8(=Wt;fF=VLU2QGA`7(`IfBYDex~GX0Xiv>o0)NJ1NxB{w>(Q&A)3 zU6&m3QQza@lI~9S6-3Alw6;Yb%NAj->g3|=K@6CNxzp8!F{{g(`ty;V%iMgI>1Gz! zP{e5AghaX20SRhQN^O)WxR*^~9vF|#cBxsdS}^QPnc6a?^jm(xXOlsS4*0GVn)0NYD{o&T1QGJ2Jdprk{!%*n z@--l&O$|$%x4tbyHEOazND?L#F^zzeQA@qOv$uS}lc7DzaLHIWT{=_eX4N@aECyZt zxU1*VB~xI#2~sHww$D+ijjdxhNz;AajDdtaQCn+R=a-jbISv|wte;J?7=g95O^LC$ zGBzEZJn7x0Gc3N8XYyH=wfkjY6m_He2eob@<&U~rgOX9J`|3#}wA2UYmnx8BSa<*8 zUN7GhjG7PF55sc-!#XqF=Lp;H`MC~aUJAk5 z0vj{8q4g*|rtm(SS88<3dwR;yf1LGtz;3c^g2^mD`vfE9?pa-y@!HkWd_;`awb{$_ z1B>)`7qE;i!^Srr8Xx2`8>keaM{IT#l zi;dbd44bW%x)=+uPGE4MU0j^c20KSQkO(G<(5<%DignShG?BQ@QFi6%pk0uNKHOlW z=M1KCDbCV(wikTyuJtC3>`TusKQ*$L`ThXBZo-p2Ep0L?Y<*I8U4ZX+{3Mw#6FM9@ z$u0&r2vt^G+4~mE8ruTIeH0Lj(d+zcD}BjP2Atkp9QQ^DXf{S9NPfde_#Hq_$8Up7 z84dJ+W{!eVI;U+|^Y`!H!~@^I8&qZobv^)~F>gO^f#|Q{QVs&a^GyimBO%ZWfI+}F zdwxCO7pepY0aS=|1cAiE8*gHpoHwta&u$`~FsRl^;8-a<|3hr3=+VfM0Ic6I#J^ew z_VGFTb;ln^Y<~Z()g1KoS<}?7-u*bj#@`|T4=M{)5@?kM`C{M_cjjw3i1&Vk(kYO; zZH^Wzmp@*jHrH}YU|XG7F&=ZNHQ88ILax*f^-L^0@<>07!d~^e8zDbB^G`?73Fe6W z4H+_8<>$ItFKG(4E$A-Kx4oC38V1Z7x^AyRydg=t2HA>VC-K+O%FUg^vb{cP)r$t* zV!1Oo?*%O5wJGn^q(?85XS}Sm#hr=>{@}>*9|L(p`n!A2Sav4Y35-X&jWPhd zLTc&uS12NUDQbw9zeWQ_Tmh!7(IMfcAmTfNQL^e!Q{uF4Hng1bB~qy(Kbk>Lnc=H_ z@I(N!PDK|H^JNpBuynIkVN6I!3DRC6&7~wF&ctEu>HXUKm=fM5d-XP88P%^K#Z^s#!G)Zr5I8d+xql~ktFdMt;<9+@d(&qP*S zego-H+;UF|5E5lI^hf%LgDn~)597SKO-xwD77GN@9pmij88Qo!l95a66QyRE^0lW2 z)tIX9aW4;fPE-;#-m9C+pR8p;jaMpbJ(nqgP(50J-}OxofSC%1>Fw^MF8CjHr~015 zduJes?%M5Mu9AZ>elqTX6-z}jt1lm;fabMxjj0)@^L^-L^xE(T>6}O!RU+I!6w&a( z_naFpkkHlKTBPQbEVbUCf)9dVap$jaXNa9{6cQ0qju)Kc6neGVjh4KM ze&CS4G23X6z+-hE3OTG65I(?A62XLbpO#L(a#Q-hdNE%kF+Oj-kK&}re%-Suaon|neXV}@;FiCsl zbloB*Z)7Kl*MSzL$gR5;ex_AA%e4>VsghL~mc0AU z!ct$O;X{-$L2g&1++sb>6QC@2wJ;HrxQBy8CRyA%vS6ZuOp|EqeXgf>>%V@MQSn>WXsnM-u|vlW5zSJ#Y}e#6 zD33rP-*iT;KRe(td$*>J3Q_}O+!^t@=teRT>kqsgBrcgM{-_o3Me+5~xtfIMZit6| zpZGT0^P;+k4zYC?lZ^Ib$=BdP$S{Y9MVl##wvGk9K6^v4ni~fUgl^e*jT)bJM$>HB z6^~B5%N&cHPr(nM5cLB6^nQ`lWi-X31rJs#snFQ5c7^B_sE0b=*RdIXS?$?rbs>Nz z)q?MGJW$WiR!BLLY`j2|IHfTFNSoLDr>sa?KxovKXQ!bR9HZpGkgsmy(QmIp>MBq;7U#3II=j;hKz&m>P>3r6km5j&s3v8lb*77~<;j&6cB2S^Q&8cW% zz~!IyA^8ANuh|(+PV8$4Fi3^xS?fF<8}Sdt<)v2}&(8Lr=IEBc?mt73^f4T6Q&

d0~5Q(5h5;h;hI?|XjC(jt>2qFF2(b)s=-Jkzl-eyt#6psORd~;=c6BU=+hLq(!g8aEfMX~Ye3IC{W#Ad{2`{09){*4}ggUG+(91AA4Fbl1u zG?#5S{w6DEFby(9M!>=s2T>8m4WyN~kNXu;aq2ktOzJ;Y&c<8B@~CT<+orErG`5k+ zh!{wPxk<2Q30hE^drdhXt(^duHj?u%chVa)t5lg^=`WgRbsyP8n=}VG%pzICrSia= zy_|VES0(EOBv})+*01;PEm3%sN%19|-VyQ??`Mp9Q5xTd^QSSU0m5!|j*NB*N=+ZI z-t9b@b9cdE9&@rX9nirU5cIt}3A~DL9`aWr&)%LCB?Qq!0JV$L)R=;CbFyqB6r4(JRV#C=U29>#z8Oif>_aWBKyUB)O~vagXRD9kIpi z$I!3Y(<+VLmq(K?UzaY8c&s?%2m&w zP87XJXVThxuj@3bW`D4%s0t7(wKj&W^hv=5Kho( zNB@>d{3jZqltr<{ak?m-hO|;(s8lTUTv() z;f9tTkf;t`j1#LOTuF_v<*1i=VY-IIbxxH|V}z1mc0-%vJIq_BLD_lsA?i z(;6%sjwh^b>y;I(hG)Or^T35WpH)7AGj_6^!^r72OoEaYn964@k27=b@0<`DbWN78 z8=t=p-5b@IN*mfpv}Ir&%~g-lj<1w$4I)fari=N-r92`~Ovji>*nQiRaQ<;4bVwT2 zj2ZtyZYY!a*daO6B;AuDd{hv(hy58D4CWi>@K|6RZcWqe6Ej8Q328FSQar*Rb|zv_ zl_uq>QPHk2|KJV&d|;^h_Ge_5iYxc-k=6&fc!u`36bd*I$fd-h$JhC3n*4Fl;Q%)J zcNd=G3__X}675@V6MKYL)^z+4@C<(Uq|A1Ku{L~SyDR@sa4}7 ziCZtzQK=O^sXAOx+Bdc)w-E&cu{W)>2dWcU^YIk|6Cjv-da9lF`I_<*4?n3KWtt4> z`TO?0<(JWUA8Q`7^d@SIoEuvzIovpNhDDzNi+^w7J=A#i0#c%qc)~54*a(pLv#$hS z*AQ<;1k~D}ifBH-`64=NprE!UC}*} zhRD4rMiSJnkQ5HYVOB@Uy~P_bHLDtp8+pNpmZN!(y_j!@Cog3aagG;xrDq2|ACbl| zdiKfCDfe{H9qy^Uw^S>j$!+n=OmL$w_qZuwUh@)KJYpdm{apVBL5?_^6B+u@JN-hK z8%w+im@nU$t7{LKiwx)Qlkja|oWRG7L{628TbPGxoMbJ&lm#vVPQW~VgBDpbQ1X26 zSvO19F~=2*s$Ric=O+l;-5JlA67Nq^|04;b6`0u8gXN9&6)=Bx41o|?l+tHtF+ z+aaj5TAilS1I?n;#eS#(OFd`diJ%gs@N~0M1?j_hU3XG9W=b_Y_C|U3SZyNL#R9xI z*Mp2RA+_8b)~ovw8(l)!;(CCFjmYG%4T8=QcqMnZ6d6lDIKqy7R15W)KlbW2dil#G zAAZOmOnHZIt(1P{H6GbbreMxrR!Z2HO6qM_)U-#(hhCjC`wbiQnoad;phP25Yv1{S ze%-tJWTwZPGxbtmKA!%A@~kHi@e(-$oygE%qwQ@h2xBjRMVbY~j%1aL40FSvKJI+Y z!-CL3s_F}=-VS=kX{f6%Y$Rt1N;tNu1{6AszbJgH{^hODLT)?NTXJ~hVVVDZRJY#} zFlP#eaoj5^l~UxxM+hddrU_X)<9`BNtuTBCo9~2DgO*z3WEcmWbVh)zQ)b0fcmAHaTl;Oudj-TF4zP zzSIQ9a-Hk?5GN)DKJ4&&s6YGz{sa&Zt&L>n0f6o^cL2^+$Ms1G3W)>&z)qc-WX%DY z)`P)x0y_B@x9E@Cg>gDY9zc`|T#2Z%uawx4y zG&7@Ez}48jrS6jqy6(0Ln<2@C&;)?Nm-2Z&t(!wq)3IeReLES z)$EH!zDlVec=A%$c_Qf9 z*-TDbomK#~MUOiH79!(uOqWb^rev*Bb{Bldo1nquFz9JuWlfoY5!Gj`{Oj4BYA(zrw-F;r=nk`qy`Nvr(C2?I=Jv@$ zPZH>Q?sIs&+Luf&X9+c&J;^8Sp~5dPmJUPaKs7s+?c^{LD$Vcil=-P%zkXY@S%__9 z_!?0k)auqe;Sbe#9@><|yDbCtLbHauBptqMqLn9|U`SjQ4JKxdpet+VoYd04BcwW6 zRz3AZsf#k)2H^50Xfym=aSzsorA>QN797S^>k68((DccgZFv;MmVhPsO)W_6$C9M1 z$3PlKI2mVTsagN)&DD%6g)#uxYl)Tp0P2n_4F8BHOjIGm>#!WX)Ez4w(emn3Y$gh5 z0tsdGL=MC9n*3e`FXoWfd%n$~rM|?JObEa@#JV6Bbw)DpReS2NTEU9 zutw6a60;`6IYtc_kAVe?i)BiQgu=k8`#18>T;mtJUEG}W%)Ea*gE9wWxz~VF;b`M- zMQ9G&3b^VCdTlG`xDaa?={;s0PP|wNQjikC_bmu90AnS-L^%>CD0z0VhwE(BoQAP2 zd$PB5!LV5Ad~xO`NCRYrym%vQ+DaR2rYdBS`9MuLU;aG zhkhrc*;+bY$Mc3AHItT=h7I_OL_Mxv2DlU8;2y)x^)c^MXIz`rEHYgeJ&_Yfy}36* zhF(Mg6)|!UH;225TtJL@#nl z2FMa)+?62R-nP0&MoRrq^K^s?f4eR(2UwbB;er>BVL-`^Th)}9H;uVfjq#XU!Ep(uoCNl;p#^ErD-s~759RnSqT=X(~JH&iG0F@`20W-JwJoj=HV7}@i= z#5a70#XhnaeaZZEF+f$LQF~axt#^VBuT^uDd5z;$yT6uMl$P1d-ZL&O?1)pSu-0cK z)^k+qus6PskA&mWOXIAzAhPmH&5HfguqC9Oe3GpF!bOh}IFb8|ml49{%RjgpBqwr$al>yhz_z}mX9 zv@++6USkpFt@S|=vrW^|Pjx6xa=oP;1 zW05LZPqduV%?Hy;+?P1IJvs0@-U0*NSS_~e`3L2duYWn>?c;y=K^7p;Pj%-0&&rPRTFkMOgyA&_BMEFYtL7oZrmpCf~KR-Ya0 zwXK8}A0LmnH@fQUwEiW~Wq?v&1jHqio{&GNN$zp*bnWCorH!`m2QmUb#&z_DS!h+Q zqIC;p528VBCMW@3u!1S-ItfrF8ll6ehRYk2sx{ok;oQ@`HlT+HHwLVJl$srje)4{7@0d0VJfF)ODM=oKGJu zKi(EP@3jQVAEmz(=vmm7{;Y7y$n{A`Dw1pKwFkg|H;d!uqy*w{%OoE9goHKZ_Jb%| zty$&7hh|Ss_|^^N)Zaa!6O=)S`cTR|LGQ)Bwc}m(9)b7tO9d;n#z$S>SLyHUn=y!2 z%X3nFYIb;tj7EH1ikkSPQ^^$wx|hO)FTz9i2t9=!i3#qp#oD~L{b{WdK$Z;;xVBZZ zO_qYfGF*3`y${NepV^xB$gr({eeX1VIbC^QxtJ1P9#Fu+k&KVoILEX40~s%NxwP7^kT+Igi6a)1K?72<+tmNs~DXt{}Oc#cMgSY0n(SFrHNk9Do5i28* z_{+_uujkg*`~45I!yoFAfNSaP=2UsaQ!!{|4;`AXG{E19r?m;axMBV!t=tnRp5E6q zk_w9iB>ZlwSd$%uKC_Q=n+oSF%nThrL<#+Z>iSu?l#$1LOY2a*ltO7PGP=QC>F27{ z`WgKzE2X|YJn$2|m!dz{CUyGCW6_9x(*iTkH9>@-VLGa&7npCvK~?38ECuM7ZLxi# zW9xOvPVZ5*85t!59+tyU6yGCk)JuO7D$I~nQ2`LTl=q2*ZGAzmg38FbncLptu8a}d z4<{;32%yfeE}O{=&-X+;)B1*w#;bb`^SCXHe9ulo^;{(=rvUvbk(b6O#viW&3=BYj z<=2P@k{P#_WG*}BtDm8vx3zi~EzsNtr%oyrHtUsGjk%acnf4~#Q_EB3{(W3BlwTLF z71ke;PVB5rs=|~89Yz;)d4#7scH|%!XV#0TPoAM+aj#X~q>dZPF z*%7P>Q^ndyai*iTOg}S3zfpG-sCKGa4-{TR9If>7;ITgk3$KmrqB^0SvM8HcpNTU0^JJ-un948NJgTG7oS<(Ke*RKP{w8b3$w}M z8{(mOpN3W~052ys^Ck)U0(OZWuup8b3dtNYu#uB>8IY-Dv_>5o`V8?IgWkDLrET&> z?scn0V0SN7D*h5JhH9ndC}71i^WydE-MQS%+8qBB1>jv{Y=>-F%e84O!uIpFhuTk= z$H*_oxjrvY@3Hl z>>-psN4p_shHk+4%P)HSl81Ks8dH`P>s()GRkygWYUNLg)c-VToailr&4z1OxS4YC zWa%%mqw^Nl3on^nxNJ=)Y*UO4WyB<30uR0a(x`GAM>Shh%S+I?M?hL2#n6ykPKVtO zg+YG$NvaH7vslpR-wwbHS3|vmp`hie5nbmGM~}#I&V~&tXJ56 zF@>?t^~EE??hK~vC{7@*)JiwEqo9!2A%6=1PeV6Cq2bIkIP>oBGf%I@Cw6hXfVZzX z>lH;)ZNJ1(xlnx5(iU}_Ql!q7nGKPz8y!|mPrUT$mzhih2P&H^ZA_HL%Fmu`Zz8$% z?lZ@}M-Iy&(KW?MCd3W^=TYmtRRkCe)GT16NkK|NEFg)Xm|NSp+YpZi!UaD;@@qu| zC~T7i+e+wN5+}EVEd%zR{$>%HE$6yMGT2QH5TzaC!BtjrQMU?Bi{P|p|88TBuh zUV%Lw$Lqvh8+TqJO48}RsW;O0YD>_7p93K69DT7Pf@(ET(HF(&o5;4$%hA?G7s!kV zIw3V1ObLs_R%)k#>z*cB-8yb81yk>(f6J+VbHO(8g$LnA@=c-8BU#sUMVcMlOadXi z#vRG`kLQRV(|MA6&4-G{ys0oKLqm`7Y{VIz75}nwS>NB74D*=#_G){*)01Lvthn;- z=8;40*uVR18t()v7bDQjom_M_iToR1CrG#l`;K}K)U$4dek4H!Z3|HK(k~c5Z%DK4 z`PaWDeL81gP;`Di46BW5(QH?)#G1e^jn$Hptg+<83#OAOp41pBi&gGHYR`tq({PrM-L0*t(P;+C%B>LJ>7xk<8mmS}CviPdr zG>jBoFKwlM^@V%!GjeOZwQdeqBFJf-YlPgPsy&}ZJB(MAc(dGcbZGsW^PnxThhix{ zbXP~oiL~<8u?g?>N5|U~*8Dr{0j*{2cf4-p)BmJQz(cq@@5I(sJ{w&xu;~a41#r%I zm9iB##@6OeYQ8~ZbG`{IT1Gjm?~-MsS3H1mrWw^qq|vmOv=o!@_D+gelZ;POzPOGO z{B|>FK`oOc{py*W*3Qh>79v;8DnljiqvhQ}nz1HN%DOlYVVY0C8}gq480WSfcBF9kYX9XD7tm!o`rQ~)&aq?Mk6*f(c* zCK{-6Ol-Ft&3zT(zE&9g?BS>8pqlcgs=H0M1r2qpPBo0s@l|Uk>ApVuS}|wPm)(v{ zDSp#)r?7P8A!HG#Hc#vX>J*}Y4NYu+I5T9OHOIG1rC51e$FbzS$vh+G{bAmP;&x~> zjckD(&E|LXza|Hs{l)~u%YAcq`cj%CZQg0|+f>CDN7KLX&)2LN84p<<#m2wg_>GKs zvzb8!72S!QB3G{H{A~DaJrJl<_wuh@1PHwNfP~CzjCr}&SdD(O3*Rv+Tw<$yOrMHO zNG~x+uiH5>Z?q#C;Hb*-%_c`Wk+mg%`nN|AL|Jra)x?S>)MlFgq_C!ouDA2Gw+}e< zY7t^-_OfNB^F>`ZVk`>S-E&EBb)>kV4-p5jyX~h%KZ1A^&;zRHi>pf21DF-&O0>BH^^-lW(fLZiyY-)fnmUzw)ehJ<)-b8eS7j}f!zGEI9Na0bshs<>%A z^8Q%e`!l5%9t4_CF=k8kHn0)O#ZoD@x>0!oY!NAU%gSFNrdKb^&eyJH`i7Xe;cEyO zUca31*jpeV4IBZaf!U^p{(*@>aB5~#kzhh9aB4}Y!Qr{ojci<3La<`tjGLrea4y`K z3Okj%L`5OnNMP3(PwTAtW@$%ym;h6~yO7E6XgLjhiRy2>oX;C8U+6ehu(y>IAA7R) z?uA0)VTsAQS*)HFkWjlyhNwf%h4_6yA#QRV1_n2+K)vr3Y}s0oGXelVvV0S_GlnrP ze%Z0L>dg_kL@G?N8KpcM*TaatE7!)TM1-mzn>%}t!PSuI$vXRC={};J!dqJM1P2My zOOH>tdt-rI?jI|MzDUj^$)FNHY+nUpKWu~PY|HvCTkxXTSC-=PUC{Qfs>MT8KUH28 z7Mqn#Ab@D{MKj%&s(U22Rb5+Ny$STANaHjo*u$vI$3+l!|FU`|<)J@jvn1Ylj@0Pd zU1MzZu%j2B3K~ymbl0Y{8nyBj-;3^bPR1;A|153?=i&gC_zBdz>ofrWvf+Cqu>?(u zo%%PDkT2dyM9jfL z87PX11_G;iU(}do;URD)iEJ5+9fo@}nd?M$0i{5NSO>ZvG+j7x|RK$>J9)u>zyTGLlzYRZW6dErM&ey8# zlT$xR%sK7*?P)g^g|FsNyvZlx3Nk#Wvsx_aW+`Wg99pAOxS$=iDc_LH7$0zUimJ=WPEt3S=dM>69y679R0;}M#?and z!m+9{r4+K<7Q#X0@KdE8iuoYV6XWh>xZIOfyid*}$S5r|GU(X@Aip$8g{N}~?+|lh zs+{p}OTT7x@+AF7yeVDQ1QhuK#lGpH-06-v%6s!e63DpqB~u(jJS<|;WoA^hVWRq> zan$#~L9v6w_I&P|hE$ zD~hipLAFw9#o5lM=J4sqS=$nj^6l!<^M)0K%SFvPPFL2t`Y9d2{HbDm-~5cq!NHMc zPYLTLr}FYXxeF9e!Da@*NX#tPL$9Mw#F}76A}Hj2QK!!hP;<*d6Iy{95QdffQ?@4ftudOn+QY_<%8Nov>E~vJ z`1e_|bM?u&M1j;9xuO+YqmStIo7w%S>fvesrZlC)E6dL_TrW|Pg$pUL!R{>I0_}=$ zB`SjhnTOnGmuS`*QV7yQguBqN)QtD6%iCA%tYOj1y@{`_-Rt_LI@-`XGaw?0cc|xA zP#C3^&iqmzNw@rS|6>f17vV3VX!(>9Dx-z_V$;S;T|15M!pQiw6F4o54+ZpR$9mBC zba!oMzqQIP25t$qd{L~lwQ)1r`<%L@3_ru%3<(uSSRsEM1u`Jl)!a0L+J8YhP1+`KoCFJW~8%d-&*ARj0UbKLqm zNXIxj^1P8R~JjU-_+@|$>*YIDBo zyDtCp-f46L-c6M8yjE`uaQu5^46WK95#byr_rrF6a2;K=cYU(~rt1yg1Hv|0!}P=a z@x!ATR!dRgZ5l#59&BeFU)V6yCP^be9+EW9tOPmzU~rn=f`MY5P9UYVaVw?*mZLB! z;Z!0BQi7PNHDABXve$CTeZ2IT!{f`8`a}j5J^^6z*q>$&xnA)++kLz?1ds6floN<6 z)o>s>1YtYoQElaRhhI+$SByy%u&5iUP5*ewV*PzWP(U9E9@#Humsz+JW-DblBV*Qo z^CppJ10^!2PuEZGeOgLP=HKKr2qI#>?_gR_g@LnJsg@#&EiMbd^)l)lx~Cup=^e#N zDKyEO*;16(?{a8%#j(Z}RlMTc`JN^c3gh7HBDljw{cFqh1zi!qdf3mN<01EmT1N@& zfU(?Qopk>g*m?y{+!TS6#1J==q`H&kt5c&F3ltcOo0T7A18?$-&jk4Wdir}1%-O#k zy2{dc;m(wLx8D6Q9N2hH6rR%Y5?Q$xkuHhFDbmZk1E9_70!r{}^dDjd8@InL%mi@! zTR(KS{pA>2y;|w4w|LT9U#k~dT$g)D;sHV>{*gsc5}#AoA-mdhLu9xBlp{=?D&qV8 z$W+m&g0Jze5AMhNWRYb{ngF(VOF@9e2{!WBoJ^xYpUTLZQaviuX9YnpgPmdn$&ii zEZSibZfbM#z#l#x1=IkteBwt}dsHO&;{Eo_{TSO?wXK0=_pW?5j|zVb2KplBX}UC9 zkLeQl!%MM=Np*so4IIv#4%f$r(iEA175P~?u7-g2d#d?WYUQ15Nwxgv!DYZNBP4Ly z+y{t&AQ}EkfEduKGA~AMLKnLH)}7j}Sb6K}#;isHj~yu|RQ$(WprBfz(uIM`a+fBp z3L_HBBa$*@<6^(H20d=}!$58dw_nS2>)rl8*n7*UD!(vZ5Do`8gmiaFN=YN#Al)S( zg3_Usp3aEh6B`qKzc~A*a>Fy9D1*CiS(ZAk1Yv$fhv*yFBS&L8j`trPcKl{nw z^V?DD0WcL-@R`eJvCS3lCaWQSvGp;n_K@Dqy&XxIS zk;=8=6JpTrd|(!i|4&RrKjhI$Ge;PfEaR^LlT{j$e4mr@^-n$9tCy&A5eta?Dm35Y3Fs>MJGMVi#EsPZ|Av~ z7{OBe$(vJcIzGWox9i;dNW66{8Ab*5TJe^o%Pk zUGuyaV}}1HD8g}PKLpwg9J$}!s`OdNx;nPd{pP%@>a57jk|bPu$>}eoxFm=^!T;We zB!}#+@b_~(xm<_{Q=gdIsNmt8$qKXtekwC%D^Cw#gyyB^C4nl3o#0XQU^CuG-6o`q zbiBeN=Vpteop}=k6CS!>$V)SHPfy|>fB<>*yP{}NsF@1dFC3Vk$tQUW?lgKmi)fNg zA=}C!hM5BUTlI{2h8(hf81`;58FSQqoN9kJGKs0Ui`=->+{np(w$~M!gjP-X_pL;j z2ml=VY%#ey0Kmp03{eeYxFV933Kw$uE3X@^X_fs!P%KdimnMp=0cpQ@*rSH;-G;KG;^ZO}y!`->fn-GFu=;lwW7X}07R?dS{8P~9NC%h9kw$AGR1 z@+Ue^z43AgicMhc^s=QXux{dCyM#zB7+s{q=6{AEacHpOMLV)$a+|bjISm){c{`2S z*2?{ABX`Jo3V>#KRL_Wa08(MTyj}9|QY11!0Mke+{jJvL?(aDCxBdsl3Wr9#Vf6!^ z@;82?c}7gsXi$Me5{tfmz%QeTYWsY-%kwpK1%6BZ|B&{2s30j+F=*o6Cw_@@TXLrP zbM~$i5%cyF0XCL^4*r{B_xR88qq?njW4?YH$CFiLsWv%hc_ehGA=>u#o^|TmB`cBi zAA6DyMyDp#5E?R$+G5Yj+b*|?f-YHl>kMWR(9qF|A8h-z4WiImWAtCN00kC4?;IWT z&FNo9cZvF~|Gn`;Ut;7Yd0Y2%Y|D-i_~TllvRB(dvn?gLnArE7*yFYVDT^ zA$~CR<|icpuZ;$2u+9^pUlHQ2FnM%6E_(5wp_}JJ=;mSLM>)i@2&^xXBkV{)r`hGf zlS8%wpcwg5yu!GD>-v!;@GIX|n^e_%Wb#%1>WWO%;OMY@G|RrG&Od*3w;dLmFKaJM z^!ITOHk$|ga6RTc7(R~GkQVTTjj#1iN&oZ4`hQIZXjh)MZ9jFQ%7ZC@{GDX0EWsvDrFnBopf#R=-`^m;ER{wfJ9RBlK=DBIFvhA z6%UH!V_B;5l+p}QpXBr)_K;l(d8HUe48@VSMo0GVosDvAdDyWk0@29Cd67Hdlk&f$ zhnIQ#>6CAuOu zV;KYpdO0DAp<`Zv!Y+~EL6XcTORxxEqa^-4N92oswe6QA4A+Au6~EP~MsW&`3Mn={ z(?DK*1cIu2`#a$ORpfb7CK@1U*v;R9jsOpne@dNy;fW&Hq`Xh?uXy}P3wd>R$A{o6 z=kK=VP1F!kVU4`SGh^fx#uM<;fD(s)9E3#1OzEhX3jTg+D(a=bCi1pC!IBY)IT8Gu zM*3ulylT25Owi34>cFY5nhPFeJH_`nPa)d5BDxEe*LWpL^RGbxC6DZYfz(3&jk=~F z2)?*{J~+79qLiicb>2VFWfaN$nKcT@!-9I5e%8IBSTsf=u16&foPR%=h#bB3O8IG$ zhQ20Px#-sd=^0e$GE5{(y?TFbJVFU)=Yn7F7oAuVL~rE)Zf^uL@dQtB(tGNFg$HU%htbw{Zk<-g)FeqQiw^ zzl)g7^{Fs^c=!8MF13&hI5&vB9MVXBCnLjj4EH#-GG0zTxNzBjdid|ZQwE1C*;O7Z zuiW2!ng#w+sm1y6W!F)g+NFa2#WpKgQ5?t_Q{qp^+f%rjuh{9o3BCLhw7Dz&gRcWW zH-Qi7j%PS)RDl!1#$Gu|{v!ukg#=zL#m%dL<#dzU*q?X{)++w<_&-pSnj=`Mg8j9; zU1AhOh$wz$`Z^N(NmH*_%fFZ$ND}}cIe^;LZ*;BZ9#~1bj&cF!@#TxbqJ&OW zPH;RF#C;yU0Al`>oZJ5g&YeGF+(s7zB6=*D74^kqg0o_9xCC9W z<4-&`SNMRK)84IXynp3H9?8yPxB(I8J8QJF=<#;Y7hcBF|0{o_Pyn^k$daL9%mD@r zwlMzf4iK|pBXG##ublAhkY?;@AR;pE!E2dY6!RD#1gUTRt59?CB9GcqWP*fc6u@c| ze|VeG1O;M_ofOvpl@sijA7O(Ys7=%MNe21c04sg(rq4gmrEd=Y1Xs|hL*Rd1C^26g zAZEp$miu451-rG&;E@NMmRP3u>82@I=`Op?e}rlIwJ4Q1-5)sq+rRut0>r%YBV+d8 z0RjpkJ+Jb+S7ZGAXJS$kF*)Jy6#nx9Who)7*Ztuu>pb#Tg#Syo@!Mxr+8r#NF^+|G z1`L08u5QhWlX>F&8vxT8a6Na-!(E^#mEAcgP!7ny-%{4Yv2ixBinoRGI{)`Bul^XE zq3w{Q^QZZzbE-fMNT7%EOf#6a2k1#Z-S!Fp+Fdmxl)|p3<;_e03zkSPedrwrl%+yq z#Ot4J?g#qRMce-WtE}Arr&RX;0UP$m)Xy-ZQ1=YKp=8fK@*6V}y_)+j-loDM&iH>e z#s3$h|9QRZNVWny?x;9<%q~clHV8H*gJK+wJ0aE9itSWfWN584udtM2=lYQoc5cqF`ErLD7y#~ zD4|# z;Yd64q?cj4cOp$Fi6uo2#)JkD#mU!5ZfCogOvPjG^Fu=?`u7970Xe>;G+m0C?Iw6k#gd7d|O)MewW~R```vbmzpF-HMfn(GzzP>7i4?V_*!=c~_HYR2~7#!`tA1D!e z;1ynWyOK#h4pNRO4wpJC4b6(EUFvl8*59Y(1W15?u%Ywbr^bQ=V#&#cf+t846EUH3 zX#f2{BGFo}F#BzCc?O|2Oo=K_W7{>+_#rgHK0S0^bdB5EvkLga{C+(*7 zg-(>n1gCrgWwS&7x5@}K7Vz2V!Y@~&h0&>C`FhOv1W1v1EH70VgRuWT1yez-Uu<5y z%ttwR9~w-J2#KcvA*ueiGH^LGV({5$G@4$@>F}orCMhXPvfk&s8D7`xZsy8Zcx@IT zvVv6V91$!Y>hj_Zkjw0~;wDLNC$1 zg$owOGAE*1UJ-0lHno!%+23tKgF=Bye8gs^0t!KdTA*8CD`62yIse{+20Io3&+Kh1 zgzadPz!6Aw4^9e8=uRlCVpqjo{;yKVfz8~Z1`91f1YIi5>mvZ0@Zqi<=U*)c8y>6$ zo;mkFW@=6bdgDPt7mZ1Y(qiZ40gJy{j7|iE4#WygLc@ombh+ju_)8vC)VOf?9Le$a z^?@YWxq-D?+pCLVu*MVd(4R6<*&$X+{a?hCh=ZquU<{E^7OvOP3{N@TV%1UveAqM? z_(=+`gh}kx$rK(x3-9k5z<4{hN(%D}2TJ@nT*!Qv7l?d3>L*9TcpWHL0s;Of2>NT- z^eIQ^2fpTl@)vUh3l`g6CFCQ3BLe5U7KOs~!EoL8_(@!5mZBUWtb%o;7TQd|n^FyY z7A${2$$DA^EMk3*atG7A5oD^eIj@6buK)RM0bD#+^)&4(ec8$e>pl}iWnL%B5%2J;IPUb@mkM>eqLnvcQ0Y%= z+FmVQR9%{uo?;N6HK6u=e94I^6PQtF&DOo<<6MqB@qP+?vDM> z$MzM{G##H>W<}^K+kg1l3xrfVzK{@ZER0#18Y{s_`m@^sv|9Kbt0P5rh9#-r zjyOiH&i4q*LD}>CPHSKqDB!ZPk9ID7>O?3-k?3{CQojj2`L&o3ht*002v=3Yu&$9T z9;^*Tw4SWcT9b=#E`K;`%ceWb-WJA(%3l$qp;3bs?ABFaAlkVDxe#qDe9BR4axJBg zDvf42TC;hR1|14XMY}b3elTt^>1Sm@xV{{ziJ_r-@cF0vMDBzWe9)l16?kWsE=Vsu=%sBqwzfZZDMe|0yYP# z8HvH+Nx;I(4TTe<%T!k_O`6o6r7u?&VH!NqJyX!Lp%PepI)&swhKEK4%=Nd@K9}6J zFCn6_mPqfTi}TxC(OREvx#c?3X!HWXrJoPGMtxJMjIX~yfs$KLlptRx%WBwbcTw5% zX%Z5%N|LhUNcJ@%SF()>4Fh3~Px@qE6dcQDX@~?HleRoyGtg((ZS zNblC!?GpNJ5e+z;4)IuZZg9T5lK19=+jI&BrOE}+x6Sq6R)Agz-|NJukqH!+4mkX( zpkl(3s}!X$7ktKhusMk@0U7cj3&AVO3=6886A>uNg2)IAD zMlbh#!T`~3TCQrs&rzSRJYzQrie*T~&2q$Ip)dIE-s{=WOBzR%uFOteJoE^MU-H;(mD$a z>}_~>H`Bm%ARcUiV0cy7eO4-(l0TER<5jQo7x8T4bw^foTI_Mob^=BTFeMWWD&49B zx$k{9CtiHYQH)rfeCM2d$LGdp90`+9-#-f|?M%kubPem@G9V~R- zh$owo9)b3czaUp+>-xbchU2|)b}C4-V5Gy4Bg93V)4Jk@QR$&bDxZ;lV`jXwwS~CA ze*TietVVyfJOVvkL@y-qoTWJo z6<(v42{e5r^VVsETd;z`(K44f@G2Sp!?m-F!7#Y0ZC`)c^*!63X*+&x)07Z8YIG5s)Pl^yzz&iJCag z_)h=Zd#e6B^N5jLz(j>-RN*q0X!{PrgVPJXyu@!JB#uVJ%hS@qjmhu)SkV6Sv!~0xj%RV7&-pher8mbu%St0&w%4M+J42G zR|>oy?xwRp9@^0p_MY_8B6c1v*7n{Q&IO*zp^%jX8kmFxU0H{L#e!Owle4_Nul6o` zTF&dciY9vw$q&j|iHb}TR62%MLr$?7B^m-x9wr}-U}v)Nnb)cvQ|c~RzMpOKP2e^x zmfI|nk@QD5uS3c!}KblRaF8`gZVgbudp7xQYfijd(3N8l4AN;%=f1y zw@JmTOru+OC|j>2;s`J=UV^U(mhvN-c?7;mb z%0C@+b}j#LTW`HXh2_LeK)euu`|afb%Qa%Ad46awR~v2utyt0r(F-@`hf6Bg9OvP! z6uX_oTF;wvgT!Q34QK6`YqKj{U*F{8e-v`^%O1#{c;DDZvcY{$=5&8ZM`Y-AT(jRU z%Pw(5^QT#3tkaLmM| zw42LEN|GSh481wIBZ3MxCnMz;ve-bdsY8JG1cELv=X`*sW9O%EkJr>f0(rY?W31G< zoN7@XxeY^sKwNSJDW_*OAfgR#7ru$FwnW=}eJLm$c0tyL7r!BSUJyt!s=NlFwba?8!UXfEb=hpo_d^5S9B$Vkmnv zWwRpcO05Q--)0P6tM$!iKBHTmC{2ih0>hLKh~e+uLg0`~L2=P%eflecRF}n@n%M8q zhvQBSq*PQ?31_ib+@aFnH>H0ZMr`|};~NLkTu@!?N3 zjIOU?nU})q>K>jeVemeG(3e>NT#57b0pLuc5~RHznAY6Cw!0g?f=H`k{rD-0G=pOz z@bHPJ963A=I#Oyiv6@7{ho;2z^PhtOHd=GhRoZV*JiBM1Bnb@*lA6E8(|R?r5;lxvL&K1_ ziRbYzK$Q!;u%g%HA*cOy{oqnZ0NII?!ePh5*o#}}I zX;;YBn%dUX7DGALa_f4HnzN8gT6t?Nv6;?C`9b)n;*Ixx*3@Pdsvhs%k2wb{arw-O z;ijLOSZ(K#2;s-S?2=0+DZ_4c6I`Oyssz6vuHq+H61aCt z#(k$G-!o;QwP*T;*H5b_3uXyJ_yA?90!R{thTqJB$nj~T?{5H^IHbEojD%Iaj=_=&czU3EE|dz_aum)u+69InWA4u;`U^0|0aKPWbR zPYoiO!acbqDgT+imx5uz=ew%O>wclHuivJathSF)Z$81eh4X!gAXg<$1JEt!0cFp4 zu)Dek$Ozupw1EfQq=7Z0!ry;X z$fU}W@D8=@jJ-SX^I5X{TGyPc_+II62RPiaUj>sH_>S@vX`*ZswEe=4BR}d zFLQaSgy6I%e>1jWs*37LjNA^vB5S~5L6uSlZ1PHbQKgi}KAbp^pDeofE_raT&cb}X zox5n$-TBorM6bC{oZkhM%D*nkUtO2=*x_t^c+i-qeC&@wWby8fsn&FQCOE^?@_apwz_j~t2 zh;A#<>Pair&R0R#*5EL}vg8nR=DR(s2X-o|p2lP6c@lD59Qkm##3+Vc^QDb1;G>0V zehCI8k+axD)*TqAEBW1vW_XVPO6dlV=9Wh)LzD|!^8q4gGp24Od#7k2&tbiGEqpXx z)K&B1ES-->lx_0;f)NG=R$OiJZJGCmpKT3Gl=OPiSW5$$ze7QBW}i{q^kg|8Gp9~; zuC|at)=NaHRBR<4m$V~ZscF*T>`eE|IG7)rP76NU5{q$ZK7b$X1+ajz-=_q0;x>b1 zv@r8}H?2)%ztv7%5^`D`8H~-Y{mNlX9C;CnK+dkV>VW#WgmyQ%Dasd>?JBuXD}joR z7()3S(>w&Rxn$KJL&yhP_3NVAPwFiB0S1Q#L3DVHSBpwEyu`4=&d=w2p=E(&yxogl z+B&zlURHKc^cEO^7{^O`ZMqa!n7+*w_C4eee{ispR^7z(d-C=~ z`NjL7ijm$6Tk1;CLFL8WNxb!V>V`b8m%%`ZEl>NG7lj;`KAqV8Be;?}Fro(dI67L@UiPz=+Wh17S47uz>&Q*pHz%r1Z&U1YeAF5GyhGS^BmDt`OfCO%w_6jz z-c3I&bQ9Ritow=FM%p!f#<=Af|V~xu)azj(Y>8`J;ZhVs0!T0?0t- zniF{-o9_?^|}xN(aoeJzNeWgo*(8*@@%Uxe zUZjvi(?o`9S(=XMvfrmOxo5r-_JhAM@h<-riGeN++sPNzYSdLUlHzLhKWSc=I94&9 ziv@cAgB9zfSiZc<|GU*L1g3Z98C6nC+vQDi<&Pj&i+jT4_RPh z#L~Bib}w||NgrZTpyOd6oV31yN;jXha!|!xsO;SQj|xg?=a^WhNh)M1=b6XPb;sap zQVNrO-=kR!VZT+XCb`%k*bWE>wX?CjHxMVd7|xgY`;$({WO4##X66!3zB_tZw~xC` zPCl&J)>d3B0=@@w;DPm|&{S@tTo5Cb7&Qh4ogMOyRNTn`C8csR$}?%3E9?ra zmN;ky4R(VpsOyTARG%(5Y-I*pE67Yi1sDngZ{R}wzIu<$8j3?2c)y(HQyAP03Kqe5j*fg2 z6w*Ga(*F8>hX-YiLE#hl8|5L5=kZGhc;Eskrx7Y*q5GPXH4g}{Yo!}DYFki)*n9OZ zUH>yc4;W+!ZWmZ2#{^?ZUTKxawDF$=`K?@kf#Pm|iBW0fg~72x;s~L2BKb`?+zOb* z{us7(90y=op!++ykAgMid_F9eUGkaMT6$_XeBz_Ohk+pPrtul&bQcRR@1ldM)dYADBz+g}e0J&Z}^YWz={)e0DuO+>! z(|>A)ma0H}sj(aQ!C@~1An|Coa?AJnE(ack3I3@P!RO+sycWiWzTkorjaTAtt&Exs z6Uu@chGDp2@B~z0wF?Wnc+3vs;J`n$8q{m?L-jwe4c%c^UvxD16(oFzV9R#DI9Z$y7 zU8M9t8OQQYLAFs_4Um-v+fbtI^3-{Oj7(K@ou)3!Rk)7(XD6tb< zKfS1bbqyUlts#d}%Wr0PuP)Erxy`ECKtXZtK6#*$BdDFM0&+XDFa5F?`v%G9eOaDhh59!69K2&>7sC&|^h##9~ZK_;i~VY11zrmY7s( zgMueXJihlvgOc*sm9yxf(z(P+56jfWpF9HulRr1N8hgNY{V&F<4RQI_$nM z1VTWJTIswtz>!w8{w_n~^F0@)s{4ilVg%z;(Yh6yIP1d;v~LcRaG;jVOr?y~rz_Dn zA_(Ynfr}Uf{;!~9;U4v4F3B^(`OnwS1RDUb*eC>RB{lfhXO7z73;!3|yaJU&lQm^Y zNP|vvquz3hdVL`5lO(3#^J7DBTNXg0XIU5<6ubUPJ^Q@$jewO=%8zL>!w}r&0YSw{83&IMeE|9=W&)cWNGzX9kpM82kM6w233d*=649^}L` z6S3+uhq2I1QG((RZYYpDlKzn!#q`DKm0vihwAe>Zzjc#_4I3gx( z2fSU92Y2`F`59tVDF&o!$%ppYMfjt!&}o&SLM_KH&v|p-r;9ibg5;NrjZLX>h(MFu;ga3UsRNo zA}>}I!|`(fIWP@}L{Q)D)h6!^_EjH;5AXgI!jXVrN#?BEx<`$ zmpa4a=)$%-{+5;3y4A9_uSlp$@RPw5Zu6j{th1r9?0K055hc_G)stmsU<*YC06FH9 zK_;sD&lbM|%tf{}OPyGdJyQJGYf#Im_vr1$9b1!i**)Z6#(P4AiTaVW3lb`DC;= zPb72Q4ecwdi-QvN$NZ7O>R}FOx?w_qm&B`9zyiMq2hu-DNg7=X97s5Wl~=D0>OlSv z=bio}mY&RfN?|@0k*$je96su*NOxr+f$p>aHYJ7ifyIQ-W_6%$&0MULSm-T4$ZRpa zVai05kllBci_Tx6ge1S1X*oi>#vrMZobnc7a~W#Cm;%|0T`9%ibQOvu-(X#zYT{Y$ z&g^LBmI{Mgz<#UJdu264T!KBr6%QS2HaGus?dSGp;lmv2len4Qkt{US?L{ zFLhmkfsG)xQpR|Nk=ruw?hF@!a2Yqaa2nNO`Ls{EFmF)BNoGYPKqXmB!xB)n8qw1^ zbdmbHYPB3V==ZmIj4SfqZG=!Jlf1i%x0>MW&kInErWA|kq`5(=pGtaK3SL)2Df`=? zzY}v|vbkrn@|Ce}vDmTK>hZHCkd03^KduqLt@4yxwz2#H2BWppZh~uM-apg>hJ_0- z*K~dkek5yyL~Hb13RnCUPb(HLb2&bk@EMHI0ISIJEeGqIt;TAb)$GE9EC#IVR_-70 ziO}->yG#OFoumL4MCvhy2k`<(k5?Qh=kwz7ms6;S^M}>(s3V?jyMSl1)@~){1yaks zsdwsNzYy@LKMJ?T%Snq?=RT(mx_CukL0|igz#m6~CgjWWS@88z>SeZ^R?XrfPQ#JF zMf1l}P^1M4=Yk|XU)$17S?bXWKDa8NsCpu)j{w-rDuZj!P!ICMClvD{=dD}JE^{vi@&OaB-cKH{kITS5gCL~nScik6kv>T7cmrvqLM|<-FhR$fkG*p<0x3=>?g2*y(i@}Yc_x)sdc(zf zZvcoq*Xm zV`T+q=>-`H93G5{ffKGf;o1OcpgZ%q&cU~VKZ9HxOfiSgGyEq_bhl1Qs+du!G5Oig z$I3_$fxiJ4)Z}VdNy>tPYR7gLjenlYlpp~hU!^ET_Y*;Q&KI13hG7nPN)Oal)=&Ti z|9yu=SFFt`_>vzjV^W(FkYnuMHVg z+kNKL%T={q!WTP_qPQV8|D08WM z@8HJx+dT0Pi3tl9RW|6$fT`654=6Pzy|lj}6`J3(xiT}Mo+Q=?L!st%o1PRd+Y$6i zDz(>=BHuur7PPQbK#`BNBHALaC4e8kF; zMya+AscHa|P1bKgoU^V!g!2E1b{?pK62R-z{WVrPe^C1h9p6Spm~Y%>8u#bhF*K4` zFtuJwrvCc=c}er2_Ffmk=}LzCr)UHK0t^Di+KwIsxXaK01Y}CDQE3PkMiGsN0W%1V zp`!>V5WFL}lgOcgnIKk>kyRqcsLS(PbYra^Zxy?3n88f1sP#*Eb3wbaf?XcRPBMhp! zi{;oZYV*8!tt24qy;K9|^yvCiC{s2gh}HM+MWsVtfv+Uca({53JZYfeDU}LDTg-aJ(>i7+2O#)3M9J=+?HMGLvF@O%lTBuT58|; z&zYu6c4k(5XeJH%#um`u1DZq#a%FX+*I6WTSZdLYUfQ#EZT@WPFGnagVr0ETOIeF_f+=W zS}VG%SnT%NJlwCv-SqiH%@6$xe{ap;ZzE0g)^GmQ*y^l71D5>k>M^;zsVS8!0M@OK zRq6nI1gN<1t*yN5PnU9WK^ZRr$*sAzEd3&FE$iTu0*8PPPjC#Id|aKCz2kmt5rE=R zE@)Ro`Gbj3ExIlhSt^*9@|K}JNHGkTIiV044e;HJ^i+|{7AU1H--Qb^A^-dCPOMNe zu4ggU79#^ctR<~m{I!mL&KOiwDtQ6x?YMaS)`=qFPQbEE@@CNm{b9s#&=YWpm#omln-+R zcn}uX@&0z(>j-5PF%`p7y$oOuw1&If@+W;_#U$-Kh34sNJ{CHr_>h@I&RdAJu*oZa1rdB!E0llhsiO zIbOg{7kUtXEYbr0dU)W^=DT4~TK360*V`9oN0jlzz%!x9?Md4fn`&I&xry!{{G7?S z-}{P7+r{+#!vfQ9$hAySA~uZ_HS#at8*Mvqxh)&2)ahhn=_K^2$i9;9_qbbCie&=t zL0g@p1v;guQj0Rh0Tvmm)Z!#QkcP$#qTSsG7pa0o#UI~j4}MAIRRnx4`gzh1ji!7) z$$6{|+H)HfHg{;CJ3~Io*~G;<@BVhX)q^QKCfb7B^r$X@E+A6vcy>vD%cuXpK4JAp zLyFFwce<$3nk%4Gt)z4f0T8`fP``h%UT5?&&PLGolfdT>$*wENV4~r99xRA+DXff| z440f+$z@f5Fn&dhe>?60utVW44y4d&Wq@QOVqoB#1W?HgKG$5CF@ex5_nk)|YG2x* zT`JW;?CJtg`hl=4$nKikD1N)R8&+CzzQv&svFu};#dVNBq;)SAwA$;XrmHS`N;&NOb6o! z9Q)-DIv-V|MVq{SvYp<^^B0M(6c080Xs`@xHR3TDdcQ}x`m?1eMO2FhEC^33uYtKs zlI~ub^}Eh7sRY(O|B*jw-{ArX%E-*jFh6jRIA9)-A%OXK3gAbY>q7in?)}*aW8#@5 zKawd>>gB%ESUdi+3SbC#UjhEs1xooNtW?~`co^=Ergbi9)$iEjzOnBM6aB{MMZCZJ z!4%Dz)KB#qG=8Q2zOKOYZvz1>v#Q|O@tzF(9IW%Ne>{I7i}8C7;Ia+*#<}X85QiUj zPB=u3i33^`6Ml_tAUp6~tF$5&PR%k7>ZO7W7HTP4h(=K3LqTYt*L$Zi>T~7fbAPR8 zY&daLGAYr4j*WqSt5plaL7kc+8J|<>nZZ@^s^W@vd5Tl+QyKYtigPk8ZywjMQ|!{U za|q@4y;K%*ogUXqxpRW}K+FgwWR%Hg^xrT17UJ@@SO`QYpS)M}=-MXm05yO9`@Izl zyQzlYIDtYBNd4};tYEJny?0_jcW*`SxdmZS3YWfVzUPzCW2gHmvBRY@zY|uYK(Li8 z%F+E#@hCstJm`$0H2_JeBS6zE7JoE48a?k(+-uGE;{!gpa?`YcY1cT9%q+&+W_c@zREu^eST|JhqvxBb{F2z zC5`9d^kYTpZN~n7kx&wZmpG3+8oy8pYf1O~xJl)_|q;?hynQ5i>++P*c3zy|M zJ7OeyC=F6XvW2(vjpF6^iugjAYwQZixr2BY@hG_%a#rd`<10J9tVg`9*adu36ShQ2 z0(U<|dM5d#&7{7?yeu9;{IxJV3zbkP&DL4M!4h5Dg zTg?F%I;{S$~W4NS-)-vLUo*cH^-^<0F9WS zyjGs(;p1PG;R~!PQOC~;z9?6{MM`;XjVk7k6`OSl=uv}P&?0H2S1vw%Yly9W${fnE zFCB0s7$0A3-twHY#&PI=vY!QEk1?7adq8h9`vjdJ(G80_h2dTMfl8K4sURnPLfQS* z35)!Yv7BV(bYrNHzAV4Xxd1_Q7G_fC*RL2a42-2C_la3mGdRxnYbLD^oU}~Mf@+Ls zrK~))Iob1qPMfc806w&iMS_F=dq-_jh z0)inGiU#9^PQUwv@A0QR)AW5J>tM_;1|h8<7#vt%@%oK-DIvk^I1bY-jPH4bCorYG zoufJ5)h{d^Q3*#=?}PAm^`ev6b#nCbx^NjGLWLKaft%?xDg3--T>5MW10LQT>UXCb zJUfy?o$LWEb?U6GZp(do{osQl1jeb}lFIY5Q9=eu&37BalsB_(J<@lH(VZG-G{4m$ zXGSdl_!l`@K*Q1IB;UEz%NN%5!5Beft;st|ZSH*pdv;kt&rzMg!wj-F)c5#>?E(ouuc_sDunMwk~mZ&TY9Ifje zM9hIic3)o(6rTIL306%Ik(8r)jHTpE;7X`O5a9%iI}lBvK(ISfk7{9%LsUuvf^=7E zm|t#h@A0$iT7}|7nt7Y9Zdz{)3U1mM7}F+Jvt=GTZAB{eTGslhcMvaVr3-CPa$7ZX za<$DHq?U-Nc{@E9QYBa(uV>LP7wma15~D*6d-&rdltIQ{i8oy=FNKTu=(0xsUjKYf zC;}`pmfC%>QG6&Up65#*(CAm&sXHF^y529Cm7Q%3%D|=IV?PGBT)hH_qc>@NV=VTl zmag5IFTeupKkvoM`vN-5PiizXtL9n)OsMe3J?49nDmXH{h--#LWD&3W$bKY)%Afys z|Dz=pkTSpc@@?-8>|8>(j^yQVMUMQ z`FxlV(%zRtmF4Fh?mwJUJp%{mdu^i z)nGRka41jl%+EpLjVG!L1gHRN?)Iycd9}yA0QZphX}^!Zl<~AamPQ^KfJ&bk0%|u^ zQHqk+9$GA~h-#zsojQYd zLC#;IdI|O+6Ep_zR^n!?fzo3ks8L0c-V$eZzeA=oP-K zvQcXV-}AgIaUZpsMo?8udiX5V=8@#Nc+fs^9hF1q&FE&&C9EJ2G27<$Ca=5n5WgrH zU;>jAwx#m>s{?Tw4Z)?aQgU2gaA`MR&(|-|5Pr1s8}ndPX?4f26#%+5lG)>KU;AAH zV={dp{wans0kr*cs9x449Mz6e;X_Be{rG0H<`^^co;-)d!)>Jj#{^N8qTGR1snyn> zKq3a(8ULC|>cifMdE!Ay#d^8_JL4?=PTDM6n~j%>&eeNs(CKYNSh&;u1p{pHJ7zSD zyPyOfB^8%Z7uj7#C)fbcw=p++ppzcM3y81?{IwD1X7>l+4zFTmjbu zbdyNe8h?F6+lMU-4c4G0HzIOSV$TUu$YSqe6H4Vh2TxFPj&?^6Lx(3Q3KNV~SSO%v zXjR(=eDTcc(lole)BT;ax}7WOQ3~Dj=|oIaCn=x2 z6Z}BT8s$kp+h}s2&|;npENWS5Jq2S0-Ip|BM}0y0xmw5M?!xn2_s9*<9fH!jSzbGT z4Eh1(4jiCPOWflK$?XA-J4*cd!eGH|ZLvoO>qarv9NMoUZ4?nTpcwVw^O}JhxLH%- zhtW*2MP-f*c^(DPjwfW?mdL|dp`Y28hzelX;xYsVbiIoRw%!;N9fm)c1_96NZDgQX zk$KbGpLb&r8Xbr4bmq|aH_qpt7_^gElNWtW6B;PD2pI$eRf*M3aB$gn$uZ;c=XtNh z7n>%Rjo}^)U0=R~=s|OBurMbmn)PEc$H$I`JDJjr8FWR*_p&a9uj(;Bum+VFa=i&| zkzBMxw|KU@8xx41$J0gk@Zw%)Jf#KWajQ)`1U26RCeXrnUL%9iN!ZlnzC7p4PvN?u zBQbw-MP8cb*;%iu6v34vZ29x+46D{abrGi#BdOUeic7j4P6EP-KD1k2)79P?^Wau5 zn-Xvr^W#*R3zrJ#u?oxn6cgh)VZ5m*IuR$bIFNR`m`xPFVoFq?=2;3le~L@aa;Uch zu9BX7qkbz*=bB9f0@MY`vmVKG>YBZmL*ey7!W&77FX^+X*5ZFKSb@|oNJ zaqInZ^4ku%%+y;CtM`n37E8iIFl9|}15E(;B$UGHFhK!psPZ>j|D2dEb2xUkJ$rvF+B=eNnxgPp` z-*2sT)_TwT*I8#Bf9MYtD} z#M(F`H~ftVaPwWa)>5)gF7kYTLNI4`e`(^GUGIGGL_8@NR+h`GT@^fQ7*94*F85!mA`R9p7lt|d~w|$rw2+Rem3;+xalkSR& zEADqa^P46QOchV3Uyeas!U~Uxv`tZu+_>kB;5{+)V{ENKO-i`W`V{2})r>eAluZNw zueIsxJa$kdpgrfYtHv~7HOq;9nF=$`M_r&4E1-2Ogi5@aVYY~`AjqT+PveVDmh#Qj zaRPem!*0omE`r@|gP%>6P^gbt&k8zuMNp^I`jh$dl@C(iD=a_yepkms`HO>^tJqK! z&s>%1NcpfgbpVbpSG>JPG7PiJ?H10h>ntLJFxy$bt`G!Lg8(+e41P1`;k@<$N zk2O!UChFvAn@mCoDxHN8d!%NpZvzBWU7Y)p9x+K>BUHc@8Mieai8;mU$hc@nbE+J6 zY8!%j_(}2JmTw+uKM!m9?;+8Z&wH|HkB7b+3CB+gFE8MvzfM19lJBr4(SF{qCA6lu z)zT>8C2^GHbp=0+FVc^5WhA0I%ZNe_{mD~ySFoYs=z%g|Fwcqujr_+j`m@%+wx}P# z@1nSJM$V%3;RDn9AlVQ+xltOt5LVPEhd_5N0giM9&>_B?J~^!MeIu1r%6`EB3S!ps zALpmy!bzdss#%uwC@aOaPi7n|0q!Z@)Znwm=W1V!e*H$jX5Fxb1Vp=Z z7+e{4lj@JY4UMFVR2U+3}|?l9iG zG~l)%LT4JJV&<*2Kf7Dla>Rvbs z5L1~5OZYS-0@Hk&={Oe?1r^0Ij!;T(l0!Y6nQll=!22C1bq(H0?BZ5^2~~zL#R3`l zpsG6N7n^5LFD$GEnY%S>e+e8Fz(-R!ruuGVHS{N~ho&qNa~i@W+By|pV>nIn?Rr+~ zVhC`*w_YXa3#^S|mk;u~?l^J^HDeORvp%EaOG;?4%id?=xV~`xn;AyDO>>dPNk4Xy zhS;+i$3c8#mbjV)deQ|!P0?JTv9F*ym$1ZtO!rEZ`6#Q8*uxq=F|q-bL|nN-=%BFm zooCv)ljtisukrag@s3qkwYc$$3%=a}GL%z6@Q}`2E)JxgP9}lAGr6^H#XftxUKN(kN%)hItTNKVHk}H2 zawBH^7In-Gg^rsm^bI-e<&5mpAAsA#bs<)>>`=lI^ob3`3}bWdm21M|GE6%~4aFOu zzj@XD=H8AdG%_xWM=Vu2n!4{al2-WaVfzf;d7tPD8gEPAUGQi18dRabvvmxH+tcY1 zTZ}P9$CsNUw0`o+6hyZWMEM3G&R0?P5i&f~-t+bt7wLp^KJlv#AT`QAU)tSIJw$@xM2K4`Sa&d2UQ3AUN5;fw5DE!8>Hs+#KGm z!H&Gi__oX$499Lsk@Z@gPHR28B`gMq7^~^Yv#fopZS|7%_SaM&EZj!YzZU)cPW*|! zZs-tor1^Y3`d;T2RNF6Q22pnpobtPY??TBgk3$U*n1xRQOwfdVs#>m~PlR%Gw5*@mElF}D*e7D5_GbETJO5a+xK8&fi;fT)r7F-Bq0PQrSQNDS z74G2F7*2I$i}z;fGB!{+sim}rkBRX8LdqW}Hu^LRefDG}92e4~ zEYp_4pjJ1M3`^PZrlA1J6*N_v^*3JEVQRfBVYU`2?`?;=KwszheN#?oPtz=NN;*AO zh&}Bkv8kY9Chr%|kIH|qgXKwBn5}j;Hy}nACt`? zLG2A9cufVvQxBK-uJ#+eYfOYD`zO){F-H*FExPL)S!jMudsT&;xR0$i1B2bx^HO%7 z-pVT3!Fskudl-{#t8HANBT|c5ME{nkN##j@pQZR!#sIS67_un~Hi4WYjc{m8JT(_~gn?6tUoJFd*wM1qS& zu#vzziKSGMLB3ck0tOAlP2PQl9$#AI-^>*RL=Qi+X#MF2$j0Swn@}Z_aM?zG)nh9D zs=v0ueK9FkjwW)sigWNwr-QWdZpbL+BhRB3YGs68%0s4W$Sw4acB70xod1`^Q(!OZ1@nz2=;>L^>Vr4;= zB8XRA_YLOhgaq$#cW!UBYyBF#%3yqw>Q{%KK&AJ zE-NhF=AcYgWE4%3p`uf^0qy4f!689*BZFhl0UaRhGt%!_DuPq0s-7WkERVhhwD69}(OciSz?{%{y@LO(vU=D}R zTzd41>oKIXq-MBySk!%uuT9g004@-=ryxLn7UW$BaQEe1;D&H;+o)h{=z246a#>eN z2095r%;Z=1y$({UhJp=1WEVVKiDWnvcIfCy6D>=K{kmS;xlS;x2Oh0yED>FV&^_)H z!GjYHse=K<05g8vc=2kG*^EfOu~|?cw$}Gu_D#f;P^1L7X$&Gnc*rl_7Xv zx48XK>}i{UQ3U+PQRZRQ7^D`r{DwvDm@!8o&~v|3x)xC6LL^wt%`%R{rt?5Z-Qk1A z0gJ>zdq(;$W;F~7q+j5ZynZG>w1uD&i`n5yyV9DnLjuL7Sce+{pkLluWX3b~e(2mY z=olZHmuc=_P)&G2Vbv#UGpJdA%D5;bf=OJCs;!uIl7H{n~T zciH?bo2@4Z0y>~pDulf!0nK;zfyL*iADx$bPVs7I2|$VHEoV5ZNWcv$t8Q98gL|FX z8a$ohUKDDnLa9A@*OUUuSTiQuQ+1%mlH;61#XZ1vl9%)`5z?M0XOj!emyk7qbDo53 zyN=6$Kp+NTXac5JC{!9Egrad6@7{rumjJhBfj7rupO#ZZc0Qee`lJW+(Pq$1Rl683 zmUEU&7ay&hMWAZV;~mmDH+Lb{S$QxTCQ$q!Q|=Ti8cP1h)?`hXKpjV(Cz4f!j>z2WsFnpI9;PEyM(umD5jF(-2~YL*`;LgmYz67m z$XoeKmo^%_h8`uV|B`Rn?{|KwbdXt{74_B5zMvR$^!Hu9v{P{? zocXM_TIWV$UFnaSj>5F_ngkY|YgLfo_xPKJoMdltdSJrdwM`7G(SDA6&J?^nMEd2m@}>KkVZ9F;;VBSRl0;D z*tx(9#aIp@p{@+U>iGUtC*O%vm!44o?WMCdWWja1D&Z?9)IF~6>ghpEned81@6OX1W^8E6}%Ip<#KE0N*SfGKqOcnAvE zxdty^Eft08=Jq~@zJ2FnX5e9)d^#fS6D=RgZ}yunlFWQe4$<&86EC2#McUI!Mp z*FM?vFZGYsN3y0xunh5syfS4I^O_cG(Dr1Y3$*Jk&{-_Fz^6`{`FW${TL352DB-N{ z$HZgr>81s%Kct@JUpy0O(0FLf6YCA7U#O#=GTK)4fW9Px4;E1znb-m#a0U>mbf7>n zc$4Yw&Zclt?=I4qfK>ViaF#?S&T1ftcql%r&>da+{?V>@x>2ftC+)SyS{b^Df$;Ji zcyTvv&r*EYt^8rP5*cA5KG>~Xf7@F1fQ}beu1*0vvzTKPloq(8M?0htT=8k%YIIoL z^$E1^8MF5>EC7psnd#Wl1Lpi&eZAnum-#t|MVQOA{0EWU;2}vA5 zA50bZZ>N#$wP-#7QNYodo#P?PwL0J4XRR4K?3$6hwEe0_i-*12n&F-{OHUL)%B;3J z-6O((zT2)R13<_&siqxyHfGbkzm!4+nY5-_Mpt@`eLi(^k(@9dl1g&-W7pm{GRD^L zZ7F<@$PDZ%K_Undzp(pI(Xc#e(-g%HI4ADW7@0wOs9&)iW&yxUeCPEy=Ad-->`w6m zU%V~K5nX%%vS;Rw_^3T(@%uzDW)Z*xz^(=mj|V9@_#~QRJHM_~dTI>6w6iP!IV40; zYTq5O+gBV%KsKcm&L~_MCG0wcivR^L;>rFl>L0b}^YCm`>ksmAmv%NGUo_{t?7#26 zvc;FEcA3O)b@&sK=ZVaoQ1aROHBSrloB^i1L(?X>1!Wr>lW8ZMM(fGqoljTBJJr5U z8cKNAjMAUV08~yS*vy<+XwreU-XOvNJ0^*`mHA9s|K)T;8va&>2knG{ze8U0`uRkK zNV+p2Pum}!cS>XxeW+9!WUX zyk3ec-B`HiEBp8bN8p+k6i~uKKP^r0<*1gk1|`_f50un#p6X-S-%g}H&VvPwCfM1{ zBCHQMdHKdE`o(B8(-j&v9j-7u!PqE5bQQ?Y;m$iMb9m#{c$0dr{%LB9Jn<9bg2Hy) zhz?XI)~oVxOInIBg<7ISN&p|hp|xuZ@8=ogj8&NDDOA2)fQaCnV zh~w>8FUmYoT>QkZPIuzj>rK+64uJaFv`$f8dSjqV$(pyfkF0i28UsOr91y0_OHL8F z!JzwsVbIK;CIXLmCN_#$a+=bF2_fR)pne^Y5o&xsS+>MqOwij;#mKjr*nQhWIZ971 z`=xPFE161t)Hs-=tb#}S*Usl9WD02X{#sv@>3ZQoK01qMJEG#e>1^`@j^SNd_{1S> zz~s=EW9DJoRVclityNSJNUGj68x96qAX6JFQF z&t_>Tfi!{EqlO*%>&f1<*|bOajbbg^A6@U}t9tE8K&L*mZtJzbG4ZAXEs7*l&D#1I z!$kZ~AXrWXvU+#qC6`AqNxHNyBzCuE-5!S$wl%2F)Snk~4S0CQOdA9(PUQAGW%PXU z++4|_17kG3++r$hbgl?P&VkJi!%OvMy>j1*3$=6w#X(Q%9i5(MUhV#57w{puXPa-PBy}zRX%`-hb%J zQ?+7uhiV2lpWBUxf1L2y^=$56x^XEx>tD zBN7mtZC@dRaOH7HnhG8XT2{0oMNjwOu9o6jg`;O`qka~;3OzO&l-iNLb?Ii)pt;#Yy&4aLd19bU?s7QkpA8dMK?!OZ&ZWc)2?>>*Wrz=2r+`w7n8aIEH>Jj zKeViG=I<35}?c03KHsP;6PSWL`fZ&A%i} z_KftEDd$30GfG#_a36jEu621vlj>lv3q?Sb#nf#4F8CMyeR~h^&+Zi*_I;qIh4;p7 zQLe5ajjLx8ca^W4drZ?e3}>X}-ZKu7^(U~gcmS+r{66K{#^86x5k51bgNGJM)s&Z6v$AidP(TA z?*41;S~HaQRN{d$L(CI27(b-WHcL-ICY_>V7g+y&aUYpL@$&v;KjqT3HnSM`b^FG{fi6?gc6te|nk zjC^!Wk8+_Iwc4}@05ol2mn-yx)WD%V0z-btEF8PU(#w86doTidf~^)}zn-zM?@CJu zP>>uLu*mP&#>Gws43^CMZ*MFQCh)*=)uKu0-kTy6j8<9MnUCG-&*3k`=kKQ$1p z(K|wwR!{`Ja3~PS_*9G(AcS&!`$(UyC6xZUs=Yj%x{f>O1~CXfZ)zh52RTojjKJKA(1L-&H$!rfZGd3X)P=%9f+N#%LkIt7cWqTc*Y)Mb!ejYcq0cA zS=NAyIIWw_fYaA+28i5?)7qcajx8cpIYf(i;lp*T=7GQ6M+ZR?wf03`f(U)=_bs0p z{ZR8lMR@OBTTOlfpMKl5W6Hf(fwA-D_3iA2qF`GojZ}B8doMb`n~hGa!;Z@(EulpU zw6UjPj5@%4hkaAswzNh6h02?=anA#A#x}detfuAZVJ_PdcIR^Ir(*{NKC<8Q%B&zq z!#3i2Anq`T@=fRjy_Cdb$Bqf*S}FMhe>)SrkIhpLiKPLDnkN8OWQ_lsI}3-Shp&(0 z4-M5;qwS%$*z);PrqU@rI3-+DmMKllrjygu-#=A0QWt$PvPjmK6d%?`kB%CM2D6P= zf`PrF7l4Eeu`oyS-}Zk_`>`Iy!i|GfdG}ZRuTenaJ?Q(9riaKGtP2}OavXX&?aB>! zJ#V2q)0b?&l;&JkOz(Mn70)dc17x^{qiA8HpUqd<*Ole-LrKYOM_y3QggHUe5|IHB zHYm?!IgcA3Iw6mEP7TD$Ctu0hBKJUa_rQQOJMi#*(+`&5Qyv8))8YJUlDUmopmUBI zciMG5%djdr-P<4tOe^JeRudVtZC~MaJP?~vlQODudjO@EOzuR(Jy3grqch$FC7<&U zS$vKno6*O5Q56>5(-1ew4!lb^_5#(`MLOntwo8)$?TL_Cs)bpi_A7oEO!R^4oZ9k(C43BOcr_fvqmok2qljJJc&>*Uq-)SCP|TeauI?P#P*sQ{PZs1qP!Ii#Op@g&8}l>!HU0W60>U4$Z7Vs|X>0Zt=| znGmIm^`%8zrsBFa!+~>h|Gl!7qtiKw)_>Ei&M7bKZpWXx+6k8s${izKf>b;QHq)dZ zLlJ`qjxiG9NkBFt8nnFfLkB^^xP7Ole|{;)RQYW6^)Y2g+!eaOW`6+9pXwLVO+qO@ z6kxv}7|2NbX=mtDCOOE3coAu`6S;-M02#?g1{|a*pxv;aO1(Zt((DJ?jlOo7xfv?p|}7IgiLTPp}GXz!FxUr0rL%t z#rF_60*D_8nqL8ob>FQAeEO#kk&v9Nul#wwhZPluH^+cN7|rYucDTOE)ID&Ew7*mA z(;@z&Y$FK=%f}E~TT>+p+$jiNEM79CiY-s$8k5Z^M5Pel8o7ajB_bNs!gGpA!dW@b zo9?V-7+`4-kqaK_*X$twV_v0r9nlMLp3-zOr~`0Dc<8Iyp;TmY$}^N2*hUalM>o=Q z3`kN5E9d@!t&E49JWhhU`yX)*GC#e7fTH#T_Tr-5TqgRW+Avr_<8V0}>-_&Lg7 z&s!ld;gL>!+f#3l5)1K2GC1RBN4ZuX);m;h1Q3uhSqHiP3V%6EP_{X8Gf`6pJnk0J zWjdm+a~FXs{rQP**CfOF1w0ugqLJlMqEjtZBUT^kAJF|1O8?-S)Q!AmrZIgN4c}A* z@%n=pmbU?(eQ!Kmr#qj7vWL?H6F`xUuupkJ88mRDSb)*snDvmo z;Pr#r-f$!*pvR~TVWd?w^@_1+Gy>6|*4E!*7JPX41%i~lAGa3wZ}}!#ly3!X&KxUF zL~YxT79|sTyHNJGj_fnNp*Zxh&4$xd?m4Ofdwk(GPW*YjH|qy#Lyti!W&M@3bg(Z^ zVUJuS%ekP0uUk6D4N8xv$oQmwZ%=a&d*C}E1Dgrx#l&BTKdIt*FGJ6S^)Rx13!FCp z5Zw@^_#&p9c0mtc$uAdRYO2Ci>lIGE7ON{;K?Ba57-=QzE8p>DRJkMS`^K!UvpHtn z8b2sq;h-E4u@eYos=CQb!8U(K4c5kjmaWRr1uZiO^O2o2?%X*AE8B4-fqG*<7Q|#r4IpQb52IIadQX zsdK_pcnk~)A}SW~f7l6{@*r|K9@LSM%Vu)G7`J>zCwef9 z-k=_XTf-zm1Z{Mgr zT4LAnwAa#Z3%*DBjZPlM1E!&1Q+LH3I|kr)eCvO}sIj-#PT4QUKYly*p^f*#*@Y1P*t&!q3^$g`0JePn2oVWMCP;B42fb~40Cf}{$gtE#Em&uE_AZpM2(wQA zea^}-(wKi#=c|1%%WNZ8aOsuaq?7-|4Qg`@mTh+R4Arbspa&haBz9^0F#|*&dSSa} zZBD8=qPP`rAsH6zNK-4*VWLbXzbMpIS;sb~BU+TCs+v?3(RiHpl<#F7xzk4jiuohh z#)7mcI;b&}N20v6WY1Xz6!zf~vj>q~rE-y#D@)B^TbZrY9e(Z+vo=)GzLT9hz4iR& zn|6KDY@eLXW?bw|n#+Zb*VTq@U&z^hQTBz`+k5Ndlhus-;{=aptIIsCg`rA6vbOntU5RYFCy7{FC8$ze0%p5Ppr~QE zXGqSmF>$gUe>n9juXj(L{8$#hS4QbgET;U>qm^!*(a});Q*k~k@2%cj$(+qDl`dVV zSKV3MeO*BMqVZn%`}!0m2TcQqg!{vLyTAN1FoHF;fXY3a)?=A`<0wF!A^{d2P0k7j z06M?+^-XW#$C-e09@BO3pYE6_uAJLR2*!bgn9d17^tyKce!cg<*}!de1i< z=~5%lnHy$yhrCz2CyI8i4jU~7*2T$GopkAMsBE(1dpzfebTTiItg5s`!!Ot=?KF zS}`X(RJ+H|ZNdqi=?l2He(%VOaN)d6O*O2;uBGmyU%B5@h%9HsMASD;c4Q28S7Xvb zemc-q+nMay=!`uoX+O>#_MFI^;^FOWtFdNQW8LZ3y{R!{H8mUIop(3hsg&o3x&I;! zbb4RyVd%MZZPrzC_p^o!Go+@WOZv3&y;=_L^KX7dgr2T=W)yP+4#=FkfI`XYlT|*3 zo9NiUmGN=D*jjD7UN)(c91(11+2eD%V)KlD)?J6QQ`>eJ{Ex(!YTmGkU-&YHuQE`b zP<50fp0xef8Q1B}#p)M!tdlC6yDw!v4B~xAL4$5qvor69WRhr6x^;M5-&$id8l8^@ zW1yH)#>$YkCYCL4)Sb=cQOvq{>0#;Yn|7r!EFr7fQ9_v*neB{MBUkr?OQ(6T`IKUt z)O$xohlI8z>X^%hrM+FgYE1VSpOd}bX|sD~Z0ycz$*YR@bhF`r1q_8cmx7?pH|4Y& zW&Pa9!o>Dw_Nd>K==A;qM_p>dF;%h;l=P8DKmH0M=wu*>{n9Z>!g`sX%u8uj3azrG zQSe$M;@q4}Kn2;Z)NEG)Y!QUI_9b%ZO}NXaE2Y%8w-b0TNX-uxG$^g5XcqyEuG2&B z{fTn&c9|$Obdnm;m$o!QLciK^E&s_>kDK+Mr8dT6%z*mMvGz$BZ15&E1%>lKP;LU8 z#E4I|KDU=2QsR9^iu|p*!%Q%rW!B!J;Bq0y7;VkE#eH955#?8Q3kb#3jwdj zM@h^&T?V+Wc@iDbW=nK6`JTsN`XTzslg;R>KVx2tl?}KIbe1iF001NRjdIqPvi2LB zYgJq|Mq*J_y9+&69l|~4UT0U8LS5;+=gv>#f<8;+ykx`zkei{Ha>QzEn=1~kH7(RQ z&b|KHC+2Wrr<|ViiIK|&&Ejk7n#)>usHi-bx0kCxguUT{7CKszP};q^pT?fgU?(Pwet#&{YpuVtJmpsN38yYC zr3O|0H%ADW95I8g!(LCyl5bX=a2a%ZkN6n4qxDc%f+wE{xIfVYlX}_$U zu(R#KB-Q6cujH*ij7j&ayMRJZe-w|3z4O~G^1OU^*Ok2V=Ku+44hXDYmUS0%oX*K^ ze4xZ~<@tOAtufPKVz`%R6EW~Ztk#lT}rC@Is&eJu}L;Oj5LKaN=h3BYEr-Ig~`-5`ML3t^=W-x9$qF}#!d0% zK)z8?AExTF+1Ge@TB*nmcdxu7;XfRJhIWyg8jwk>OVW@B)8uAjJ6=}^W{AQ~IiWd! z+V8=TwZJ8dFDYR7yPuyn$`Z1S6#28Cl#!HUbAL3l#OHlPe|P(Bs3;eHI?vd!`7@GnUk+bn!z9+I=eTu$l+g#mm{H4Rr4jkEW@zJY0GZjBQMwumN=>~3Y#k-At zPk!IX=)iRD90zOp+*^Cj!gw_3HB$4}Y~PaF=+aqSs0$rU^_UNpoUh_L%I{q|C(21p z#U0rZCNzmD9-EKP3(grf)^ZAa!yO)e`3=o}%2{TYfuM~gDdtiI_r-B`FvZr1tKY;=j9vHm>iZkfu^O~)@kth%dk zi(;l-^U>!0 zvoJ5PW55gaz)E8!c$)u*nndH3Js+5I8h+c@}|wh@n9 z`mzf;6fL1+5;$8P*Wna(71w_o8BFu?tgiS|(6b(|?5Rzh~-)D1hU zosq=#)z6IjD`~rHMT>Fg7zVv{irz!%oLq_mlId`etRQu;clz-SE9nIhcumOrLv3PX zVPZ3dbp}$mepZ&Z`0RD^I(gd&_LnWUe2?u3HBM41o6Vf%VWS8#uN2g|8@`60e2XC` zImMXw;!^_e#E06Fi>gmPbajLbf78zos$|LCPUyHM;>9Jsvz9%UA~_fJV=G3zpt?e8 zZnL|UyHs4jGgtdSi1Z+yCC9J0aA!{}WcMDk+_8qZ=NiBwZc{xHaeos) zOj^;KQ=WquEG=`qFn~N2sJ#)W=JxV#5;A2qy=^tT_cH~UF zoz6VRWL?I+1qa!tUjw_Hl@M$TMQq7{>hjtJA11HW=A(UAapyjl0Lgt?BnordEzoap*uCuTdQsaA#>!pi%VZns z8|E9o5MpxS4C`qg#7 z*`&%hlnJtb>p0W%y&e2aqE#wA$gLSB8;@Py_hDRYY|n6uE+7|cIHGKXbz8c@XSVTt zb9Hv5F}2U5v#-x9Ikj)pVPI@+deh354{MLBQPss-ey*6?yUgUy;FkF;Gu{WS#>mk1 z(Ye`G-Z~iE?@X5=nK8-u@Cff6*4`|i78yz!3W`!*YoGn?iVpkUcqa1poFPpw`Sx3( zPM*Zp1rVdHUvroWezE7RW-m=axBWH1%(MTrUy|n5($nRbz zgt5EhI~|2QdB@N}e72LO`(V&zRz7_I9qle^xwrhvZMbJvcrU;)eIQ?96-GP){>z1l zW^#YTGmk}~@%6V|x<_vGWiH*XzE^9k#pdq$y|(TNiALBs%Ufs?*$TzGfeQRTU#>$UZ%536ST z%I?Od8?F|;X9@NC^;HyTEP}7Z6K`Y0G*6o+!c1lIu(8pdvm4&OBWAX!Dh8+B+T{WA zlUJxiQ)eUhJTheBs|Q*6PtQm85~QxOxQ!14Vg`sfO9<@+1VkHuzK;wRwU^@Fb&5H& z81F+zEvYsYoI?2hliOE<8@He5@Dzi>!9$T71}6<)IhsU7o^fgupR9jN$olAuF{D9g zqC80y>TN~I9dl$<;c;-VhjBqi+Y{Lw1?VWq%yB*=@ySnR45l!`ggG8w|NbKK!Z{i? zvzYTOuc?rK{pTa>9Zoj3%Swf0s{eWuK9x|xU^-MXCk$Z_|MmS^2`UPTJdVqOT>p9# zu9gB)op49l>GGOzBhA62LN?bF+9%FA6}bbWJsab{^OL8*2%S}C`T zzs4X&>gxK$r{}XR1wEYYN4?!kZMVuQhp9EB&bW16wJ%bf{UBOoaf7ws>&_!%YFd^4 z)7#AgNtefa(FXme4}5OyHSY~tr7gdY9szCq1(z*+yyIBhd)Fsua%b8LIyvSUYN|}* zb~beHj$gYv;0@3w7ODH7P4oR+T*XfB+G3B(n+Sc^K0$}^gv94JmAvm<5pdCiQ3kZHih$#eeJ(UUxxiRMJ9mvuNyoC7ct;= z#`)8LA;-q`Yh#tC`RfMb;UcG~CH}lUiYZU9>&eKBLx0_13tS|gnE9`#ajph0wftTa z|F0XgfQz);Bm3)VB#dFCHq4&k{&j=Ja1jeR!oQy8e>>di+-y@ne=0EQN+4w^*8?I&UXHB$`W>=}q{c>bKF|F>>hlbT{>b(M7PGk6*6VYS(agy0h| zscSAJsgZ6Ao_XRjns6Z|2W!mzv`c+k+eT1$p`||dOYmPIe=R??jxCROj!+_l#8(Z_ z-~Qm7k$q=+#n`vBqO&Yl^dk3wi+BH{&by1MF(1lOj773bjkY_@i*HOOd_3`jwniw? zeqh7m_JyAf{QrD4yv+C_Cd$RM>~AmA05RYZS9{&_e=apaSlfa+KmMA+5I)GR=lY(9 z{b@_);5N*kc0Ty?-6Q%?1Q;yRrT^0$o4_MAsXgKN^WEe5V0W*~as~f1yeM#XQHd9n z{(M){5Z1fXdp(Q)GA_h@u$B1wvYm~K`19TLS74;05igqm@-UWg4NG3;ziihDyi^0o zo3KI zd+CpFpan^~&}c6&FMr8jYipL$P0Gy74xA~CH?|oV!@K;4l_kOl)`dkFatLc4I5U(l z+Sv(aM;rnuur{Z zYb?%(+EkS-4VsVJ%| :warning: Proxy mode is currently only supported in Camunda 8 SaaS environment. -You can configure the HTTP JSON Connector to do any outgoing HTTP call via a proxy. This proxy should be effectively also an HTTP JSON Connector +> :warning: This is for Camunda internal use only, do not mistake this with +> +the [general proxy configuration](https://docs.camunda.io/docs/components/connectors/protocol/rest/#configure-a-proxy-server-in-self-managed) +> available in +> Self-Managed. + +You can configure the HTTP JSON Connector to do any outgoing HTTP call via a proxy. This proxy should be effectively +also an HTTP JSON Connector running in a different environment. For example, you can build the following runtime architecture: @@ -155,10 +167,12 @@ For example, you can build the following runtime architecture: [ Camunda Network, e.g. K8S ] [ Separate network, e.g. Google Function ] ``` -Now, any call via the Http Connector will be just forwarded to a specified hardcoded URL. And this proxy does the real call then. +Now, any call via the Http Connector will be just forwarded to a specified hardcoded URL. And this proxy does the real +call then. This avoids that you could reach internal endpoints in your Camunda network (e.g. the current Kubernetes cluster). -Just set the following property to enable proxy mode for the connector, e.g. in application.properties when using the Spring-based runtime: +Just set the following property to enable proxy mode for the connector, e.g. in application.properties when using the +Spring-based runtime: ```properties camunda.connector.http.proxy.url=https://someUrl/ @@ -178,7 +192,8 @@ GOOGLE_APPLICATION_CREDENTIALS=... ### :lock: Test the Connector locally with Google Cloud Function as a proxy -Run the [:lock:connector-proxy-saas](https://github.com/camunda/connector-proxy-saas) project locally as described in its [:lock:README](https://github.com/camunda/connector-proxy-saas#usage). +Run the [:lock:connector-proxy-saas](https://github.com/camunda/connector-proxy-saas) project locally as described in +its [:lock:README](https://github.com/camunda/connector-proxy-saas#usage). Set the specific property or environment variable to enable proxy mode as described above. @@ -190,48 +205,66 @@ The generic HTTP JSON Connector element template can be found in the [element-templates/http-json-connector.json](element-templates/http-json-connector.json) file. Additional Connector templates based on the HTTP JSON Connector: + - [Automation Anywhere Connector](../automation-anywhere) - [Blue Prism Connector](../blue-prism) - [UiPath Connector](../uipath) - ## Properties + | Name | Type | Required | Description | Example | -| ------ | -------- | -------- | ----------- | -------------- | +|--------|----------|----------|-------------|----------------| | Method | Dropdown | Yes | | ```{ }``` | | URL | String | Yes | | ```"string"``` | + ## Result + The following json structure will be returned by the Connector and can be used in the result expression. ```json { - "body" : { - "order" : { - "id" : "123", - "total" : "100.00€" + "body": { + "order": { + "id": "123", + "total": "100.00€" } }, - "headers" : { - "Content-Type" : "application/json" + "document": { + "documentId": "977c5cbf-0f19-4a76-a8e1-60902216a07b", + "metadata": { + "contentType": "application/pdf", + "customProperties": { + "key": "value" + }, + "fileName": "theFileName.pdf", + "size": 516554 + }, + "storeId": "theStoreId", + "documentType": "camunda" }, - "status" : 200 + "headers": { + "Content-Type": "application/json" + }, + "status": 200 } ``` The body can be accessed via FEEL: + ```json = body.order.id ``` + leading to the following result + ```json "123" ``` - -| Connector Info | | -| --- | --- | -| Type | io.camunda:http-json:1 | -| Version | 8 | -| Supported element types | | +| Connector Info | | +|-------------------------|------------------------| +| Type | io.camunda:http-json:1 | +| Version | 9 | +| Supported element types | | diff --git a/connectors/http/rest/element-templates/http-json-connector.json b/connectors/http/rest/element-templates/http-json-connector.json index e368b87207..70a497234f 100644 --- a/connectors/http/rest/element-templates/http-json-connector.json +++ b/connectors/http/rest/element-templates/http-json-connector.json @@ -7,7 +7,7 @@ "keywords" : [ ] }, "documentationRef" : "https://docs.camunda.io/docs/components/connectors/protocol/rest/", - "version" : 8, + "version" : 9, "category" : { "id" : "connectors", "name" : "Connectors" @@ -390,6 +390,18 @@ "type" : "zeebe:input" }, "type" : "String" + }, { + "id" : "storeResponse", + "label" : "Store response", + "description" : "Store the response as a document in the document store", + "optional" : false, + "value" : false, + "group" : "endpoint", + "binding" : { + "name" : "storeResponse", + "type" : "zeebe:input" + }, + "type" : "Boolean" }, { "id" : "connectionTimeoutInSeconds", "label" : "Connection timeout in seconds", diff --git a/connectors/http/rest/element-templates/hybrid/http-json-connector-hybrid.json b/connectors/http/rest/element-templates/hybrid/http-json-connector-hybrid.json index 3e8c91c1bd..39a698a790 100644 --- a/connectors/http/rest/element-templates/hybrid/http-json-connector-hybrid.json +++ b/connectors/http/rest/element-templates/hybrid/http-json-connector-hybrid.json @@ -7,7 +7,7 @@ "keywords" : [ ] }, "documentationRef" : "https://docs.camunda.io/docs/components/connectors/protocol/rest/", - "version" : 8, + "version" : 9, "category" : { "id" : "connectors", "name" : "Connectors" @@ -395,6 +395,18 @@ "type" : "zeebe:input" }, "type" : "String" + }, { + "id" : "storeResponse", + "label" : "Store response", + "description" : "Store the response as a document in the document store", + "optional" : false, + "value" : false, + "group" : "endpoint", + "binding" : { + "name" : "storeResponse", + "type" : "zeebe:input" + }, + "type" : "Boolean" }, { "id" : "connectionTimeoutInSeconds", "label" : "Connection timeout in seconds", diff --git a/connectors/http/rest/src/main/java/io/camunda/connector/http/rest/HttpJsonFunction.java b/connectors/http/rest/src/main/java/io/camunda/connector/http/rest/HttpJsonFunction.java index b0c1eda353..853ea26799 100644 --- a/connectors/http/rest/src/main/java/io/camunda/connector/http/rest/HttpJsonFunction.java +++ b/connectors/http/rest/src/main/java/io/camunda/connector/http/rest/HttpJsonFunction.java @@ -36,7 +36,8 @@ "connectionTimeoutInSeconds", "readTimeoutInSeconds", "writeTimeoutInSeconds", - "body" + "body", + "storeResponse" }, type = HttpJsonFunction.TYPE) @ElementTemplate( @@ -45,7 +46,7 @@ description = "Invoke REST API", inputDataClass = HttpJsonRequest.class, outputDataClass = HttpCommonResult.class, - version = 8, + version = 9, propertyGroups = { @PropertyGroup(id = "authentication", label = "Authentication"), @PropertyGroup(id = "endpoint", label = "HTTP endpoint"), @@ -71,6 +72,6 @@ public HttpJsonFunction() { @Override public Object execute(final OutboundConnectorContext context) { final var request = context.bindVariables(HttpJsonRequest.class); - return httpService.executeConnectorRequest(request); + return httpService.executeConnectorRequest(request, context); } } From 96539667be32bcc08b9dbedde54bf9c58debc6af Mon Sep 17 00:00:00 2001 From: Simon Date: Tue, 7 Jan 2025 17:05:14 +0100 Subject: [PATCH 2/3] feat(box-connector): Box Connector (#3753) * fix(box-connector): Add first version of the Box Connector * feat(box): Add move, delete operations. Minor refactorings * feat(box): Element template refinements --- bundle/default-bundle/pom.xml | 4 + bundle/pom.xml | 5 + connectors/box/LICENSE.txt | 5 + .../box-outbound-connector.json | 620 +++++++++++++++++ .../hybrid/box-outbound-connector-hybrid.json | 625 ++++++++++++++++++ connectors/box/pom.xml | 68 ++ .../io/camunda/connector/box/BoxFunction.java | 39 ++ .../camunda/connector/box/BoxOperations.java | 138 ++++ .../io/camunda/connector/box/BoxUtil.java | 88 +++ .../camunda/connector/box/model/BoxPath.java | 52 ++ .../connector/box/model/BoxRequest.java | 268 ++++++++ .../connector/box/model/BoxResult.java | 23 + ...tor.api.outbound.OutboundConnectorFunction | 1 + connectors/box/src/main/resources/icon.svg | 3 + .../io/camunda/connector/BoxPathTests.java | 53 ++ connectors/pom.xml | 1 + parent/pom.xml | 8 + 17 files changed, 2001 insertions(+) create mode 100644 connectors/box/LICENSE.txt create mode 100644 connectors/box/element-templates/box-outbound-connector.json create mode 100644 connectors/box/element-templates/hybrid/box-outbound-connector-hybrid.json create mode 100644 connectors/box/pom.xml create mode 100644 connectors/box/src/main/java/io/camunda/connector/box/BoxFunction.java create mode 100644 connectors/box/src/main/java/io/camunda/connector/box/BoxOperations.java create mode 100644 connectors/box/src/main/java/io/camunda/connector/box/BoxUtil.java create mode 100644 connectors/box/src/main/java/io/camunda/connector/box/model/BoxPath.java create mode 100644 connectors/box/src/main/java/io/camunda/connector/box/model/BoxRequest.java create mode 100644 connectors/box/src/main/java/io/camunda/connector/box/model/BoxResult.java create mode 100644 connectors/box/src/main/resources/META-INF/services/io.camunda.connector.api.outbound.OutboundConnectorFunction create mode 100644 connectors/box/src/main/resources/icon.svg create mode 100644 connectors/box/src/test/java/io/camunda/connector/BoxPathTests.java diff --git a/bundle/default-bundle/pom.xml b/bundle/default-bundle/pom.xml index d125eaa4a2..9a293463d2 100644 --- a/bundle/default-bundle/pom.xml +++ b/bundle/default-bundle/pom.xml @@ -45,6 +45,10 @@ io.camunda.connector connector-aws-textract + + io.camunda.connector + connector-box + io.camunda.connector connector-google-drive diff --git a/bundle/pom.xml b/bundle/pom.xml index b87a055f53..f28e0c3218 100644 --- a/bundle/pom.xml +++ b/bundle/pom.xml @@ -69,6 +69,11 @@ connector-aws-sns ${project.version} + + io.camunda.connector + connector-box + ${project.version} + io.camunda.connector connector-aws-textract diff --git a/connectors/box/LICENSE.txt b/connectors/box/LICENSE.txt new file mode 100644 index 0000000000..85fdd16e79 --- /dev/null +++ b/connectors/box/LICENSE.txt @@ -0,0 +1,5 @@ +Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH under one or more contributor license agreements and licensed to you under a proprietary license. +You may not use this file except in compliance with the proprietary license. +The proprietary license can be either the Camunda Self-Managed Free Edition license (available on Camunda’s website) or the Camunda Self-Managed Enterprise Edition license (a copy you obtain when you contact Camunda). +The Camunda Self-Managed Free Edition comes for free but only allows for usage of the software (file) in non-production environments. +If you want to use the software (file) in production, you need to purchase the Camunda Self-Managed Enterprise Edition. \ No newline at end of file diff --git a/connectors/box/element-templates/box-outbound-connector.json b/connectors/box/element-templates/box-outbound-connector.json new file mode 100644 index 0000000000..8ff08fa21e --- /dev/null +++ b/connectors/box/element-templates/box-outbound-connector.json @@ -0,0 +1,620 @@ +{ + "$schema" : "https://unpkg.com/@camunda/zeebe-element-templates-json-schema/resources/schema.json", + "name" : "Box Outbound Connector", + "id" : "io.camunda.connectors.box", + "description" : "Interact with the Box Document API", + "metadata" : { + "keywords" : [ ] + }, + "documentationRef" : "https://docs.camunda.io/docs/components/connectors/out-of-the-box-connectors/box/", + "version" : 1, + "category" : { + "id" : "connectors", + "name" : "Connectors" + }, + "appliesTo" : [ "bpmn:Task" ], + "elementType" : { + "value" : "bpmn:ServiceTask" + }, + "groups" : [ { + "id" : "authentication", + "label" : "Authentication" + }, { + "id" : "operation", + "label" : "Operation" + }, { + "id" : "output", + "label" : "Output mapping" + }, { + "id" : "error", + "label" : "Error handling" + }, { + "id" : "retries", + "label" : "Retries" + } ], + "properties" : [ { + "value" : "io.camunda:box:1", + "binding" : { + "property" : "type", + "type" : "zeebe:taskDefinition" + }, + "type" : "Hidden" + }, { + "id" : "authentication.type", + "label" : "Authentication", + "description" : "Specify authentication strategy. Learn more at the documentation page", + "value" : "developerToken", + "group" : "authentication", + "binding" : { + "name" : "authentication.type", + "type" : "zeebe:input" + }, + "type" : "Dropdown", + "choices" : [ { + "name" : "Client Credentials Enterprise", + "value" : "clientCredentialsEnterprise" + }, { + "name" : "Client Credentials User", + "value" : "clientCredentialsUser" + }, { + "name" : "Developer token", + "value" : "developerToken" + }, { + "name" : "JWT JSON Config", + "value" : "jwtJsonConfig" + } ] + }, { + "id" : "authentication.clientIdEnterprise", + "label" : "Client id", + "description" : "The client id", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "authentication", + "binding" : { + "name" : "authentication.clientId", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "authentication.type", + "equals" : "clientCredentialsEnterprise", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "authentication.clientSecretEnterprise", + "label" : "Client secret", + "description" : "The client secret", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "authentication", + "binding" : { + "name" : "authentication.clientSecret", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "authentication.type", + "equals" : "clientCredentialsEnterprise", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "authentication.enterpriseId", + "label" : "Enterprise ID", + "description" : "The enterprise ID to authenticate against", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "authentication", + "binding" : { + "name" : "authentication.enterpriseId", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "authentication.type", + "equals" : "clientCredentialsEnterprise", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "authentication.clientIdUser", + "label" : "Client id", + "description" : "The client id", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "authentication", + "binding" : { + "name" : "authentication.clientId", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "authentication.type", + "equals" : "clientCredentialsUser", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "authentication.clientSecretUser", + "label" : "Client secret", + "description" : "The client secret", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "authentication", + "binding" : { + "name" : "authentication.clientSecret", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "authentication.type", + "equals" : "clientCredentialsUser", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "authentication.userId", + "label" : "User ID", + "description" : "The user ID to of the account to authenticate against", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "authentication", + "binding" : { + "name" : "authentication.userId", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "authentication.type", + "equals" : "clientCredentialsUser", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "authentication.accessToken", + "label" : "Access key", + "description" : "The access key or developer token", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "authentication", + "binding" : { + "name" : "authentication.accessToken", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "authentication.type", + "equals" : "developerToken", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "authentication.jsonConfig", + "label" : "JSON config", + "description" : "The JSON config as string", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "authentication", + "binding" : { + "name" : "authentication.jsonConfig", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "authentication.type", + "equals" : "jwtJsonConfig", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "operation.type", + "label" : "Operation", + "description" : "The operation to execute.", + "value" : "createFolder", + "group" : "operation", + "binding" : { + "name" : "operation.type", + "type" : "zeebe:input" + }, + "type" : "Dropdown", + "choices" : [ { + "name" : "Create Folder", + "value" : "createFolder" + }, { + "name" : "Delete Folder", + "value" : "deleteFolder" + }, { + "name" : "Upload File", + "value" : "uploadFile" + }, { + "name" : "Download File", + "value" : "downloadFile" + }, { + "name" : "Move File", + "value" : "moveFile" + }, { + "name" : "Delete File", + "value" : "deleteFile" + }, { + "name" : "Search", + "value" : "search" + } ] + }, { + "id" : "operation.createFolderName", + "label" : "Folder name", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "operation", + "binding" : { + "name" : "operation.name", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "operation.type", + "equals" : "createFolder", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "operation.createFolderParentPath", + "label" : "Parent path", + "optional" : false, + "value" : "/", + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "operation", + "binding" : { + "name" : "operation.folderPath", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "operation.type", + "equals" : "createFolder", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "operation.deleteFolderPath", + "label" : "Folder path", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "operation", + "binding" : { + "name" : "operation.folderPath", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "operation.type", + "equals" : "deleteFolder", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "operation.recursive", + "label" : "Recursive", + "description" : "Deletes all items contained by the folder", + "optional" : false, + "value" : "true", + "feel" : "optional", + "group" : "operation", + "binding" : { + "name" : "operation.recursive", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "operation.type", + "equals" : "deleteFolder", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "operation.uploadFileName", + "label" : "File name", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "operation", + "binding" : { + "name" : "operation.name", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "operation.type", + "equals" : "uploadFile", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "operation.uploadFileFolderPath", + "label" : "Folder path", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "operation", + "binding" : { + "name" : "operation.folderPath", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "operation.type", + "equals" : "uploadFile", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "operation.uploadFileDocument", + "label" : "Document reference", + "description" : "The document reference that will be uploaded", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "required", + "group" : "operation", + "binding" : { + "name" : "operation.document", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "operation.type", + "equals" : "uploadFile", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "operation.downloadFilePath", + "label" : "File path", + "description" : "Path to the file item to download", + "optional" : false, + "feel" : "optional", + "group" : "operation", + "binding" : { + "name" : "operation.filePath", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "operation.type", + "equals" : "downloadFile", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "operation.moveFilePath", + "label" : "File path", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "operation", + "binding" : { + "name" : "operation.filePath", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "operation.type", + "equals" : "moveFile", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "operation.moveFileFolderPath", + "label" : "Target folder path", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "operation", + "binding" : { + "name" : "operation.folderPath", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "operation.type", + "equals" : "moveFile", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "operation.deleteFilePath", + "label" : "File path", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "operation", + "binding" : { + "name" : "operation.filePath", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "operation.type", + "equals" : "deleteFile", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "operation.searchQuery", + "label" : "Search query", + "optional" : false, + "feel" : "optional", + "group" : "operation", + "binding" : { + "name" : "operation.query", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "operation.type", + "equals" : "search", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "operation.searchSortColumn", + "label" : "Search sort column", + "description" : "Column for sorting search results", + "optional" : false, + "value" : "modified_at", + "feel" : "optional", + "group" : "operation", + "binding" : { + "name" : "operation.sortColumn", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "operation.type", + "equals" : "search", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "operation.searchSortDirection", + "label" : "Search sort direction", + "description" : "Direction for sorting search results", + "optional" : false, + "value" : "DESC", + "group" : "operation", + "binding" : { + "name" : "operation.sortDirection", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "operation.type", + "equals" : "search", + "type" : "simple" + }, + "type" : "Dropdown", + "choices" : [ { + "name" : "ASC", + "value" : "ASC" + }, { + "name" : "DESC", + "value" : "DESC" + } ] + }, { + "id" : "operation.searchOffset", + "label" : "Search offset", + "description" : "Offset for search results", + "optional" : false, + "value" : "0", + "feel" : "optional", + "group" : "operation", + "binding" : { + "name" : "operation.offset", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "operation.type", + "equals" : "search", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "operation.searchLimit", + "label" : "Search limit", + "description" : "Limit", + "optional" : false, + "value" : "30", + "feel" : "optional", + "group" : "operation", + "binding" : { + "name" : "operation.limit", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "operation.type", + "equals" : "search", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "resultVariable", + "label" : "Result variable", + "description" : "Name of variable to store the response in", + "group" : "output", + "binding" : { + "key" : "resultVariable", + "type" : "zeebe:taskHeader" + }, + "type" : "String" + }, { + "id" : "resultExpression", + "label" : "Result expression", + "description" : "Expression to map the response into process variables", + "feel" : "required", + "group" : "output", + "binding" : { + "key" : "resultExpression", + "type" : "zeebe:taskHeader" + }, + "type" : "Text" + }, { + "id" : "errorExpression", + "label" : "Error expression", + "description" : "Expression to handle errors. Details in the documentation.", + "feel" : "required", + "group" : "error", + "binding" : { + "key" : "errorExpression", + "type" : "zeebe:taskHeader" + }, + "type" : "Text" + }, { + "id" : "retryCount", + "label" : "Retries", + "description" : "Number of retries", + "value" : "3", + "feel" : "optional", + "group" : "retries", + "binding" : { + "property" : "retries", + "type" : "zeebe:taskDefinition" + }, + "type" : "String" + }, { + "id" : "retryBackoff", + "label" : "Retry backoff", + "description" : "ISO-8601 duration to wait between retries", + "value" : "PT0S", + "group" : "retries", + "binding" : { + "key" : "retryBackoff", + "type" : "zeebe:taskHeader" + }, + "type" : "String" + } ], + "icon" : { + "contents" : "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTE1LjY5MjkgMTEuMjAwMkMxNS44ODggMTEuNDczNCAxNS44NDkgMTEuODI0NiAxNS42MTQ4IDEyLjAxOTdDMTUuMzQxNyAxMi4yMTQ4IDE0Ljk1MTQgMTIuMTc1OCAxNC43NTYzIDExLjk0MTdMMTMuMzkwNCAxMC4xODU2TDEyLjA2MzYgMTEuOTAyNkMxMS44Njg1IDEyLjE3NTggMTEuNDc4MiAxMi4xNzU4IDExLjIwNTEgMTEuOTgwN0MxMC45MzE5IDExLjc4NTYgMTAuODkyOSAxMS40MzQzIDExLjA4OCAxMS4xNjEyTDEyLjY0OSA5LjEzMTkxTDExLjA4OCA3LjEwMjY0QzEwLjg5MjkgNi44Mjk0NyAxMC45NzA5IDYuNDM5MjMgMTEuMjA1MSA2LjI0NDFDMTEuNDc4MiA2LjA0ODk4IDExLjg2ODUgNi4xMjcwMyAxMi4wNjM2IDYuMzYxMThMMTMuMzkwNCA4LjExNzI4TDE0Ljc1NjMgNi40MzkyM0MxNC45NTE0IDYuMTY2MDYgMTUuMzAyNiA2LjEyNzAzIDE1LjYxNDggNi4zMjIxNUMxNS44ODggNi41MTcyOCAxNS44ODggNi45MDc1MiAxNS42OTI5IDcuMTgwNjlMMTQuMTcwOSA5LjE3MDkzTDE1LjY5MjkgMTEuMjAwMlpNOC41OTA0NCAxMC45NjYxQzcuNTc1OCAxMC45NjYxIDYuNzU2MjkgMTAuMTg1NiA2Ljc1NjI5IDkuMTcwOTNDNi43NTYyOSA4LjE5NTMyIDcuNTc1OCA3LjM3NTgxIDguNTkwNDQgNy4zNzU4MUM5LjYwNTA3IDcuMzc1ODEgMTAuNDI0NiA4LjE5NTMyIDEwLjQyNDYgOS4xNzA5M0MxMC4zODU2IDEwLjE4NTYgOS41NjYwNSAxMC45NjYxIDguNTkwNDQgMTAuOTY2MVpNMy4yMDUwNyAxMC45NjYxQzIuMTkwNDQgMTAuOTY2MSAxLjM3MDkzIDEwLjE4NTYgMS4zNzA5MyA5LjE3MDkzQzEuMzcwOTMgOC4xOTUzMiAyLjE5MDQ0IDcuMzc1ODEgMy4yMDUwNyA3LjM3NTgxQzQuMjE5NzEgNy4zNzU4MSA1LjAzOTIyIDguMTk1MzIgNS4wMzkyMiA5LjE3MDkzQzUuMDM5MjIgMTAuMTg1NiA0LjIxOTcxIDEwLjk2NjEgMy4yMDUwNyAxMC45NjYxWk04LjU5MDQ0IDYuMjA1MDhDNy40NTg3MyA2LjIwNTA4IDYuNDQ0MSA2LjgyOTQ3IDUuOTM2NzggNy43NjYwNkM1LjQyOTQ2IDYuODI5NDcgNC40MTQ4MyA2LjIwNTA4IDMuMjQ0MSA2LjIwNTA4QzIuNTQxNjYgNi4yMDUwOCAxLjkxNzI3IDYuNDM5MjMgMS40MDk5NSA2Ljc5MDQ1VjQuMjkyODlDMS40MDk5NSAzLjk4MDY5IDEuMTM2NzggMy43MDc1MiAwLjgyNDU4NiAzLjcwNzUyQzAuNDczMzY2IDMuNzA3NTIgMC4yMDAxOTUgMy45ODA2OSAwLjIwMDE5NSA0LjI5Mjg5VjkuMjA5OTZDMC4yMzkyMiAxMC44NDkgMS41NjYwNSAxMi4xMzY4IDMuMjA1MDcgMTIuMTM2OEM0LjM3NTggMTIuMTM2OCA1LjM5MDQ0IDExLjQ3MzQgNS44OTc3NiAxMC41MzY4QzYuNDA1MDcgMTEuNDczNCA3LjQxOTcxIDEyLjEzNjggOC41NTE0MSAxMi4xMzY4QzEwLjIyOTUgMTIuMTM2OCAxMS41OTUzIDEwLjgxIDExLjU5NTMgOS4xMzE5MUMxMS42MzQzIDcuNTMxOTEgMTAuMjY4NSA2LjIwNTA4IDguNTkwNDQgNi4yMDUwOFoiIGZpbGw9IiMwMDcxRjciLz4KPC9zdmc+Cg==" + } +} \ No newline at end of file diff --git a/connectors/box/element-templates/hybrid/box-outbound-connector-hybrid.json b/connectors/box/element-templates/hybrid/box-outbound-connector-hybrid.json new file mode 100644 index 0000000000..db12543a9c --- /dev/null +++ b/connectors/box/element-templates/hybrid/box-outbound-connector-hybrid.json @@ -0,0 +1,625 @@ +{ + "$schema" : "https://unpkg.com/@camunda/zeebe-element-templates-json-schema/resources/schema.json", + "name" : "Hybrid Box Outbound Connector", + "id" : "io.camunda.connectors.box-hybrid", + "description" : "Interact with the Box Document API", + "metadata" : { + "keywords" : [ ] + }, + "documentationRef" : "https://docs.camunda.io/docs/components/connectors/out-of-the-box-connectors/box/", + "version" : 1, + "category" : { + "id" : "connectors", + "name" : "Connectors" + }, + "appliesTo" : [ "bpmn:Task" ], + "elementType" : { + "value" : "bpmn:ServiceTask" + }, + "groups" : [ { + "id" : "taskDefinitionType", + "label" : "Task definition type" + }, { + "id" : "authentication", + "label" : "Authentication" + }, { + "id" : "operation", + "label" : "Operation" + }, { + "id" : "output", + "label" : "Output mapping" + }, { + "id" : "error", + "label" : "Error handling" + }, { + "id" : "retries", + "label" : "Retries" + } ], + "properties" : [ { + "id" : "taskDefinitionType", + "value" : "io.camunda:box:1", + "group" : "taskDefinitionType", + "binding" : { + "property" : "type", + "type" : "zeebe:taskDefinition" + }, + "type" : "String" + }, { + "id" : "authentication.type", + "label" : "Authentication", + "description" : "Specify authentication strategy. Learn more at the documentation page", + "value" : "developerToken", + "group" : "authentication", + "binding" : { + "name" : "authentication.type", + "type" : "zeebe:input" + }, + "type" : "Dropdown", + "choices" : [ { + "name" : "Client Credentials Enterprise", + "value" : "clientCredentialsEnterprise" + }, { + "name" : "Client Credentials User", + "value" : "clientCredentialsUser" + }, { + "name" : "Developer token", + "value" : "developerToken" + }, { + "name" : "JWT JSON Config", + "value" : "jwtJsonConfig" + } ] + }, { + "id" : "authentication.clientIdEnterprise", + "label" : "Client id", + "description" : "The client id", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "authentication", + "binding" : { + "name" : "authentication.clientId", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "authentication.type", + "equals" : "clientCredentialsEnterprise", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "authentication.clientSecretEnterprise", + "label" : "Client secret", + "description" : "The client secret", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "authentication", + "binding" : { + "name" : "authentication.clientSecret", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "authentication.type", + "equals" : "clientCredentialsEnterprise", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "authentication.enterpriseId", + "label" : "Enterprise ID", + "description" : "The enterprise ID to authenticate against", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "authentication", + "binding" : { + "name" : "authentication.enterpriseId", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "authentication.type", + "equals" : "clientCredentialsEnterprise", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "authentication.clientIdUser", + "label" : "Client id", + "description" : "The client id", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "authentication", + "binding" : { + "name" : "authentication.clientId", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "authentication.type", + "equals" : "clientCredentialsUser", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "authentication.clientSecretUser", + "label" : "Client secret", + "description" : "The client secret", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "authentication", + "binding" : { + "name" : "authentication.clientSecret", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "authentication.type", + "equals" : "clientCredentialsUser", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "authentication.userId", + "label" : "User ID", + "description" : "The user ID to of the account to authenticate against", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "authentication", + "binding" : { + "name" : "authentication.userId", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "authentication.type", + "equals" : "clientCredentialsUser", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "authentication.accessToken", + "label" : "Access key", + "description" : "The access key or developer token", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "authentication", + "binding" : { + "name" : "authentication.accessToken", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "authentication.type", + "equals" : "developerToken", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "authentication.jsonConfig", + "label" : "JSON config", + "description" : "The JSON config as string", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "authentication", + "binding" : { + "name" : "authentication.jsonConfig", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "authentication.type", + "equals" : "jwtJsonConfig", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "operation.type", + "label" : "Operation", + "description" : "The operation to execute.", + "value" : "createFolder", + "group" : "operation", + "binding" : { + "name" : "operation.type", + "type" : "zeebe:input" + }, + "type" : "Dropdown", + "choices" : [ { + "name" : "Create Folder", + "value" : "createFolder" + }, { + "name" : "Delete Folder", + "value" : "deleteFolder" + }, { + "name" : "Upload File", + "value" : "uploadFile" + }, { + "name" : "Download File", + "value" : "downloadFile" + }, { + "name" : "Move File", + "value" : "moveFile" + }, { + "name" : "Delete File", + "value" : "deleteFile" + }, { + "name" : "Search", + "value" : "search" + } ] + }, { + "id" : "operation.createFolderName", + "label" : "Folder name", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "operation", + "binding" : { + "name" : "operation.name", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "operation.type", + "equals" : "createFolder", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "operation.createFolderParentPath", + "label" : "Parent path", + "optional" : false, + "value" : "/", + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "operation", + "binding" : { + "name" : "operation.folderPath", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "operation.type", + "equals" : "createFolder", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "operation.deleteFolderPath", + "label" : "Folder path", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "operation", + "binding" : { + "name" : "operation.folderPath", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "operation.type", + "equals" : "deleteFolder", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "operation.recursive", + "label" : "Recursive", + "description" : "Deletes all items contained by the folder", + "optional" : false, + "value" : "true", + "feel" : "optional", + "group" : "operation", + "binding" : { + "name" : "operation.recursive", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "operation.type", + "equals" : "deleteFolder", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "operation.uploadFileName", + "label" : "File name", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "operation", + "binding" : { + "name" : "operation.name", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "operation.type", + "equals" : "uploadFile", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "operation.uploadFileFolderPath", + "label" : "Folder path", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "operation", + "binding" : { + "name" : "operation.folderPath", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "operation.type", + "equals" : "uploadFile", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "operation.uploadFileDocument", + "label" : "Document reference", + "description" : "The document reference that will be uploaded", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "required", + "group" : "operation", + "binding" : { + "name" : "operation.document", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "operation.type", + "equals" : "uploadFile", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "operation.downloadFilePath", + "label" : "File path", + "description" : "Path to the file item to download", + "optional" : false, + "feel" : "optional", + "group" : "operation", + "binding" : { + "name" : "operation.filePath", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "operation.type", + "equals" : "downloadFile", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "operation.moveFilePath", + "label" : "File path", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "operation", + "binding" : { + "name" : "operation.filePath", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "operation.type", + "equals" : "moveFile", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "operation.moveFileFolderPath", + "label" : "Target folder path", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "operation", + "binding" : { + "name" : "operation.folderPath", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "operation.type", + "equals" : "moveFile", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "operation.deleteFilePath", + "label" : "File path", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "operation", + "binding" : { + "name" : "operation.filePath", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "operation.type", + "equals" : "deleteFile", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "operation.searchQuery", + "label" : "Search query", + "optional" : false, + "feel" : "optional", + "group" : "operation", + "binding" : { + "name" : "operation.query", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "operation.type", + "equals" : "search", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "operation.searchSortColumn", + "label" : "Search sort column", + "description" : "Column for sorting search results", + "optional" : false, + "value" : "modified_at", + "feel" : "optional", + "group" : "operation", + "binding" : { + "name" : "operation.sortColumn", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "operation.type", + "equals" : "search", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "operation.searchSortDirection", + "label" : "Search sort direction", + "description" : "Direction for sorting search results", + "optional" : false, + "value" : "DESC", + "group" : "operation", + "binding" : { + "name" : "operation.sortDirection", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "operation.type", + "equals" : "search", + "type" : "simple" + }, + "type" : "Dropdown", + "choices" : [ { + "name" : "ASC", + "value" : "ASC" + }, { + "name" : "DESC", + "value" : "DESC" + } ] + }, { + "id" : "operation.searchOffset", + "label" : "Search offset", + "description" : "Offset for search results", + "optional" : false, + "value" : "0", + "feel" : "optional", + "group" : "operation", + "binding" : { + "name" : "operation.offset", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "operation.type", + "equals" : "search", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "operation.searchLimit", + "label" : "Search limit", + "description" : "Limit", + "optional" : false, + "value" : "30", + "feel" : "optional", + "group" : "operation", + "binding" : { + "name" : "operation.limit", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "operation.type", + "equals" : "search", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "resultVariable", + "label" : "Result variable", + "description" : "Name of variable to store the response in", + "group" : "output", + "binding" : { + "key" : "resultVariable", + "type" : "zeebe:taskHeader" + }, + "type" : "String" + }, { + "id" : "resultExpression", + "label" : "Result expression", + "description" : "Expression to map the response into process variables", + "feel" : "required", + "group" : "output", + "binding" : { + "key" : "resultExpression", + "type" : "zeebe:taskHeader" + }, + "type" : "Text" + }, { + "id" : "errorExpression", + "label" : "Error expression", + "description" : "Expression to handle errors. Details in the documentation.", + "feel" : "required", + "group" : "error", + "binding" : { + "key" : "errorExpression", + "type" : "zeebe:taskHeader" + }, + "type" : "Text" + }, { + "id" : "retryCount", + "label" : "Retries", + "description" : "Number of retries", + "value" : "3", + "feel" : "optional", + "group" : "retries", + "binding" : { + "property" : "retries", + "type" : "zeebe:taskDefinition" + }, + "type" : "String" + }, { + "id" : "retryBackoff", + "label" : "Retry backoff", + "description" : "ISO-8601 duration to wait between retries", + "value" : "PT0S", + "group" : "retries", + "binding" : { + "key" : "retryBackoff", + "type" : "zeebe:taskHeader" + }, + "type" : "String" + } ], + "icon" : { + "contents" : "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTE1LjY5MjkgMTEuMjAwMkMxNS44ODggMTEuNDczNCAxNS44NDkgMTEuODI0NiAxNS42MTQ4IDEyLjAxOTdDMTUuMzQxNyAxMi4yMTQ4IDE0Ljk1MTQgMTIuMTc1OCAxNC43NTYzIDExLjk0MTdMMTMuMzkwNCAxMC4xODU2TDEyLjA2MzYgMTEuOTAyNkMxMS44Njg1IDEyLjE3NTggMTEuNDc4MiAxMi4xNzU4IDExLjIwNTEgMTEuOTgwN0MxMC45MzE5IDExLjc4NTYgMTAuODkyOSAxMS40MzQzIDExLjA4OCAxMS4xNjEyTDEyLjY0OSA5LjEzMTkxTDExLjA4OCA3LjEwMjY0QzEwLjg5MjkgNi44Mjk0NyAxMC45NzA5IDYuNDM5MjMgMTEuMjA1MSA2LjI0NDFDMTEuNDc4MiA2LjA0ODk4IDExLjg2ODUgNi4xMjcwMyAxMi4wNjM2IDYuMzYxMThMMTMuMzkwNCA4LjExNzI4TDE0Ljc1NjMgNi40MzkyM0MxNC45NTE0IDYuMTY2MDYgMTUuMzAyNiA2LjEyNzAzIDE1LjYxNDggNi4zMjIxNUMxNS44ODggNi41MTcyOCAxNS44ODggNi45MDc1MiAxNS42OTI5IDcuMTgwNjlMMTQuMTcwOSA5LjE3MDkzTDE1LjY5MjkgMTEuMjAwMlpNOC41OTA0NCAxMC45NjYxQzcuNTc1OCAxMC45NjYxIDYuNzU2MjkgMTAuMTg1NiA2Ljc1NjI5IDkuMTcwOTNDNi43NTYyOSA4LjE5NTMyIDcuNTc1OCA3LjM3NTgxIDguNTkwNDQgNy4zNzU4MUM5LjYwNTA3IDcuMzc1ODEgMTAuNDI0NiA4LjE5NTMyIDEwLjQyNDYgOS4xNzA5M0MxMC4zODU2IDEwLjE4NTYgOS41NjYwNSAxMC45NjYxIDguNTkwNDQgMTAuOTY2MVpNMy4yMDUwNyAxMC45NjYxQzIuMTkwNDQgMTAuOTY2MSAxLjM3MDkzIDEwLjE4NTYgMS4zNzA5MyA5LjE3MDkzQzEuMzcwOTMgOC4xOTUzMiAyLjE5MDQ0IDcuMzc1ODEgMy4yMDUwNyA3LjM3NTgxQzQuMjE5NzEgNy4zNzU4MSA1LjAzOTIyIDguMTk1MzIgNS4wMzkyMiA5LjE3MDkzQzUuMDM5MjIgMTAuMTg1NiA0LjIxOTcxIDEwLjk2NjEgMy4yMDUwNyAxMC45NjYxWk04LjU5MDQ0IDYuMjA1MDhDNy40NTg3MyA2LjIwNTA4IDYuNDQ0MSA2LjgyOTQ3IDUuOTM2NzggNy43NjYwNkM1LjQyOTQ2IDYuODI5NDcgNC40MTQ4MyA2LjIwNTA4IDMuMjQ0MSA2LjIwNTA4QzIuNTQxNjYgNi4yMDUwOCAxLjkxNzI3IDYuNDM5MjMgMS40MDk5NSA2Ljc5MDQ1VjQuMjkyODlDMS40MDk5NSAzLjk4MDY5IDEuMTM2NzggMy43MDc1MiAwLjgyNDU4NiAzLjcwNzUyQzAuNDczMzY2IDMuNzA3NTIgMC4yMDAxOTUgMy45ODA2OSAwLjIwMDE5NSA0LjI5Mjg5VjkuMjA5OTZDMC4yMzkyMiAxMC44NDkgMS41NjYwNSAxMi4xMzY4IDMuMjA1MDcgMTIuMTM2OEM0LjM3NTggMTIuMTM2OCA1LjM5MDQ0IDExLjQ3MzQgNS44OTc3NiAxMC41MzY4QzYuNDA1MDcgMTEuNDczNCA3LjQxOTcxIDEyLjEzNjggOC41NTE0MSAxMi4xMzY4QzEwLjIyOTUgMTIuMTM2OCAxMS41OTUzIDEwLjgxIDExLjU5NTMgOS4xMzE5MUMxMS42MzQzIDcuNTMxOTEgMTAuMjY4NSA2LjIwNTA4IDguNTkwNDQgNi4yMDUwOFoiIGZpbGw9IiMwMDcxRjciLz4KPC9zdmc+Cg==" + } +} \ No newline at end of file diff --git a/connectors/box/pom.xml b/connectors/box/pom.xml new file mode 100644 index 0000000000..293ec141cc --- /dev/null +++ b/connectors/box/pom.xml @@ -0,0 +1,68 @@ + + 4.0.0 + + + io.camunda.connector + connectors-parent + 8.7.0-SNAPSHOT + ../pom.xml + + + connector-box + Camunda Cloud Box Connector + connector-box + jar + + + + Camunda Self-Managed Free Edition license + https://camunda.com/legal/terms/cloud-terms-and-conditions/camunda-cloud-self-managed-free-edition-terms/ + + + Camunda Self-Managed Enterprise Edition license + + + + + Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH +under one or more contributor license agreements. Licensed under a proprietary license. +See the License.txt file for more information. You may not use this file +except in compliance with the proprietary license. + + + + + com.box + box-java-sdk + + + io.camunda.connector + element-template-generator-core + + + + + + + io.camunda.connector + element-template-generator-maven-plugin + ${project.version} + + + + io.camunda.connector.box.BoxFunction + + + io.camunda.connectors.box + box-outbound-connector.json + + + true + + + + + + + + diff --git a/connectors/box/src/main/java/io/camunda/connector/box/BoxFunction.java b/connectors/box/src/main/java/io/camunda/connector/box/BoxFunction.java new file mode 100644 index 0000000000..1c6483f2f7 --- /dev/null +++ b/connectors/box/src/main/java/io/camunda/connector/box/BoxFunction.java @@ -0,0 +1,39 @@ +/* + * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH + * under one or more contributor license agreements. Licensed under a proprietary license. + * See the License.txt file for more information. You may not use this file + * except in compliance with the proprietary license. + */ +package io.camunda.connector.box; + +import io.camunda.connector.api.annotation.OutboundConnector; +import io.camunda.connector.api.outbound.OutboundConnectorContext; +import io.camunda.connector.api.outbound.OutboundConnectorFunction; +import io.camunda.connector.box.model.BoxRequest; +import io.camunda.connector.generator.java.annotation.ElementTemplate; + +@OutboundConnector( + name = "Box", + inputVariables = {"authentication", "operation"}, + type = "io.camunda:box:1") +@ElementTemplate( + id = "io.camunda.connectors.box", + name = "Box Outbound Connector", + description = "Interact with the Box Document API", + inputDataClass = BoxRequest.class, + version = 1, + propertyGroups = { + @ElementTemplate.PropertyGroup(id = "authentication", label = "Authentication"), + @ElementTemplate.PropertyGroup(id = "operation", label = "Operation"), + }, + documentationRef = + "https://docs.camunda.io/docs/components/connectors/out-of-the-box-connectors/box/", + icon = "icon.svg") +public class BoxFunction implements OutboundConnectorFunction { + + @Override + public Object execute(OutboundConnectorContext context) { + var request = context.bindVariables(BoxRequest.class); + return BoxOperations.execute(request, context); + } +} diff --git a/connectors/box/src/main/java/io/camunda/connector/box/BoxOperations.java b/connectors/box/src/main/java/io/camunda/connector/box/BoxOperations.java new file mode 100644 index 0000000000..32f30a4884 --- /dev/null +++ b/connectors/box/src/main/java/io/camunda/connector/box/BoxOperations.java @@ -0,0 +1,138 @@ +/* + * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH + * under one or more contributor license agreements. Licensed under a proprietary license. + * See the License.txt file for more information. You may not use this file + * except in compliance with the proprietary license. + */ +package io.camunda.connector.box; + +import static io.camunda.connector.box.BoxUtil.download; +import static io.camunda.connector.box.BoxUtil.getFile; +import static io.camunda.connector.box.BoxUtil.getFolder; +import static io.camunda.connector.box.BoxUtil.item; + +import com.box.sdk.BoxAPIConnection; +import com.box.sdk.BoxCCGAPIConnection; +import com.box.sdk.BoxConfig; +import com.box.sdk.BoxDeveloperEditionAPIConnection; +import com.box.sdk.BoxFile; +import com.box.sdk.BoxFolder; +import com.box.sdk.BoxItem; +import com.box.sdk.BoxSearch; +import com.box.sdk.BoxSearchParameters; +import com.box.sdk.IAccessTokenCache; +import com.box.sdk.InMemoryLRUAccessTokenCache; +import io.camunda.connector.api.outbound.OutboundConnectorContext; +import io.camunda.connector.box.model.BoxRequest; +import io.camunda.connector.box.model.BoxRequest.Operation.Search.SortDirection; +import io.camunda.connector.box.model.BoxResult; +import io.camunda.document.Document; +import io.camunda.document.store.DocumentCreationRequest; +import java.util.Optional; +import java.util.stream.Collectors; + +public class BoxOperations { + + public static BoxResult execute(BoxRequest request, OutboundConnectorContext context) { + var api = connectToApi(request.authentication()); + return switch (request.operation()) { + case BoxRequest.Operation.UploadFile uploadFile -> uploadFile(uploadFile, api); + case BoxRequest.Operation.DownloadFile downloadFile -> + downloadFile(downloadFile, api, context); + case BoxRequest.Operation.MoveFile moveFile -> moveFile(moveFile, api); + case BoxRequest.Operation.DeleteFile deleteFile -> deleteFile(deleteFile, api); + case BoxRequest.Operation.CreateFolder createFolder -> createFolder(createFolder, api); + case BoxRequest.Operation.DeleteFolder deleteFolder -> deleteFolder(deleteFolder, api); + case BoxRequest.Operation.Search search -> search(search, api); + }; + } + + private static BoxAPIConnection connectToApi(BoxRequest.Authentication authentication) { + return switch (authentication) { + case BoxRequest.Authentication.DeveloperToken developerToken -> + new BoxAPIConnection(developerToken.accessToken()); + case BoxRequest.Authentication.ClientCredentialsUser user -> + BoxCCGAPIConnection.userConnection(user.clientId(), user.clientSecret(), user.userId()); + case BoxRequest.Authentication.ClientCredentialsEnterprise enterprise -> + BoxCCGAPIConnection.applicationServiceAccountConnection( + enterprise.clientId(), enterprise.clientSecret(), enterprise.enterpriseId()); + case BoxRequest.Authentication.JWTJsonConfig jwtJsonConfig -> { + BoxConfig boxConfig = BoxConfig.readFrom(jwtJsonConfig.jsonConfig()); + IAccessTokenCache tokenCache = new InMemoryLRUAccessTokenCache(100); + yield BoxDeveloperEditionAPIConnection.getAppEnterpriseConnection(boxConfig, tokenCache); + } + }; + } + + private static BoxResult.Upload uploadFile( + BoxRequest.Operation.UploadFile uploadFile, BoxAPIConnection api) { + var folder = getFolder(uploadFile.folderPath(), api); + var file = folder.uploadFile(uploadFile.document().asInputStream(), uploadFile.getFileName()); + return new BoxResult.Upload(item(file)); + } + + private static BoxResult.Download downloadFile( + BoxRequest.Operation.DownloadFile downloadFile, + BoxAPIConnection api, + OutboundConnectorContext context) { + var file = getFile(downloadFile.filePath(), api); + var document = createDocument(file, context); + return new BoxResult.Download(item(file), document); + } + + private static Document createDocument(BoxFile file, OutboundConnectorContext context) { + var fileContent = download(file); + var documentCreationRequest = + DocumentCreationRequest.from(fileContent).fileName(file.getInfo().getName()).build(); + return context.createDocument(documentCreationRequest); + } + + private static BoxResult deleteFile( + BoxRequest.Operation.DeleteFile deleteFile, BoxAPIConnection api) { + BoxFile file = getFile(deleteFile.filePath(), api); + file.delete(); + return new BoxResult.Generic(item(file)); + } + + private static BoxResult moveFile(BoxRequest.Operation.MoveFile moveFile, BoxAPIConnection api) { + BoxFile file = getFile(moveFile.filePath(), api); + BoxFolder folder = getFolder(moveFile.folderPath(), api); + BoxItem.Info info = file.move(folder); + return new BoxResult.Generic(item(info)); + } + + private static BoxResult deleteFolder( + BoxRequest.Operation.DeleteFolder deleteFolder, BoxAPIConnection api) { + var folder = getFolder(deleteFolder.folderPath(), api); + folder.delete(deleteFolder.recursive()); + return new BoxResult.Generic(item(folder)); + } + + private static BoxResult createFolder( + BoxRequest.Operation.CreateFolder createFolder, BoxAPIConnection api) { + var folder = getFolder(createFolder.folderPath(), api).createFolder(createFolder.name()); + return new BoxResult.Generic(item(folder)); + } + + private static BoxResult.Search search(BoxRequest.Operation.Search search, BoxAPIConnection api) { + var searchParams = searchParameters(search); + var offset = Optional.ofNullable(search.offset()).orElse(0L); + var limit = Optional.ofNullable(search.limit()).orElse(50L); + BoxSearch boxSearch = new BoxSearch(api); + var items = + boxSearch.searchRange(offset, limit, searchParams).stream() + .map(BoxUtil::item) + .collect(Collectors.toList()); + return new BoxResult.Search(items); + } + + private static BoxSearchParameters searchParameters(BoxRequest.Operation.Search search) { + BoxSearchParameters searchParams = new BoxSearchParameters(); + searchParams.setQuery(search.query()); + Optional.ofNullable(search.sortColumn()).ifPresent(searchParams::setSort); + Optional.ofNullable(search.sortDirection()) + .map(SortDirection::getValue) + .ifPresent(searchParams::setDirection); + return searchParams; + } +} diff --git a/connectors/box/src/main/java/io/camunda/connector/box/BoxUtil.java b/connectors/box/src/main/java/io/camunda/connector/box/BoxUtil.java new file mode 100644 index 0000000000..7c9b606b15 --- /dev/null +++ b/connectors/box/src/main/java/io/camunda/connector/box/BoxUtil.java @@ -0,0 +1,88 @@ +/* + * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH + * under one or more contributor license agreements. Licensed under a proprietary license. + * See the License.txt file for more information. You may not use this file + * except in compliance with the proprietary license. + */ +package io.camunda.connector.box; + +import com.box.sdk.BoxAPIConnection; +import com.box.sdk.BoxFile; +import com.box.sdk.BoxFolder; +import com.box.sdk.BoxItem; +import io.camunda.connector.box.model.BoxPath; +import io.camunda.connector.box.model.BoxResult; +import java.io.ByteArrayOutputStream; +import java.util.Optional; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; + +public class BoxUtil { + + public static BoxFile getFile(String path, BoxAPIConnection api) { + return new BoxFile(api, getItem(path, api).getID()); + } + + public static BoxFolder getFolder(String path, BoxAPIConnection api) { + return new BoxFolder(api, getItem(path, api).getID()); + } + + public static BoxItem.Info getItem(String path, BoxAPIConnection api) { + return findItem(path, api) + .orElseThrow(() -> new RuntimeException("Could not find item: " + path)); + } + + public static Optional findItem(String path, BoxAPIConnection api) { + return findItem(BoxPath.from(path), api); + } + + public static Optional findItem(BoxPath path, BoxAPIConnection api) { + return switch (path) { + case BoxPath.Root root -> Optional.of(BoxFolder.getRootFolder(api).getInfo()); + case BoxPath.Id id -> Optional.of(new BoxFile(api, id.id()).getInfo()); + case BoxPath.Segments segments -> findItemInTree(BoxFolder.getRootFolder(api), segments); + }; + } + + public static Optional findItemInTree(BoxFolder folder, BoxPath.Segments segments) { + String segment = segments.segments().getFirst(); + return findItemByName(items(folder), segment) + .flatMap( + item -> + segments.isPathEnd() + ? Optional.of(item) + : findItemInTree( + new BoxFolder(folder.getAPI(), item.getID()), + segments.withoutFirstSegment())); + } + + private static Stream items(BoxFolder folder) { + return StreamSupport.stream(folder.spliterator(), false); + } + + private static Optional findItemByName(Stream items, String name) { + return items.filter(item -> item.getName().equals(name)).findFirst(); + } + + public static byte[] download(BoxFile file) { + try (ByteArrayOutputStream out = new ByteArrayOutputStream()) { + file.download(out); + return out.toByteArray(); + } catch (Throwable e) { + throw new RuntimeException("Error downloading file: " + file.getID(), e); + } + } + + public static BoxResult.Item item(BoxFile file) { + return new BoxResult.Item(file.getID(), file.getInfo().getName(), file.getInfo().getType()); + } + + public static BoxResult.Item item(BoxFolder folder) { + return new BoxResult.Item( + folder.getID(), folder.getInfo().getName(), folder.getInfo().getType()); + } + + public static BoxResult.Item item(BoxItem.Info info) { + return new BoxResult.Item(info.getID(), info.getName(), info.getType()); + } +} diff --git a/connectors/box/src/main/java/io/camunda/connector/box/model/BoxPath.java b/connectors/box/src/main/java/io/camunda/connector/box/model/BoxPath.java new file mode 100644 index 0000000000..3f2ea997fe --- /dev/null +++ b/connectors/box/src/main/java/io/camunda/connector/box/model/BoxPath.java @@ -0,0 +1,52 @@ +/* + * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH + * under one or more contributor license agreements. Licensed under a proprietary license. + * See the License.txt file for more information. You may not use this file + * except in compliance with the proprietary license. + */ +package io.camunda.connector.box.model; + +import java.util.Arrays; +import java.util.List; + +public sealed interface BoxPath permits BoxPath.Id, BoxPath.Root, BoxPath.Segments { + + Root ROOT = new Root(); + + record Root() implements BoxPath {} + + record Id(String id) implements BoxPath {} + + record Segments(List segments) implements BoxPath { + + public Segments { + if (segments == null || segments.isEmpty()) { + throw new IllegalArgumentException("segments is null or empty"); + } + } + + public boolean isPathEnd() { + return segments().size() == 1; + } + + public Segments withoutFirstSegment() { + return new Segments(segments.subList(1, segments.size())); + } + } + + static BoxPath from(String path) { + if (path == null || path.trim().isEmpty()) { + return ROOT; + } else if (path.startsWith("/")) { + var segments = + Arrays.stream(path.trim().split("/")).filter(s -> !s.trim().isEmpty()).toList(); + if (segments.isEmpty()) { + return ROOT; + } else { + return new Segments(segments); + } + } else { + return new Id(path.trim()); + } + } +} diff --git a/connectors/box/src/main/java/io/camunda/connector/box/model/BoxRequest.java b/connectors/box/src/main/java/io/camunda/connector/box/model/BoxRequest.java new file mode 100644 index 0000000000..24f45e6986 --- /dev/null +++ b/connectors/box/src/main/java/io/camunda/connector/box/model/BoxRequest.java @@ -0,0 +1,268 @@ +/* + * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH + * under one or more contributor license agreements. Licensed under a proprietary license. + * See the License.txt file for more information. You may not use this file + * except in compliance with the proprietary license. + */ +package io.camunda.connector.box.model; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.camunda.connector.generator.dsl.Property; +import io.camunda.connector.generator.java.annotation.TemplateDiscriminatorProperty; +import io.camunda.connector.generator.java.annotation.TemplateProperty; +import io.camunda.connector.generator.java.annotation.TemplateSubType; +import io.camunda.document.Document; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record BoxRequest( + @TemplateProperty(group = "authentication", id = "authentication") @Valid @NotNull + Authentication authentication, + @TemplateProperty(group = "operation", id = "operation") @Valid @NotNull Operation operation) { + + @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") + @JsonSubTypes({ + @JsonSubTypes.Type(value = Authentication.DeveloperToken.class, name = "developerToken"), + @JsonSubTypes.Type( + value = Authentication.ClientCredentialsUser.class, + name = "clientCredentialsUser"), + @JsonSubTypes.Type( + value = Authentication.ClientCredentialsEnterprise.class, + name = "clientCredentialsEnterprise"), + @JsonSubTypes.Type(value = Authentication.JWTJsonConfig.class, name = "jwtJsonConfig") + }) + @TemplateDiscriminatorProperty( + label = "Authentication", + group = "authentication", + name = "type", + defaultValue = "developerToken", + description = + "Specify authentication strategy. Learn more at the documentation page") + public sealed interface Authentication + permits Authentication.ClientCredentialsEnterprise, + Authentication.ClientCredentialsUser, + Authentication.DeveloperToken, + Authentication.JWTJsonConfig { + @TemplateSubType(id = "developerToken", label = "Developer token") + record DeveloperToken( + @TemplateProperty( + group = "authentication", + label = "Access key", + description = "The access key or developer token") + @NotBlank + String accessToken) + implements Authentication {} + + @TemplateSubType(id = "clientCredentialsUser", label = "Client Credentials User") + record ClientCredentialsUser( + @TemplateProperty( + group = "authentication", + id = "clientIdUser", + label = "Client id", + description = "The client id") + @NotBlank + String clientId, + @TemplateProperty( + group = "authentication", + id = "clientSecretUser", + label = "Client secret", + description = "The client secret") + @NotBlank + String clientSecret, + @TemplateProperty( + group = "authentication", + label = "User ID", + description = "The user ID to of the account to authenticate against") + @NotBlank + String userId) + implements Authentication {} + + @TemplateSubType(id = "clientCredentialsEnterprise", label = "Client Credentials Enterprise") + record ClientCredentialsEnterprise( + @TemplateProperty( + group = "authentication", + id = "clientIdEnterprise", + label = "Client id", + description = "The client id") + @NotBlank + String clientId, + @TemplateProperty( + group = "authentication", + id = "clientSecretEnterprise", + label = "Client secret", + description = "The client secret") + @NotBlank + String clientSecret, + @TemplateProperty( + group = "authentication", + label = "Enterprise ID", + description = "The enterprise ID to authenticate against") + @NotBlank + String enterpriseId) + implements Authentication {} + + @TemplateSubType(id = "jwtJsonConfig", label = "JWT JSON Config") + record JWTJsonConfig( + @TemplateProperty( + group = "authentication", + label = "JSON config", + description = "The JSON config as string") + @NotBlank + String jsonConfig) + implements Authentication {} + } + + @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") + @JsonSubTypes({ + @JsonSubTypes.Type(value = Operation.CreateFolder.class, name = "createFolder"), + @JsonSubTypes.Type(value = Operation.DeleteFolder.class, name = "deleteFolder"), + @JsonSubTypes.Type(value = Operation.UploadFile.class, name = "uploadFile"), + @JsonSubTypes.Type(value = Operation.DownloadFile.class, name = "downloadFile"), + @JsonSubTypes.Type(value = Operation.MoveFile.class, name = "moveFile"), + @JsonSubTypes.Type(value = Operation.DeleteFile.class, name = "deleteFile"), + @JsonSubTypes.Type(value = Operation.Search.class, name = "search") + }) + @TemplateDiscriminatorProperty( + label = "Operation", + group = "operation", + name = "type", + defaultValue = "createFolder", + description = "The operation to execute.") + public sealed interface Operation + permits Operation.CreateFolder, + Operation.DeleteFolder, + Operation.UploadFile, + Operation.DownloadFile, + Operation.MoveFile, + Operation.DeleteFile, + Operation.Search { + @TemplateSubType(id = "createFolder", label = "Create Folder") + record CreateFolder( + @TemplateProperty(id = "createFolderName", group = "operation", label = "Folder name") + @NotBlank + String name, + @TemplateProperty( + id = "createFolderParentPath", + group = "operation", + label = "Parent path", + defaultValue = "/") + @NotBlank + String folderPath) + implements Operation {} + + @TemplateSubType(id = "deleteFolder", label = "Delete Folder") + record DeleteFolder( + @TemplateProperty(id = "deleteFolderPath", group = "operation", label = "Folder path") + @NotBlank + String folderPath, + @TemplateProperty( + defaultValue = "true", + group = "operation", + label = "Recursive", + description = "Deletes all items contained by the folder") + boolean recursive) + implements Operation {} + + @TemplateSubType(id = "uploadFile", label = "Upload File") + record UploadFile( + @TemplateProperty(id = "uploadFileName", group = "operation", label = "File name") @NotBlank + String name, + @TemplateProperty(id = "uploadFileFolderPath", group = "operation", label = "Folder path") + @NotBlank + String folderPath, + @TemplateProperty( + id = "uploadFileDocument", + group = "operation", + type = TemplateProperty.PropertyType.String, + feel = Property.FeelMode.required, + label = "Document reference", + description = "The document reference that will be uploaded") + @NotNull + Document document) + implements Operation { + + public String getFileName() { + return name != null ? name : document.metadata().getFileName(); + } + } + + @TemplateSubType(id = "downloadFile", label = "Download File") + record DownloadFile( + @TemplateProperty( + id = "downloadFilePath", + group = "operation", + label = "File path", + description = "Path to the file item to download") + String filePath) + implements Operation {} + + @TemplateSubType(id = "moveFile", label = "Move File") + record MoveFile( + @TemplateProperty(id = "moveFilePath", group = "operation", label = "File path") @NotBlank + String filePath, + @TemplateProperty( + id = "moveFileFolderPath", + group = "operation", + label = "Target folder path") + @NotBlank + String folderPath) + implements Operation {} + + @TemplateSubType(id = "deleteFile", label = "Delete File") + record DeleteFile( + @TemplateProperty(id = "deleteFilePath", group = "operation", label = "File path") @NotBlank + String filePath) + implements Operation {} + + @TemplateSubType(id = "search", label = "Search") + record Search( + @TemplateProperty(id = "searchQuery", group = "operation") String query, + @TemplateProperty( + id = "searchSortColumn", + defaultValue = "modified_at", + description = "Column for sorting search results", + group = "operation") + String sortColumn, + @TemplateProperty( + id = "searchSortDirection", + defaultValue = "DESC", + description = "Direction for sorting search results", + group = "operation") + SortDirection sortDirection, + @TemplateProperty( + id = "searchOffset", + defaultValue = "0", + description = "Offset for search results", + group = "operation") + Long offset, + @TemplateProperty( + id = "searchLimit", + defaultValue = "30", + description = "Limit", + group = "operation") + @Min(1) + @Max(200) + Long limit) + implements Operation { + + public enum SortDirection { + ASC("ASC"), + DESC("DESC"); + + private final String value; + + SortDirection(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + } + } + } +} diff --git a/connectors/box/src/main/java/io/camunda/connector/box/model/BoxResult.java b/connectors/box/src/main/java/io/camunda/connector/box/model/BoxResult.java new file mode 100644 index 0000000000..a72762ab95 --- /dev/null +++ b/connectors/box/src/main/java/io/camunda/connector/box/model/BoxResult.java @@ -0,0 +1,23 @@ +/* + * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH + * under one or more contributor license agreements. Licensed under a proprietary license. + * See the License.txt file for more information. You may not use this file + * except in compliance with the proprietary license. + */ +package io.camunda.connector.box.model; + +import io.camunda.document.Document; +import java.util.List; + +public sealed interface BoxResult + permits BoxResult.Download, BoxResult.Generic, BoxResult.Search, BoxResult.Upload { + record Item(String id, String name, String type) {} + + record Download(Item item, Document document) implements BoxResult {} + + record Upload(Item item) implements BoxResult {} + + record Generic(Item item) implements BoxResult {} + + record Search(List items) implements BoxResult {} +} diff --git a/connectors/box/src/main/resources/META-INF/services/io.camunda.connector.api.outbound.OutboundConnectorFunction b/connectors/box/src/main/resources/META-INF/services/io.camunda.connector.api.outbound.OutboundConnectorFunction new file mode 100644 index 0000000000..0776743a20 --- /dev/null +++ b/connectors/box/src/main/resources/META-INF/services/io.camunda.connector.api.outbound.OutboundConnectorFunction @@ -0,0 +1 @@ +io.camunda.connector.box.BoxFunction \ No newline at end of file diff --git a/connectors/box/src/main/resources/icon.svg b/connectors/box/src/main/resources/icon.svg new file mode 100644 index 0000000000..110450e639 --- /dev/null +++ b/connectors/box/src/main/resources/icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/connectors/box/src/test/java/io/camunda/connector/BoxPathTests.java b/connectors/box/src/test/java/io/camunda/connector/BoxPathTests.java new file mode 100644 index 0000000000..9e7623a1f3 --- /dev/null +++ b/connectors/box/src/test/java/io/camunda/connector/BoxPathTests.java @@ -0,0 +1,53 @@ +/* + * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH + * under one or more contributor license agreements. Licensed under a proprietary license. + * See the License.txt file for more information. You may not use this file + * except in compliance with the proprietary license. + */ +package io.camunda.connector; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.camunda.connector.box.model.BoxPath; +import java.util.List; +import org.junit.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +public class BoxPathTests { + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" ", "/", "//", "/ / "}) + public void testRoot(String path) { + assertInstanceOf(BoxPath.Root.class, BoxPath.from(path)); + } + + @ParameterizedTest + @ValueSource(strings = {"1", "0"}) + public void testId(String path) { + assertInstanceOf(BoxPath.Id.class, BoxPath.from(path)); + } + + @ParameterizedTest + @ValueSource(strings = {"/a", "/a/b"}) + public void testSegments(String path) { + assertInstanceOf(BoxPath.Segments.class, BoxPath.from(path)); + } + + @Test + public void testLastSegment() { + BoxPath.Segments segments = (BoxPath.Segments) BoxPath.from("/a"); + assertTrue(segments.isPathEnd()); + } + + @Test + public void withoutFirstSegment() { + BoxPath.Segments segments = ((BoxPath.Segments) BoxPath.from("/a/b")).withoutFirstSegment(); + assertEquals(List.of("b"), segments.segments()); + assertTrue(segments.isPathEnd()); + } +} diff --git a/connectors/pom.xml b/connectors/pom.xml index b6016f0e38..8c54d97594 100644 --- a/connectors/pom.xml +++ b/connectors/pom.xml @@ -20,6 +20,7 @@ automation-anywhere aws + box email google http diff --git a/parent/pom.xml b/parent/pom.xml index 90c03ccb13..f39ad46dae 100644 --- a/parent/pom.xml +++ b/parent/pom.xml @@ -124,6 +124,8 @@ limitations under the License. 1.14.2 1.79 + 4.12.0 + 4.10.3 1.44.2 @@ -509,6 +511,12 @@ limitations under the License. ${version.failsafe} + + com.box + box-java-sdk + ${version.box-sdk} + + io.camunda From 2fcec357ff2355987896b10496376bacff263bcc Mon Sep 17 00:00:00 2001 From: Ingo Richtsmeier Date: Tue, 7 Jan 2025 18:10:16 +0100 Subject: [PATCH 3/3] feat(element-template-generator): allow send tasks using connectors (#3827) --- .../camunda/connector/generator/dsl/BpmnType.java | 1 + .../java/util/TemplateGenerationContextUtil.java | 1 + .../generator/dsl/ElementTypeSupportTest.java | 15 +++++++++++++++ .../OutboundClassBasedTemplateGeneratorTest.java | 6 +++++- .../example/outbound/MyConnectorFunction.java | 1 + 5 files changed, 23 insertions(+), 1 deletion(-) diff --git a/element-template-generator/core/src/main/java/io/camunda/connector/generator/dsl/BpmnType.java b/element-template-generator/core/src/main/java/io/camunda/connector/generator/dsl/BpmnType.java index 88575b74e2..a3312d7652 100644 --- a/element-template-generator/core/src/main/java/io/camunda/connector/generator/dsl/BpmnType.java +++ b/element-template-generator/core/src/main/java/io/camunda/connector/generator/dsl/BpmnType.java @@ -24,6 +24,7 @@ public enum BpmnType { SERVICE_TASK("bpmn:ServiceTask", false, "ServiceTask"), RECEIVE_TASK("bpmn:ReceiveTask", true, "ReceiveTask"), SCRIPT_TASK("bpmn:ScriptTask", false, "ScriptTask"), + SEND_TASK("bpmn:SendTask", false, "SendTask"), START_EVENT("bpmn:StartEvent", false, "StartEvent"), INTERMEDIATE_CATCH_EVENT("bpmn:IntermediateCatchEvent", true, "IntermediateCatchEvent"), INTERMEDIATE_THROW_EVENT("bpmn:IntermediateThrowEvent", true, "IntermediateThrowEvent"), diff --git a/element-template-generator/core/src/main/java/io/camunda/connector/generator/java/util/TemplateGenerationContextUtil.java b/element-template-generator/core/src/main/java/io/camunda/connector/generator/java/util/TemplateGenerationContextUtil.java index 42d1d11582..e5c7f08a5c 100644 --- a/element-template-generator/core/src/main/java/io/camunda/connector/generator/java/util/TemplateGenerationContextUtil.java +++ b/element-template-generator/core/src/main/java/io/camunda/connector/generator/java/util/TemplateGenerationContextUtil.java @@ -30,6 +30,7 @@ public class TemplateGenerationContextUtil { private static final Set OUTBOUND_SUPPORTED_ELEMENT_TYPES = Set.of( BpmnType.SERVICE_TASK, + BpmnType.SEND_TASK, BpmnType.INTERMEDIATE_THROW_EVENT, BpmnType.SCRIPT_TASK, BpmnType.MESSAGE_END_EVENT); diff --git a/element-template-generator/core/src/test/java/io/camunda/connector/generator/dsl/ElementTypeSupportTest.java b/element-template-generator/core/src/test/java/io/camunda/connector/generator/dsl/ElementTypeSupportTest.java index be4f14da1a..ecbab39490 100644 --- a/element-template-generator/core/src/test/java/io/camunda/connector/generator/dsl/ElementTypeSupportTest.java +++ b/element-template-generator/core/src/test/java/io/camunda/connector/generator/dsl/ElementTypeSupportTest.java @@ -72,6 +72,21 @@ void scriptTask() { assertThat(template.elementType().eventDefinition()).isNull(); } + @Test + void sendTask() { + ElementTemplate template = + ElementTemplateBuilder.createOutbound() + .id("id") + .name("name") + .type("type", false) + .appliesTo(BpmnType.TASK) + .elementType(BpmnType.SEND_TASK) + .build(); + assertThat(template.appliesTo()).containsExactly(BpmnType.TASK.getName()); + assertThat(template.elementType().value()).isEqualTo(BpmnType.SEND_TASK.getName()); + assertThat(template.elementType().eventDefinition()).isNull(); + } + @Test void messageEndEvent() { ElementTemplate template = diff --git a/element-template-generator/core/src/test/java/io/camunda/connector/generator/java/OutboundClassBasedTemplateGeneratorTest.java b/element-template-generator/core/src/test/java/io/camunda/connector/generator/java/OutboundClassBasedTemplateGeneratorTest.java index 7770f8cdcb..e3c4797360 100644 --- a/element-template-generator/core/src/test/java/io/camunda/connector/generator/java/OutboundClassBasedTemplateGeneratorTest.java +++ b/element-template-generator/core/src/test/java/io/camunda/connector/generator/java/OutboundClassBasedTemplateGeneratorTest.java @@ -223,6 +223,7 @@ void multipleElementTypes_definedInAnnotation() { generator.generate(MyConnectorFunction.WithMultipleElementTypes.class, config); boolean hasServiceTask = false, hasScriptTask = false, + hasSendTask = false, hasMessageThrowEvent = false, hasMessageEndEvent = false; for (var template : templates) { @@ -230,6 +231,8 @@ void multipleElementTypes_definedInAnnotation() { hasServiceTask = true; } else if (template.elementType().equals(ElementTypeWrapper.from(BpmnType.SCRIPT_TASK))) { hasScriptTask = true; + } else if (template.elementType().equals(ElementTypeWrapper.from(BpmnType.SEND_TASK))) { + hasSendTask = true; } else if (template .elementType() .equals(ElementTypeWrapper.from(BpmnType.INTERMEDIATE_THROW_EVENT))) { @@ -240,9 +243,10 @@ void multipleElementTypes_definedInAnnotation() { hasMessageEndEvent = true; } } - assertThat(templates.size()).isEqualTo(4); + assertThat(templates.size()).isEqualTo(5); assertTrue(hasServiceTask); assertTrue(hasScriptTask); + assertTrue(hasSendTask); assertTrue(hasMessageThrowEvent); assertTrue(hasMessageEndEvent); } diff --git a/element-template-generator/core/src/test/java/io/camunda/connector/generator/java/example/outbound/MyConnectorFunction.java b/element-template-generator/core/src/test/java/io/camunda/connector/generator/java/example/outbound/MyConnectorFunction.java index 4085a87b87..164819b400 100644 --- a/element-template-generator/core/src/test/java/io/camunda/connector/generator/java/example/outbound/MyConnectorFunction.java +++ b/element-template-generator/core/src/test/java/io/camunda/connector/generator/java/example/outbound/MyConnectorFunction.java @@ -118,6 +118,7 @@ public static class WithDuplicatePropertyIds extends MyConnectorFunction {} elementTypes = { @ConnectorElementType(appliesTo = BpmnType.TASK, elementType = BpmnType.SERVICE_TASK), @ConnectorElementType(appliesTo = BpmnType.TASK, elementType = BpmnType.SCRIPT_TASK), + @ConnectorElementType(appliesTo = BpmnType.TASK, elementType = BpmnType.SEND_TASK), @ConnectorElementType( appliesTo = BpmnType.END_EVENT, elementType = BpmnType.MESSAGE_END_EVENT),