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 c5419d1de0c9 CAMEL-25526: camel-vertx-http - Send each value of a
multi-value header as its own HTTP header (#27689)
c5419d1de0c9 is described below
commit c5419d1de0c9920347dd2b761e54a75ae76e4e14
Author: allthingssecurity <[email protected]>
AuthorDate: Tue Oct 13 01:24:13 2026 +0530
CAMEL-25526: camel-vertx-http - Send each value of a multi-value header as
its own HTTP header (#27689)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
.../vertx/http/DefaultVertxHttpBinding.java | 13 +-
.../vertx/http/VertxHttpMultiValueHeaderTest.java | 141 +++++++++++++++++
.../org/apache/camel/support/http/HttpUtil.java | 38 +++++
.../apache/camel/support/http/HttpUtilTest.java | 167 +++++++++++++++++++++
.../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 11 ++
5 files changed, 366 insertions(+), 4 deletions(-)
diff --git
a/components/camel-vertx/camel-vertx-http/src/main/java/org/apache/camel/component/vertx/http/DefaultVertxHttpBinding.java
b/components/camel-vertx/camel-vertx-http/src/main/java/org/apache/camel/component/vertx/http/DefaultVertxHttpBinding.java
index d16cb2a0dbfe..d90764122458 100644
---
a/components/camel-vertx/camel-vertx-http/src/main/java/org/apache/camel/component/vertx/http/DefaultVertxHttpBinding.java
+++
b/components/camel-vertx/camel-vertx-http/src/main/java/org/apache/camel/component/vertx/http/DefaultVertxHttpBinding.java
@@ -38,6 +38,7 @@ import org.apache.camel.http.base.HttpHelper;
import org.apache.camel.http.base.HttpOperationFailedException;
import org.apache.camel.spi.HeaderFilterStrategy;
import org.apache.camel.support.ExchangeHelper;
+import org.apache.camel.support.http.HttpUtil;
import org.apache.camel.util.IOHelper;
import org.apache.camel.util.ObjectHelper;
@@ -138,10 +139,14 @@ public class DefaultVertxHttpBinding implements
VertxHttpBinding {
continue;
}
- if (!strategy.applyFilterToCamelHeaders(key, headerValue,
exchange)) {
- String str = tc.convertTo(String.class, headerValue);
- headers.set(key, str);
- }
+ // a collection or an object array is sent as one header per
value; any other value is a single header
+ HttpUtil.applyHeaderValue(strategy, exchange, headerValue, tc,
key, (multiValues, prev) -> {
+ if (multiValues != null) {
+ headers.set(key, multiValues);
+ } else if (prev != null) {
+ headers.set(key, prev);
+ }
+ });
}
}
}
diff --git
a/components/camel-vertx/camel-vertx-http/src/test/java/org/apache/camel/component/vertx/http/VertxHttpMultiValueHeaderTest.java
b/components/camel-vertx/camel-vertx-http/src/test/java/org/apache/camel/component/vertx/http/VertxHttpMultiValueHeaderTest.java
new file mode 100644
index 000000000000..c6ec209b08d4
--- /dev/null
+++
b/components/camel-vertx/camel-vertx-http/src/test/java/org/apache/camel/component/vertx/http/VertxHttpMultiValueHeaderTest.java
@@ -0,0 +1,141 @@
+/*
+ * 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.vertx.http;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import io.vertx.core.json.JsonArray;
+import io.vertx.core.json.JsonObject;
+import org.apache.camel.RoutesBuilder;
+import org.apache.camel.builder.RouteBuilder;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * A header whose value is a collection or an object array (as the HTTP
consumers map a repeated request header) must be
+ * sent as one HTTP header per value. Any other value is one header.
+ */
+class VertxHttpMultiValueHeaderTest extends VertxHttpTestSupport {
+
+ private static final String NOT_SENT = "X-Test was not sent";
+
+ @Test
+ void sendsEachValueOfAListHeader() {
+ String result = template.requestBodyAndHeader(getProducerUri(), null,
"X-Test", List.of("a", "b"), String.class);
+ assertEquals("a|b", result);
+ }
+
+ @Test
+ void sendsASingleValueListHeaderAsSingleHeader() {
+ String result = template.requestBodyAndHeader(getProducerUri(), null,
"X-Test", List.of("a"), String.class);
+ assertEquals("a", result);
+ }
+
+ @Test
+ void sendsEachValueOfAnArrayHeader() {
+ String result = template.requestBodyAndHeader(getProducerUri(), null,
"X-Test", new String[] { "a", "b" },
+ String.class);
+ assertEquals("a|b", result);
+ }
+
+ @Test
+ void sendsAStringHeaderWithACommaAsOneValue() {
+ String result = template.requestBodyAndHeader(getProducerUri(), null,
"X-Test", "a, b", String.class);
+ assertEquals("a, b", result);
+ }
+
+ @Test
+ void sendsAByteArrayHeaderAsOneValue() {
+ // a byte[] (such as a Kafka record header) is one value and not one
header per byte
+ byte[] value = "abc".getBytes(StandardCharsets.UTF_8);
+ String result = template.requestBodyAndHeader(getProducerUri(), null,
"X-Test", value, String.class);
+ assertEquals("abc", result);
+ }
+
+ @Test
+ void sendsAJsonObjectHeaderAsItsJson() {
+ // a Vert.x JsonObject is an Iterable of its entries, but it is one
value
+ JsonObject value = new JsonObject().put("a", "b").put("c", "d");
+ String result = template.requestBodyAndHeader(getProducerUri(), null,
"X-Test", value, String.class);
+ assertEquals("{\"a\":\"b\",\"c\":\"d\"}", result);
+ }
+
+ @Test
+ void sendsAJsonArrayHeaderAsItsJson() {
+ // a Vert.x JsonArray is an Iterable of its elements, but it is one
value (a List has one header per value)
+ JsonArray value = new JsonArray().add("a").add("b");
+ String result = template.requestBodyAndHeader(getProducerUri(), null,
"X-Test", value, String.class);
+ assertEquals("[\"a\",\"b\"]", result);
+ }
+
+ @Test
+ void sendsAMapHeaderAsOneValue() {
+ Map<String, Object> value = new LinkedHashMap<>();
+ value.put("a", "b");
+ value.put("c", "d");
+ String result = template.requestBodyAndHeader(getProducerUri(), null,
"X-Test", value, String.class);
+ assertEquals("{a=b, c=d}", result);
+ }
+
+ @Test
+ void doesNotSendAHeaderForAnEmptyList() {
+ // a collection without values has no header to send (it was sent as
the value "[]")
+ String result = template.requestBodyAndHeader(getProducerUri(), null,
"X-Test", List.of(), String.class);
+ assertEquals(NOT_SENT, result);
+ }
+
+ @Test
+ void doesNotSendAHeaderForAnEmptyArray() {
+ String result = template.requestBodyAndHeader(getProducerUri(), null,
"X-Test", new Object[0], String.class);
+ assertEquals(NOT_SENT, result);
+ }
+
+ @Test
+ void doesNotSendAHeaderForAListWithOnlyNullValues() {
+ String result = template.requestBodyAndHeader(getProducerUri(), null,
"X-Test", Arrays.asList(null, null),
+ String.class);
+ assertEquals(NOT_SENT, result);
+ }
+
+ @Override
+ protected RoutesBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ // the undertow consumer maps a repeated request header to a
list of its values
+ from(getTestServerUri())
+ .process(exchange -> {
+ Object value =
exchange.getMessage().getHeader("X-Test");
+ String body;
+ if (value == null) {
+ body = NOT_SENT;
+ } else if (value instanceof List<?> list) {
+ body = String.join("|",
list.stream().map(String::valueOf).toList());
+ } else {
+ body = String.valueOf(value);
+ }
+ exchange.getMessage().setBody(body);
+ });
+ }
+ };
+ }
+}
diff --git
a/core/camel-support/src/main/java/org/apache/camel/support/http/HttpUtil.java
b/core/camel-support/src/main/java/org/apache/camel/support/http/HttpUtil.java
index 4a5604561bc1..4a8ae4eba454 100644
---
a/core/camel-support/src/main/java/org/apache/camel/support/http/HttpUtil.java
+++
b/core/camel-support/src/main/java/org/apache/camel/support/http/HttpUtil.java
@@ -17,6 +17,8 @@
package org.apache.camel.support.http;
import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
@@ -233,6 +235,42 @@ public final class HttpUtil {
consumer.accept(values, firstValue);
}
+ /**
+ * Passes the value or values of a message header to the consumer after
applying the filter strategy. This is mostly
+ * used to simplify setting the headers of HTTP requests and responses.
+ * <p/>
+ * A {@link Collection} or an object array has one value per element: the
elements are converted and filtered one by
+ * one as {@link #applyHeader(HeaderFilterStrategy, Exchange, Iterator,
TypeConverter, String, BiConsumer)} does,
+ * and null elements are skipped. Every other value is a single value,
which is filtered as it is and then converted
+ * to a String: a String (it is not split on a delimiter), a
<tt>byte[]</tt> or another primitive array, a
+ * {@link Map}, and an {@link Iterable} that is not a collection (such as
a JSON object or JSON array type that
+ * iterates over its content).
+ *
+ * @param headerFilterStrategy the filter strategy to apply
+ * @param exchange an exchange to apply the header strategy
+ * @param value the header value
+ * @param tc a type converter instance so that the
values can be converted to string
+ * @param key the header name
+ * @param consumer a consumer method to receive the converted
values. It can receive either a list of
+ * values (two or more) or a single value, and
null for both when there is no value to
+ * send.
+ */
+ public static void applyHeaderValue(
+ HeaderFilterStrategy headerFilterStrategy, Exchange exchange,
Object value,
+ TypeConverter tc, String key, BiConsumer<List<String>, String>
consumer) {
+ if (value instanceof Collection<?> collection) {
+ applyHeader(headerFilterStrategy, exchange, collection.iterator(),
tc, key, consumer);
+ } else if (value instanceof Object[] array) {
+ applyHeader(headerFilterStrategy, exchange,
Arrays.asList(array).iterator(), tc, key, consumer);
+ } else {
+ String headerValue = null;
+ if (value != null &&
!headerFilterStrategy.applyFilterToCamelHeaders(key, value, exchange)) {
+ headerValue = tc.convertTo(String.class, value);
+ }
+ consumer.accept(null, headerValue);
+ }
+ }
+
/*
* Using rest producer then headers are mapping to uri and query
parameters using {key} syntax
* if there is a match to an existing Camel Message header, then we should
filter (=true) this
diff --git
a/core/camel-support/src/test/java/org/apache/camel/support/http/HttpUtilTest.java
b/core/camel-support/src/test/java/org/apache/camel/support/http/HttpUtilTest.java
index b20b5f4b3f25..0951224cc54a 100644
---
a/core/camel-support/src/test/java/org/apache/camel/support/http/HttpUtilTest.java
+++
b/core/camel-support/src/test/java/org/apache/camel/support/http/HttpUtilTest.java
@@ -16,13 +16,39 @@
*/
package org.apache.camel.support.http;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.camel.Exchange;
+import org.apache.camel.TypeConverter;
+import org.apache.camel.spi.HeaderFilterStrategy;
+import org.apache.camel.support.TypeConverterSupport;
import org.junit.jupiter.api.Test;
+import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertNull;
@SuppressWarnings("deprecation")
class HttpUtilTest {
+ private static final String FILTERED = "filtered";
+
+ private static final TypeConverter CONVERTER = new TypeConverterSupport() {
+ @Override
+ public <T> T convertTo(Class<T> type, Exchange exchange, Object value)
{
+ assertThat(type).isEqualTo(String.class);
+ if (value == null) {
+ return null;
+ }
+ return type.cast(value instanceof byte[] bytes ? new String(bytes,
StandardCharsets.UTF_8) : value.toString());
+ }
+ };
+
@Test
void testGetCharsetFromContentTypeEmptyCharset() {
// an empty charset parameter is no charset
@@ -31,4 +57,145 @@ class HttpUtilTest {
assertNull(HttpUtil.getCharsetFromContentType("text/plain; charset=
"));
assertNull(HttpUtil.getCharsetFromContentType("text/plain; charset=;
format=flowed"));
}
+
+ @Test
+ void applyHeaderValueGivesEachValueOfACollection() {
+ Applied applied = applyHeaderValue(List.of("a", 2, "c"));
+
+ assertThat(applied.values).containsExactly("a", "2", "c");
+ assertThat(applied.single).isEqualTo("a");
+ // each element is filtered by itself, as a String
+ assertThat(applied.filtered).containsExactly("a", "2", "c");
+ }
+
+ @Test
+ void applyHeaderValueGivesEachValueOfAnObjectArray() {
+ Applied applied = applyHeaderValue(new String[] { "a", "b" });
+
+ assertThat(applied.values).containsExactly("a", "b");
+ }
+
+ @Test
+ void applyHeaderValueGivesTheOnlyValueOfACollectionAsSingleValue() {
+ Applied applied = applyHeaderValue(List.of("a"));
+
+ assertThat(applied.values).isNull();
+ assertThat(applied.single).isEqualTo("a");
+ }
+
+ @Test
+ void applyHeaderValueSkipsNullAndFilteredElements() {
+ Applied applied = applyHeaderValue(Arrays.asList("a", null, FILTERED,
"", "b"));
+
+ // an empty element is a value
+ assertThat(applied.values).containsExactly("a", "", "b");
+ }
+
+ @Test
+ void applyHeaderValueGivesNoValueForACollectionWithoutValues() {
+ for (Object value : new Object[] { List.of(), new Object[0],
Arrays.asList(null, null), List.of(FILTERED) }) {
+ Applied applied = applyHeaderValue(value);
+
+ assertThat(applied.calls).isEqualTo(1);
+ assertThat(applied.values).isNull();
+ assertThat(applied.single).isNull();
+ }
+ }
+
+ @Test
+ void applyHeaderValueDoesNotSplitAString() {
+ Applied applied = applyHeaderValue("a, b");
+
+ assertThat(applied.values).isNull();
+ assertThat(applied.single).isEqualTo("a, b");
+
+ assertThat(applyHeaderValue("").single).isEmpty();
+ }
+
+ @Test
+ void applyHeaderValueGivesAPrimitiveArrayAsSingleValue() {
+ byte[] bytes = "abc".getBytes(StandardCharsets.UTF_8);
+ Applied applied = applyHeaderValue(bytes);
+
+ assertThat(applied.values).isNull();
+ assertThat(applied.single).isEqualTo("abc");
+ // a single value is filtered as it is
+ assertThat(applied.filtered).containsExactly(bytes);
+ }
+
+ @Test
+ void applyHeaderValueGivesAMapAsSingleValue() {
+ Map<String, Object> map = new LinkedHashMap<>();
+ map.put("a", 1);
+ map.put("b", 2);
+ Applied applied = applyHeaderValue(map);
+
+ assertThat(applied.values).isNull();
+ assertThat(applied.single).isEqualTo("{a=1, b=2}");
+ }
+
+ @Test
+ void applyHeaderValueGivesAnIterableThatIsNotACollectionAsSingleValue() {
+ // such as a Vert.x JsonObject or JsonArray, which iterate over their
content
+ Iterable<String> json = new Iterable<>() {
+ @Override
+ public Iterator<String> iterator() {
+ return List.of("a", "b").iterator();
+ }
+
+ @Override
+ public String toString() {
+ return "[\"a\",\"b\"]";
+ }
+ };
+ Applied applied = applyHeaderValue(json);
+
+ assertThat(applied.values).isNull();
+ assertThat(applied.single).isEqualTo("[\"a\",\"b\"]");
+ assertThat(applied.filtered).containsExactly(json);
+ }
+
+ @Test
+ void applyHeaderValueGivesNoValueForAFilteredOrNullValue() {
+ Applied filtered = applyHeaderValue(FILTERED);
+ assertThat(filtered.calls).isEqualTo(1);
+ assertThat(filtered.values).isNull();
+ assertThat(filtered.single).isNull();
+
+ Applied none = applyHeaderValue(null);
+ assertThat(none.calls).isEqualTo(1);
+ assertThat(none.values).isNull();
+ assertThat(none.single).isNull();
+ assertThat(none.filtered).isEmpty();
+ }
+
+ private static Applied applyHeaderValue(Object value) {
+ Applied applied = new Applied();
+ HeaderFilterStrategy strategy = new HeaderFilterStrategy() {
+ @Override
+ public boolean applyFilterToCamelHeaders(String headerName, Object
headerValue, Exchange exchange) {
+ assertThat(headerName).isEqualTo("X-Test");
+ applied.filtered.add(headerValue);
+ return FILTERED.equals(headerValue);
+ }
+
+ @Override
+ public boolean applyFilterToExternalHeaders(String headerName,
Object headerValue, Exchange exchange) {
+ throw new UnsupportedOperationException("the headers are
copied from Camel to the HTTP message");
+ }
+ };
+ HttpUtil.applyHeaderValue(strategy, null, value, CONVERTER, "X-Test",
(values, single) -> {
+ applied.calls++;
+ applied.values = values;
+ applied.single = single;
+ });
+ return applied;
+ }
+
+ private static final class Applied {
+ private final List<Object> filtered = new ArrayList<>();
+ private int calls;
+ private List<String> values;
+ private String single;
+ }
}
diff --git
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index 75b4abd55eff..afe742bb5405 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -96,3 +96,14 @@ the module is the path of the endpoint URI, available as
`WasmEndpoint.getModule
module.
* `WasmSupport.deserialize(byte[], Exchange)`.
* The constructor `WasmProducer(Endpoint, String, String)` is replaced by
`WasmProducer(WasmEndpoint)`.
+
+=== camel-vertx-http - a header with several values is sent as several headers
+
+A message header whose value is a `java.util.Collection` or an object array
(as the HTTP consumers map a request
+header that occurs more than once) was sent as one HTTP header with the
`toString()` of the value, for example
+`[a, b]`. The producer now sends one HTTP header per value. A collection or an
object array that is empty, or that
+holds only null values, now sends no header (an empty List was sent as the
header value `[]`).
+
+Every other value is converted to one String and sent as one header as before:
a String (also when it contains
+commas), a `byte[]` and the other primitive arrays, a `Map`, and a Vert.x
`JsonObject` or `JsonArray` (sent as JSON).
+A route that needs the old single header can set the header to a String, for
example with `String.valueOf(list)`.