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)`.

Reply via email to