This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new b0a816919420 CAMEL-25615: camel-bonita - startCase uploads a file
input through the client of its own endpoint and leaves no temporary file
behind (#27802)
b0a816919420 is described below
commit b0a81691942041582053ece46168a2891e546498
Author: allthingssecurity <[email protected]>
AuthorDate: Wed Oct 14 17:15:54 2026 +0530
CAMEL-25615: camel-bonita - startCase uploads a file input through the
client of its own endpoint and leaves no temporary file behind (#27802)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
.../camel/component/bonita/api/BonitaAPI.java | 20 +-
.../component/bonita/api/BonitaAPIBuilder.java | 4 +-
.../component/bonita/api/util/BonitaAPIUtil.java | 52 ++--
.../bonita/BonitaStartCaseFileUploadTest.java | 262 +++++++++++++++++++++
.../api/util/BonitaAPIUtilPrepareInputsTest.java | 13 +-
5 files changed, 325 insertions(+), 26 deletions(-)
diff --git
a/components/camel-bonita/src/main/java/org/apache/camel/component/bonita/api/BonitaAPI.java
b/components/camel-bonita/src/main/java/org/apache/camel/component/bonita/api/BonitaAPI.java
index 7f85c30fa312..d051f6966031 100644
---
a/components/camel-bonita/src/main/java/org/apache/camel/component/bonita/api/BonitaAPI.java
+++
b/components/camel-bonita/src/main/java/org/apache/camel/component/bonita/api/BonitaAPI.java
@@ -37,11 +37,26 @@ public class BonitaAPI {
private BonitaAPIConfig bonitaApiConfig;
private WebTarget webTarget;
+ private final BonitaAPIUtil bonitaApiUtil;
+ /**
+ * @deprecated the file inputs are uploaded with a JAX-RS client of their
own, which is never closed. Use
+ * {@link #BonitaAPI(BonitaAPIConfig, WebTarget,
BonitaAPIUtil)} with a helper from
+ * {@link BonitaAPIUtil#forClient}, as {@code
BonitaAPIBuilder} does.
+ */
+ @Deprecated(since = "4.24.0")
protected BonitaAPI(BonitaAPIConfig bonitaApiConfig, WebTarget webTarget) {
+ this(bonitaApiConfig, webTarget,
BonitaAPIUtil.getInstance(bonitaApiConfig));
+ }
+
+ /**
+ * @param bonitaApiUtil the helper that uploads the file inputs of {@link
#startCase}; it has to use the same Bonita
+ * server and account as the given target
+ */
+ protected BonitaAPI(BonitaAPIConfig bonitaApiConfig, WebTarget webTarget,
BonitaAPIUtil bonitaApiUtil) {
this.bonitaApiConfig = bonitaApiConfig;
this.webTarget = webTarget;
-
+ this.bonitaApiUtil = bonitaApiUtil;
}
private WebTarget getBaseResource() {
@@ -74,8 +89,7 @@ public class BonitaAPI {
if (rawInputs == null) {
throw new IllegalArgumentException("The contract input is null");
}
- Map<String, Serializable> inputs =
BonitaAPIUtil.getInstance(bonitaApiConfig)
- .prepareInputs(processDefinition, rawInputs);
+ Map<String, Serializable> inputs =
bonitaApiUtil.prepareInputs(processDefinition, rawInputs);
WebTarget resource =
getBaseResource().path("process/{processId}/instantiation")
.resolveTemplate("processId", processDefinition.getId());
return resource.request().accept(MediaType.APPLICATION_JSON)
diff --git
a/components/camel-bonita/src/main/java/org/apache/camel/component/bonita/api/BonitaAPIBuilder.java
b/components/camel-bonita/src/main/java/org/apache/camel/component/bonita/api/BonitaAPIBuilder.java
index cedf13c70710..f4c740b3b73e 100644
---
a/components/camel-bonita/src/main/java/org/apache/camel/component/bonita/api/BonitaAPIBuilder.java
+++
b/components/camel-bonita/src/main/java/org/apache/camel/component/bonita/api/BonitaAPIBuilder.java
@@ -23,6 +23,7 @@ import jakarta.ws.rs.client.WebTarget;
import com.fasterxml.jackson.jakarta.rs.json.JacksonJsonProvider;
import org.apache.camel.component.bonita.api.filter.BonitaAuthFilter;
import org.apache.camel.component.bonita.api.util.BonitaAPIConfig;
+import org.apache.camel.component.bonita.api.util.BonitaAPIUtil;
public class BonitaAPIBuilder {
@@ -39,7 +40,8 @@ public class BonitaAPIBuilder {
Client client = clientBuilder.build();
client.register(new BonitaAuthFilter(bonitaAPIConfig));
WebTarget webTarget =
client.target(bonitaAPIConfig.getBaseBonitaURI()).path("/API/bpm");
- return new BonitaAPI(bonitaAPIConfig, webTarget);
+ // the file inputs are uploaded with the client of this API, so they
go to its server with its account
+ return new BonitaAPI(bonitaAPIConfig, webTarget,
BonitaAPIUtil.forClient(client, bonitaAPIConfig));
}
}
diff --git
a/components/camel-bonita/src/main/java/org/apache/camel/component/bonita/api/util/BonitaAPIUtil.java
b/components/camel-bonita/src/main/java/org/apache/camel/component/bonita/api/util/BonitaAPIUtil.java
index 950fdf068f91..0d940564293a 100644
---
a/components/camel-bonita/src/main/java/org/apache/camel/component/bonita/api/util/BonitaAPIUtil.java
+++
b/components/camel-bonita/src/main/java/org/apache/camel/component/bonita/api/util/BonitaAPIUtil.java
@@ -17,13 +17,11 @@
package org.apache.camel.component.bonita.api.util;
import java.io.ByteArrayInputStream;
-import java.io.File;
-import java.io.FileOutputStream;
import java.io.Serializable;
-import java.nio.file.Files;
import java.util.HashMap;
import java.util.Map;
import java.util.Map.Entry;
+import java.util.concurrent.ThreadLocalRandom;
import jakarta.ws.rs.client.Client;
import jakarta.ws.rs.client.ClientBuilder;
@@ -44,20 +42,35 @@ import static jakarta.ws.rs.client.Entity.entity;
public class BonitaAPIUtil {
- private static BonitaAPIUtil instance;
private WebTarget webTarget;
+ /**
+ * Creates a helper that uploads to the Bonita server of the given
configuration, with the account of that
+ * configuration. Every call creates a new helper with a JAX-RS client of
its own, which is never closed; nothing is
+ * shared between configurations.
+ *
+ * @deprecated use {@link #forClient(Client, BonitaAPIConfig)} with a
client that the caller closes.
+ * {@code BonitaAPIBuilder} does not use this method: it binds
the helper to the client of the
+ * {@code BonitaAPI} that it builds.
+ */
+ @Deprecated(since = "4.24.0")
public static BonitaAPIUtil getInstance(BonitaAPIConfig bonitaAPIConfig) {
- if (instance == null) {
- instance = new BonitaAPIUtil();
- ClientBuilder clientBuilder = ClientBuilder.newBuilder();
- Client client = clientBuilder.build();
- client.register(JacksonJsonProvider.class);
- client.register(new JsonClientFilter());
- client.register(new BonitaAuthFilter(bonitaAPIConfig));
-
instance.setWebTarget(client.target(bonitaAPIConfig.getBaseBonitaURI()));
- }
- return instance;
+ ClientBuilder clientBuilder = ClientBuilder.newBuilder();
+ Client client = clientBuilder.build();
+ client.register(JacksonJsonProvider.class);
+ client.register(new BonitaAuthFilter(bonitaAPIConfig));
+ return forClient(client, bonitaAPIConfig);
+ }
+
+ /**
+ * Creates a helper that uploads with the given client, which must already
authenticate against the Bonita server of
+ * the given configuration.
+ */
+ public static BonitaAPIUtil forClient(Client client, BonitaAPIConfig
bonitaAPIConfig) {
+ BonitaAPIUtil util = new BonitaAPIUtil();
+ // the upload answers with JSON as text/plain: the filter is needed
for this target only
+
util.setWebTarget(client.target(bonitaAPIConfig.getBaseBonitaURI()).register(new
JsonClientFilter()));
+ return util;
}
public UploadFileResponse uploadFile(
@@ -69,13 +82,10 @@ public class BonitaAPIUtil {
.resolveTemplate("processName", processDefinition.getName())
.resolveTemplate("processVersion",
processDefinition.getVersion());
- File tempFile = Files.createTempFile("tempFile", ".tmp").toFile();
-
- try (FileOutputStream fos = new FileOutputStream(tempFile)) {
- fos.write(file.getContent());
- }
-
- String dispositionValue =
String.format("form-data;filename=%s;name=file", tempFile.getName());
+ // the part is named like a temporary file (as before), but no file is
created: the content is sent from the
+ // byte array
+ String fileName = "tempFile" +
ThreadLocalRandom.current().nextLong(Long.MAX_VALUE) + ".tmp";
+ String dispositionValue =
String.format("form-data;filename=%s;name=file", fileName);
Attachment attachment = new AttachmentBuilder()
.object(new ByteArrayInputStream(file.getContent()))
.contentDisposition(new ContentDisposition(dispositionValue))
diff --git
a/components/camel-bonita/src/test/java/org/apache/camel/component/bonita/BonitaStartCaseFileUploadTest.java
b/components/camel-bonita/src/test/java/org/apache/camel/component/bonita/BonitaStartCaseFileUploadTest.java
new file mode 100644
index 000000000000..62746c12f999
--- /dev/null
+++
b/components/camel-bonita/src/test/java/org/apache/camel/component/bonita/BonitaStartCaseFileUploadTest.java
@@ -0,0 +1,262 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.bonita;
+
+import java.io.Serializable;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import jakarta.ws.rs.InternalServerErrorException;
+
+import com.github.tomakehurst.wiremock.WireMockServer;
+import com.github.tomakehurst.wiremock.core.WireMockConfiguration;
+import com.github.tomakehurst.wiremock.verification.LoggedRequest;
+import org.apache.camel.CamelContext;
+import org.apache.camel.Exchange;
+import org.apache.camel.ProducerTemplate;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.bonita.api.model.FileInput;
+import org.apache.camel.impl.DefaultCamelContext;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
+import static com.github.tomakehurst.wiremock.client.WireMock.anyRequestedFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.anyUrl;
+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.post;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Starts cases with a file input through two bonita endpoints that point to
two different Bonita servers (two WireMock
+ * servers) with two different accounts, and checks where each request of an
exchange went.
+ */
+class BonitaStartCaseFileUploadTest {
+
+ private static final String LOGIN = "/bonita/loginservice";
+ private static final String PROCESS = "/bonita/API/bpm/process";
+ private static final String UPLOAD =
"/bonita/portal/resource/process/orders/1.0/API/formFileUpload";
+ private static final String INSTANTIATION =
"/bonita/API/bpm/process/7/instantiation";
+ // one part: boundary, headers with the disposition that names the part
like a temporary file, content, closing boundary
+ private static final Pattern UPLOADED_PART = Pattern.compile(
+ "(?:\r\n)?--(\\S+)\r\n(?:(?!Content-Disposition)[^\r\n]+\r\n)*"
+ +
"Content-Disposition: form-data;filename=(tempFile\\d+\\.tmp);name=file\r\n"
+ +
"(?:[^\r\n]+\r\n)*\r\n(.*)\r\n--(\\S+)--(?:\r\n)?",
+ Pattern.DOTALL);
+
+ private WireMockServer serverA;
+ private WireMockServer serverB;
+ private CamelContext context;
+ private ProducerTemplate template;
+
+ @BeforeEach
+ void setup() throws Exception {
+ serverA = startBonita("A");
+ serverB = startBonita("B");
+
+ context = new DefaultCamelContext();
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+ from("direct:a").to(bonita(serverA, "A"));
+ from("direct:b").to(bonita(serverB, "B"));
+ }
+ });
+ context.start();
+ template = context.createProducerTemplate();
+ }
+
+ @AfterEach
+ void tearDown() throws Exception {
+ if (template != null) {
+ template.stop();
+ }
+ if (context != null) {
+ context.stop();
+ }
+ if (serverA != null) {
+ serverA.stop();
+ }
+ if (serverB != null) {
+ serverB.stop();
+ }
+ }
+
+ @Test
+ void fileOfSecondEndpointIsUploadedToItsOwnServer() {
+ startCase("direct:a");
+ assertOwnRequestsOnly(serverA, "A", 1);
+ assertRequests(serverB, 0, 0, 0, 0);
+
+ startCase("direct:b");
+ assertOwnRequestsOnly(serverB, "B", 1);
+ assertOwnRequestsOnly(serverA, "A", 1);
+
+ startCase("direct:a");
+ assertOwnRequestsOnly(serverA, "A", 2);
+ assertOwnRequestsOnly(serverB, "B", 1);
+ }
+
+ @Test
+ void
fileOfFirstEndpointIsUploadedToItsOwnServerWhenTheOtherEndpointWasUsedFirst() {
+ startCase("direct:b");
+ assertOwnRequestsOnly(serverB, "B", 1);
+ assertRequests(serverA, 0, 0, 0, 0);
+
+ startCase("direct:a");
+ assertOwnRequestsOnly(serverA, "A", 1);
+ assertOwnRequestsOnly(serverB, "B", 1);
+ }
+
+ @Test
+ void uploadLeavesNoTemporaryFile() throws Exception {
+ startCase("direct:a");
+
+ assertUploadedPartAndNoTemporaryFileLeft(serverA);
+ }
+
+ @Test
+ void uploadLeavesNoTemporaryFileWhenTheUploadFails() throws Exception {
+ serverA.stubFor(post(urlEqualTo(UPLOAD)).atPriority(1)
+
.willReturn(aResponse().withStatus(500).withHeader("Content-Type", "text/plain")
+ .withBody("upload refused")));
+
+ Exchange exchange = template.send("direct:a", e ->
e.getIn().setBody(inputsWithFile()));
+
+ assertInstanceOf(InternalServerErrorException.class,
exchange.getException(),
+ "the exchange must fail with the answer of Bonita to the
upload");
+ assertRequests(serverA, 2, 1, 1, 0);
+ assertUploadedPartAndNoTemporaryFileLeft(serverA);
+ }
+
+ private void startCase(String uri) {
+ Exchange exchange = template.send(uri, e ->
e.getIn().setBody(inputsWithFile()));
+ assertNull(exchange.getException(), "exception of the exchange sent to
" + uri);
+ }
+
+ private static Map<String, Serializable> inputsWithFile() {
+ Map<String, Serializable> inputs = new HashMap<>();
+ inputs.put("amount", 1);
+ inputs.put("document", new FileInput("report.txt",
"content".getBytes(StandardCharsets.UTF_8)));
+ return inputs;
+ }
+
+ private static String bonita(WireMockServer server, String id) {
+ return "bonita:startCase?hostname=localhost&port=" + server.port() +
"&processName=orders&username=user" + id
+ + "&password=pass" + id;
+ }
+
+ /**
+ * A Bonita server that knows the process "orders" and names its session,
its API token and the path of an uploaded
+ * file after itself.
+ */
+ private static WireMockServer startBonita(String id) {
+ WireMockServer server = new
WireMockServer(WireMockConfiguration.options().dynamicPort());
+ server.start();
+ server.stubFor(post(urlEqualTo(LOGIN))
+ .willReturn(aResponse().withHeader("Set-Cookie",
"JSESSIONID=session-" + id,
+ "X-Bonita-API-Token=token-" + id)));
+ server.stubFor(get(urlPathEqualTo(PROCESS)).withQueryParam("s",
equalTo("orders"))
+ .willReturn(aResponse().withHeader("Content-Type",
"application/json")
+
.withBody("[{\"id\":\"7\",\"name\":\"orders\",\"version\":\"1.0\"}]")));
+ server.stubFor(post(urlEqualTo(UPLOAD))
+ .willReturn(aResponse().withHeader("Content-Type",
"text/plain;charset=UTF-8")
+
.withBody("{\"filename\":\"report.txt\",\"tempPath\":\"tmp_" + id
+ +
".doc\",\"contentType\":\"text/plain\"}")));
+ server.stubFor(post(urlEqualTo(INSTANTIATION))
+ .willReturn(aResponse().withHeader("Content-Type",
"application/json")
+ .withBody("{\"caseId\":\"1\"}")));
+ return server;
+ }
+
+ /**
+ * The server got exactly the requests of the given number of its own
exchanges (per exchange: process lookup, file
+ * upload and case creation, each after its own login), every one of them
with the account and the session of this
+ * server, and every case refers to a file that this server stored.
+ */
+ private static void assertOwnRequestsOnly(WireMockServer server, String
id, int exchanges) {
+ assertRequests(server, 3 * exchanges, exchanges, exchanges, exchanges);
+ for (LoggedRequest request :
server.findAll(anyRequestedFor(anyUrl()))) {
+ String path = request.getUrl();
+ if (LOGIN.equals(path)) {
+ String form = request.getBodyAsString();
+ assertTrue(form.contains("username=user" + id) &&
form.contains("password=pass" + id),
+ "login at Bonita server " + id + " with the account of
another endpoint: " + form);
+ } else {
+ assertEquals("token-" + id,
request.getHeader("X-Bonita-API-Token"),
+ "API token of " + path + " at Bonita server " + id);
+ String cookies = String.join(",",
request.header("Cookie").values());
+ assertTrue(cookies.contains("JSESSIONID=session-" + id),
+ "session of " + path + " at Bonita server " + id + ":
" + cookies);
+ }
+ if (INSTANTIATION.equals(path)) {
+ assertTrue(request.getBodyAsString().contains("tmp_" + id +
".doc"),
+ "the case started at Bonita server " + id + " must
refer to the file uploaded to this server: "
+
+ request.getBodyAsString());
+ }
+ }
+ }
+
+ private static void assertRequests(WireMockServer server, int logins, int
lookups, int uploads, int cases) {
+ List<LoggedRequest> requests =
server.findAll(anyRequestedFor(anyUrl()));
+ String received = "requests received by the Bonita server on port " +
server.port();
+ assertEquals(uploads, count(requests, UPLOAD), "file uploads among the
" + received);
+ assertEquals(cases, count(requests, INSTANTIATION), "case creations
among the " + received);
+ assertEquals(lookups, count(requests, PROCESS + "?s=orders"), "process
lookups among the " + received);
+ assertEquals(logins, count(requests, LOGIN), "logins among the " +
received);
+ assertEquals(logins + lookups + uploads + cases, requests.size(),
received);
+ }
+
+ private static long count(List<LoggedRequest> requests, String url) {
+ return requests.stream().filter(r -> url.equals(r.getUrl())).count();
+ }
+
+ /**
+ * The upload is one multipart part with the content of the file input,
named like a temporary file
+ * ({@code tempFile<digits>.tmp}, as on main). No file of that name may be
left in the temporary directory.
+ */
+ private static void
assertUploadedPartAndNoTemporaryFileLeft(WireMockServer server) throws
Exception {
+ List<LoggedRequest> uploads =
server.findAll(anyRequestedFor(urlEqualTo(UPLOAD)));
+ assertEquals(1, uploads.size(), "file uploads received");
+ LoggedRequest upload = uploads.get(0);
+ String contentType = upload.getHeader("Content-Type");
+ assertTrue(contentType.startsWith("multipart/form-data"), "content
type of the upload: " + contentType);
+ String body = upload.getBodyAsString();
+ Matcher matcher = UPLOADED_PART.matcher(body);
+ assertTrue(matcher.matches(), "the upload must be one part named like
a temporary file: " + body);
+ assertEquals(matcher.group(1), matcher.group(4), "closing boundary of
the upload: " + body);
+ assertEquals("content", matcher.group(3), "content of the uploaded
part");
+ Path file = Path.of(System.getProperty("java.io.tmpdir"),
matcher.group(2));
+
+ // deleteIfExists also cleans up when the assertion fails
+ assertFalse(Files.deleteIfExists(file), "the temporary file of the
upload was left behind: " + file);
+ }
+}
diff --git
a/components/camel-bonita/src/test/java/org/apache/camel/component/bonita/api/util/BonitaAPIUtilPrepareInputsTest.java
b/components/camel-bonita/src/test/java/org/apache/camel/component/bonita/api/util/BonitaAPIUtilPrepareInputsTest.java
index b503da9739e5..dd3f3389a7a3 100644
---
a/components/camel-bonita/src/test/java/org/apache/camel/component/bonita/api/util/BonitaAPIUtilPrepareInputsTest.java
+++
b/components/camel-bonita/src/test/java/org/apache/camel/component/bonita/api/util/BonitaAPIUtilPrepareInputsTest.java
@@ -20,9 +20,13 @@ import java.io.Serializable;
import java.util.HashMap;
import java.util.Map;
+import jakarta.ws.rs.client.Client;
+import jakarta.ws.rs.client.ClientBuilder;
+
import org.apache.camel.component.bonita.api.model.FileInput;
import org.apache.camel.component.bonita.api.model.ProcessDefinitionResponse;
import org.apache.camel.component.bonita.api.model.UploadFileResponse;
+import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -41,11 +45,18 @@ public class BonitaAPIUtilPrepareInputsTest {
@Mock
ProcessDefinitionResponse processDefinition;
+ private Client client;
private BonitaAPIUtil bonitaApiUtil;
@BeforeEach
public void setup() {
- bonitaApiUtil = BonitaAPIUtil.getInstance(new
BonitaAPIConfig("hostname", "port", "username", "password"));
+ client = ClientBuilder.newClient();
+ bonitaApiUtil = BonitaAPIUtil.forClient(client, new
BonitaAPIConfig("hostname", "port", "username", "password"));
+ }
+
+ @AfterEach
+ public void tearDown() {
+ client.close();
}
@Test