From c9984499b4c72fd492e4b00eae64ea0405fa0418 Mon Sep 17 00:00:00 2001 From: Filip Hrisafov Date: Fri, 13 Feb 2026 22:53:49 +0100 Subject: [PATCH] Add mime type support for text MultiValuePart with configurable multipart mode Add a fromText(name, value, mimeType) factory method to MultiValuePart so that text parts can specify a custom Content-Type (e.g. application/json). Both Apache HTTP client implementations now respect the mime type on text parts by passing ContentType to addTextBody when set. The multipart entity builder mode is changed from BROWSER_COMPATIBLE/LEGACY to STRICT so that Content-Type headers are written for all parts including text parts. For backward compatibility, the multipart mode is configurable via HttpClientConfig.setMultipartMode(). Supported values are STRICT (default), BROWSER_COMPATIBLE, LEGACY, and EXTENDED. The Spring WebClient implementation is unaffected as it already handled mime types for all parts. --- .../http/common/api/MultiValuePart.java | 4 + .../http/common/impl/HttpClientConfig.java | 28 ++++ ...pacheHttpComponentsFlowableHttpClient.java | 23 ++- ...acheHttpComponents5FlowableHttpClient.java | 24 ++- ...tibleApacheHttpClientArgumentProvider.java | 52 +++++++ .../flowable/http/FlowableHttpClientTest.java | 140 ++++++++++++++++++ 6 files changed, 267 insertions(+), 4 deletions(-) create mode 100644 modules/flowable-http/src/test/java/org/flowable/http/BrowserCompatibleApacheHttpClientArgumentProvider.java diff --git a/modules/flowable-http-common/src/main/java/org/flowable/http/common/api/MultiValuePart.java b/modules/flowable-http-common/src/main/java/org/flowable/http/common/api/MultiValuePart.java index b525d867962..aea2a5cb1a4 100644 --- a/modules/flowable-http-common/src/main/java/org/flowable/http/common/api/MultiValuePart.java +++ b/modules/flowable-http-common/src/main/java/org/flowable/http/common/api/MultiValuePart.java @@ -56,6 +56,10 @@ public static MultiValuePart fromText(String name, String value) { return new MultiValuePart(name, value, null); } + public static MultiValuePart fromText(String name, String value, String mimeType) { + return new MultiValuePart(name, value, null, mimeType); + } + public static MultiValuePart fromFile(String name, byte[] value, String filename) { return new MultiValuePart(name, value, filename); } diff --git a/modules/flowable-http-common/src/main/java/org/flowable/http/common/impl/HttpClientConfig.java b/modules/flowable-http-common/src/main/java/org/flowable/http/common/impl/HttpClientConfig.java index 1d19896f29d..88861021a28 100644 --- a/modules/flowable-http-common/src/main/java/org/flowable/http/common/impl/HttpClientConfig.java +++ b/modules/flowable-http-common/src/main/java/org/flowable/http/common/impl/HttpClientConfig.java @@ -92,6 +92,22 @@ public class HttpClientConfig { protected boolean useSystemProperties = false; + /** + * The multipart mode to use when building multipart/form-data requests with the Apache HTTP client implementations. + *

+ * Supported values: + *

+ *

+ * This setting has no effect on the Spring WebClient implementation. + */ + protected String multipartMode = "STRICT"; + protected FlowableHttpClient httpClient; protected Runnable closeRunnable; @@ -148,6 +164,14 @@ public boolean isUseSystemProperties() { return useSystemProperties; } + public String getMultipartMode() { + return multipartMode; + } + + public void setMultipartMode(String multipartMode) { + this.multipartMode = multipartMode; + } + public void merge(HttpClientConfig other) { if (this.connectTimeout != other.getConnectTimeout()) { setConnectTimeout(other.getConnectTimeout()); @@ -173,6 +197,10 @@ public void merge(HttpClientConfig other) { setUseSystemProperties(other.isUseSystemProperties()); } + if (!Objects.equals(this.multipartMode, other.getMultipartMode())) { + setMultipartMode(other.getMultipartMode()); + } + if (!Objects.equals(this.httpClient, other.getHttpClient())) { setHttpClient(other.getHttpClient()); } diff --git a/modules/flowable-http-common/src/main/java/org/flowable/http/common/impl/apache/ApacheHttpComponentsFlowableHttpClient.java b/modules/flowable-http-common/src/main/java/org/flowable/http/common/impl/apache/ApacheHttpComponentsFlowableHttpClient.java index e54625b773a..1e9c30bb02e 100644 --- a/modules/flowable-http-common/src/main/java/org/flowable/http/common/impl/apache/ApacheHttpComponentsFlowableHttpClient.java +++ b/modules/flowable-http-common/src/main/java/org/flowable/http/common/impl/apache/ApacheHttpComponentsFlowableHttpClient.java @@ -102,6 +102,7 @@ public class ApacheHttpComponentsFlowableHttpClient implements FlowableHttpClien protected final Logger logger = LoggerFactory.getLogger(getClass()); protected HttpClientBuilder clientBuilder; + protected HttpMultipartMode multipartMode; protected int socketTimeout; protected int connectTimeout; protected int connectionRequestTimeout; @@ -149,6 +150,7 @@ public boolean verify(String s, SSLSession sslSession) { this.clientBuilder = httpClientBuilder; + this.multipartMode = resolveMultipartMode(config.getMultipartMode()); this.socketTimeout = config.getSocketTimeout(); this.connectTimeout = config.getConnectTimeout(); this.connectionRequestTimeout = config.getConnectionRequestTimeout(); @@ -157,11 +159,24 @@ public boolean verify(String s, SSLSession sslSession) { public ApacheHttpComponentsFlowableHttpClient(HttpClientBuilder clientBuilder, int socketTimeout, int connectTimeout, int connectionRequestTimeout) { this.clientBuilder = clientBuilder; + this.multipartMode = HttpMultipartMode.STRICT; this.socketTimeout = socketTimeout; this.connectTimeout = connectTimeout; this.connectionRequestTimeout = connectionRequestTimeout; } + protected static HttpMultipartMode resolveMultipartMode(String mode) { + if (mode == null) { + return HttpMultipartMode.STRICT; + } + return switch (mode.toUpperCase()) { + case "BROWSER_COMPATIBLE" -> HttpMultipartMode.BROWSER_COMPATIBLE; + case "STRICT" -> HttpMultipartMode.STRICT; + default -> throw new FlowableIllegalArgumentException("Unsupported multipart mode: " + mode + + ". Supported values are: STRICT, BROWSER_COMPATIBLE"); + }; + } + @Override public ExecutableHttpRequest prepareRequest(HttpRequest requestInfo) { try { @@ -235,7 +250,7 @@ protected void setRequestEntity(HttpRequest requestInfo, HttpEntityEnclosingRequ } else if (requestInfo.getMultiValueParts() != null) { if (MULTIPART_ENTITY_BUILDER_PRESENT) { MultipartEntityBuilder entityBuilder = MultipartEntityBuilder.create(); - entityBuilder.setMode(HttpMultipartMode.BROWSER_COMPATIBLE); + entityBuilder.setMode(multipartMode); for (MultiValuePart part : requestInfo.getMultiValueParts()) { String name = part.getName(); Object value = part.getBody(); @@ -246,7 +261,11 @@ protected void setRequestEntity(HttpRequest requestInfo, HttpEntityEnclosingRequ entityBuilder.addBinaryBody(name, (byte[]) value, ContentType.DEFAULT_BINARY, part.getFilename()); } } else if (value instanceof String) { - entityBuilder.addTextBody(name, (String) value); + if (StringUtils.isNotBlank(part.getMimeType())) { + entityBuilder.addTextBody(name, (String) value, ContentType.create(part.getMimeType())); + } else { + entityBuilder.addTextBody(name, (String) value); + } } else if (value != null) { throw new FlowableIllegalArgumentException("Value of type " + value.getClass() + " is not supported as multi part content"); } diff --git a/modules/flowable-http-common/src/main/java/org/flowable/http/common/impl/apache/client5/ApacheHttpComponents5FlowableHttpClient.java b/modules/flowable-http-common/src/main/java/org/flowable/http/common/impl/apache/client5/ApacheHttpComponents5FlowableHttpClient.java index e7df28b902b..52324decd59 100644 --- a/modules/flowable-http-common/src/main/java/org/flowable/http/common/impl/apache/client5/ApacheHttpComponents5FlowableHttpClient.java +++ b/modules/flowable-http-common/src/main/java/org/flowable/http/common/impl/apache/client5/ApacheHttpComponents5FlowableHttpClient.java @@ -86,6 +86,7 @@ public class ApacheHttpComponents5FlowableHttpClient implements FlowableAsyncHtt protected HttpAsyncClient client; protected boolean closeClient; + protected HttpMultipartMode multipartMode; protected int socketTimeout; protected int connectTimeout; protected int connectionRequestTimeout; @@ -133,6 +134,7 @@ public ApacheHttpComponents5FlowableHttpClient(HttpClientConfig config, Consumer this.client = client; this.closeClient = true; + this.multipartMode = resolveMultipartMode(config.getMultipartMode()); this.socketTimeout = config.getSocketTimeout(); this.connectTimeout = config.getConnectTimeout(); this.connectionRequestTimeout = config.getConnectionRequestTimeout(); @@ -141,11 +143,25 @@ public ApacheHttpComponents5FlowableHttpClient(HttpClientConfig config, Consumer public ApacheHttpComponents5FlowableHttpClient(HttpAsyncClient client, int socketTimeout, int connectTimeout, int connectionRequestTimeout) { this.client = client; + this.multipartMode = HttpMultipartMode.STRICT; this.socketTimeout = socketTimeout; this.connectTimeout = connectTimeout; this.connectionRequestTimeout = connectionRequestTimeout; } + protected static HttpMultipartMode resolveMultipartMode(String mode) { + if (mode == null) { + return HttpMultipartMode.STRICT; + } + return switch (mode.toUpperCase()) { + case "BROWSER_COMPATIBLE", "LEGACY" -> HttpMultipartMode.LEGACY; + case "STRICT" -> HttpMultipartMode.STRICT; + case "EXTENDED" -> HttpMultipartMode.EXTENDED; + default -> throw new FlowableIllegalArgumentException("Unsupported multipart mode: " + mode + + ". Supported values are: STRICT, BROWSER_COMPATIBLE, LEGACY, EXTENDED"); + }; + } + public void close() { if (closeClient && client instanceof ModalCloseable) { ((ModalCloseable) client).close(CloseMode.GRACEFUL); @@ -220,7 +236,7 @@ protected void setRequestEntity(HttpRequest requestInfo, AsyncRequestBuilder req } } else if (requestInfo.getMultiValueParts() != null) { MultipartEntityBuilder entityBuilder = MultipartEntityBuilder.create(); - entityBuilder.setMode(HttpMultipartMode.LEGACY); + entityBuilder.setMode(multipartMode); for (MultiValuePart part : requestInfo.getMultiValueParts()) { String name = part.getName(); Object value = part.getBody(); @@ -231,7 +247,11 @@ protected void setRequestEntity(HttpRequest requestInfo, AsyncRequestBuilder req entityBuilder.addBinaryBody(name, (byte[]) value, ContentType.DEFAULT_BINARY, part.getFilename()); } } else if (value instanceof String) { - entityBuilder.addTextBody(name, (String) value); + if (StringUtils.isNotBlank(part.getMimeType())) { + entityBuilder.addTextBody(name, (String) value, ContentType.create(part.getMimeType())); + } else { + entityBuilder.addTextBody(name, (String) value); + } } else if (value != null) { throw new FlowableIllegalArgumentException("Value of type " + value.getClass() + " is not supported as multi part content"); } diff --git a/modules/flowable-http/src/test/java/org/flowable/http/BrowserCompatibleApacheHttpClientArgumentProvider.java b/modules/flowable-http/src/test/java/org/flowable/http/BrowserCompatibleApacheHttpClientArgumentProvider.java new file mode 100644 index 00000000000..dc4d063e3f9 --- /dev/null +++ b/modules/flowable-http/src/test/java/org/flowable/http/BrowserCompatibleApacheHttpClientArgumentProvider.java @@ -0,0 +1,52 @@ +/* Licensed 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.flowable.http; + +import java.time.Duration; +import java.util.stream.Stream; + +import org.flowable.http.common.impl.HttpClientConfig; +import org.flowable.http.common.impl.apache.ApacheHttpComponentsFlowableHttpClient; +import org.flowable.http.common.impl.apache.client5.ApacheHttpComponents5FlowableHttpClient; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.ArgumentsProvider; +import org.junit.jupiter.params.support.ParameterDeclarations; + +/** + * @author Filip Hrisafov + */ +public class BrowserCompatibleApacheHttpClientArgumentProvider implements ArgumentsProvider { + + @Override + public Stream provideArguments(ParameterDeclarations parameters, ExtensionContext context) { + HttpClientConfig config = createClientConfig(); + return Stream.of( + Arguments.of(new ApacheHttpComponentsFlowableHttpClient(config)), + Arguments.of(new ApacheHttpComponents5FlowableHttpClient(config)) + ); + } + + protected HttpClientConfig createClientConfig() { + HttpClientConfig config = new HttpClientConfig(); + config.setConnectTimeout(Duration.ofSeconds(5)); + config.setSocketTimeout(Duration.ofSeconds(5)); + config.setConnectionRequestTimeout(Duration.ofSeconds(5)); + config.setRequestRetryLimit(5); + config.setDisableCertVerify(true); + config.setMultipartMode("BROWSER_COMPATIBLE"); + + return config; + } + +} diff --git a/modules/flowable-http/src/test/java/org/flowable/http/FlowableHttpClientTest.java b/modules/flowable-http/src/test/java/org/flowable/http/FlowableHttpClientTest.java index 71df818025a..0ed2fcd8fac 100644 --- a/modules/flowable-http/src/test/java/org/flowable/http/FlowableHttpClientTest.java +++ b/modules/flowable-http/src/test/java/org/flowable/http/FlowableHttpClientTest.java @@ -14,19 +14,25 @@ import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; import org.apache.commons.io.IOUtils; +import org.flowable.common.engine.api.FlowableIllegalArgumentException; import org.flowable.http.bpmn.HttpServiceTaskTestServer; import org.flowable.http.common.api.HttpHeaders; import org.flowable.http.common.api.HttpRequest; import org.flowable.http.common.api.HttpResponse; import org.flowable.http.common.api.MultiValuePart; import org.flowable.http.common.api.client.FlowableHttpClient; +import org.flowable.http.common.impl.HttpClientConfig; +import org.flowable.http.common.impl.apache.ApacheHttpComponentsFlowableHttpClient; +import org.flowable.http.common.impl.apache.client5.ApacheHttpComponents5FlowableHttpClient; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ArgumentsSource; import org.springframework.core.io.ClassPathResource; @@ -224,6 +230,49 @@ void postWithMultiPartWithMimeType(FlowableHttpClient httpClient) { .containsExactly("application/json"); } + @ParameterizedTest + @ArgumentsSource(FlowableHttpClientArgumentProvider.class) + void postWithMultiPartTextWithMimeType(FlowableHttpClient httpClient) { + HttpRequest request = new HttpRequest(); + request.setUrl("http://localhost:9798/api/test-multi?testArg=testMultiPartTextWithMimeType"); + request.setMethod("POST"); + request.addMultiValuePart(MultiValuePart.fromText("name", "kermit")); + request.addMultiValuePart(MultiValuePart.fromText("jsonData", "{\"value\":\"kermit\"}", "application/json")); + HttpHeaders httpHeaders = new HttpHeaders(); + httpHeaders.add("X-Test", "Test MultiPart Text With MimeType"); + request.setHttpHeaders(httpHeaders); + HttpResponse response = httpClient.prepareRequest(request).call(); + + assertThatJson(response.getBody()) + .when(Option.IGNORING_EXTRA_FIELDS) + .isEqualTo(""" + { + url: 'http://localhost:9798/api/test-multi', + args: { + testArg: [ 'testMultiPartTextWithMimeType' ] + }, + headers: { + X-Test: [ 'Test MultiPart Text With MimeType' ] + }, + parts: { + name: [ + { + content: 'kermit' + } + ], + jsonData: [ + { + content: '{"value":"kermit"}', + contentType: 'application/json' + } + ] + } + }"""); + assertThat(response.getStatusCode()).isEqualTo(200); + assertThat(response.getHttpHeaders().get("Content-Type")) + .containsExactly("application/json"); + } + @ParameterizedTest @ArgumentsSource(FlowableHttpClientArgumentProvider.class) void postWithFormParameters(FlowableHttpClient httpClient) { @@ -320,5 +369,96 @@ void simpleOptions(FlowableHttpClient httpClient) { .isEqualTo("GET, HEAD, TRACE, OPTIONS"); } + @ParameterizedTest + @ArgumentsSource(BrowserCompatibleApacheHttpClientArgumentProvider.class) + void postWithMultiPartTextWithMimeTypeWithBrowserCompatibleMode(FlowableHttpClient httpClient) { + HttpRequest request = new HttpRequest(); + request.setUrl("http://localhost:9798/api/test-multi?testArg=testMultiPartTextBrowserCompat"); + request.setMethod("POST"); + request.addMultiValuePart(MultiValuePart.fromText("name", "kermit")); + request.addMultiValuePart(MultiValuePart.fromText("jsonData", "{\"value\":\"kermit\"}", "application/json")); + HttpHeaders httpHeaders = new HttpHeaders(); + httpHeaders.add("X-Test", "Test MultiPart Text Browser Compatible"); + request.setHttpHeaders(httpHeaders); + HttpResponse response = httpClient.prepareRequest(request).call(); + + // In BROWSER_COMPATIBLE mode, Content-Type headers are not written for text parts (parts without a filename), + // so the mime type set on the text part is not sent to the server + assertThatJson(response.getBody()) + .whenIgnoringPaths("args", "code", "delay", "headers", "origin") + .isEqualTo(""" + { + url: 'http://localhost:9798/api/test-multi', + parts: { + name: [ + { + content: 'kermit' + } + ], + jsonData: [ + { + content: '{"value":"kermit"}' + } + ] + } + }"""); + assertThat(response.getStatusCode()).isEqualTo(200); + } + + @ParameterizedTest + @ArgumentsSource(BrowserCompatibleApacheHttpClientArgumentProvider.class) + void postWithMultiPartFileMimeTypeWithBrowserCompatibleMode(FlowableHttpClient httpClient) { + HttpRequest request = new HttpRequest(); + request.setUrl("http://localhost:9798/api/test-multi?testArg=testMultiPartFileBrowserCompat"); + request.setMethod("POST"); + request.addMultiValuePart(MultiValuePart.fromFile("document", "kermit;gonzo".getBytes(StandardCharsets.UTF_8), "kermit.csv", "text/csv")); + request.addMultiValuePart(MultiValuePart.fromFile("myJson", "{'value':'kermit'}".getBytes(StandardCharsets.UTF_8), "kermit.json", "application/json")); + HttpResponse response = httpClient.prepareRequest(request).call(); + + // In BROWSER_COMPATIBLE mode, Content-Type headers are still written for file parts (parts with a filename) + assertThatJson(response.getBody()) + .when(Option.IGNORING_EXTRA_FIELDS) + .isEqualTo(""" + { + url: 'http://localhost:9798/api/test-multi', + parts: { + document: [ + { + content: 'kermit;gonzo', + filename: 'kermit.csv', + contentType: 'text/csv' + } + ], + myJson: [ + { + content: "{'value':'kermit'}", + filename: 'kermit.json', + contentType: 'application/json' + } + ] + } + }"""); + assertThat(response.getStatusCode()).isEqualTo(200); + } + + @Test + void invalidMultipartModeForApacheHttpComponents() { + HttpClientConfig config = new HttpClientConfig(); + config.setMultipartMode("INVALID"); + + assertThatThrownBy(() -> new ApacheHttpComponentsFlowableHttpClient(config)) + .isInstanceOf(FlowableIllegalArgumentException.class) + .hasMessageContaining("Unsupported multipart mode: INVALID"); + } + + @Test + void invalidMultipartModeForApacheHttpComponents5() { + HttpClientConfig config = new HttpClientConfig(); + config.setMultipartMode("INVALID"); + + assertThatThrownBy(() -> new ApacheHttpComponents5FlowableHttpClient(config)) + .isInstanceOf(FlowableIllegalArgumentException.class) + .hasMessageContaining("Unsupported multipart mode: INVALID"); + } }