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 062a825031cf CAMEL-25168: camel-openapi-validator - pass multi-valued 
headers to the validator (#27099)
062a825031cf is described below

commit 062a825031cfe587a95569964f4be24f8dbe8baa
Author: Thomas Raddatz <[email protected]>
AuthorDate: Wed Sep 30 18:55:04 2026 +0200

    CAMEL-25168: camel-openapi-validator - pass multi-valued headers to the 
validator (#27099)
    
    A header sent more than once arrives on the message as a Collection, and 
converting it to String handed the validator a fabricated value like "[key-one, 
key-two]". Repeated scalar parameters were therefore never reported and 
array-typed headers were validated against the wrong value.
    
    Each value of a multi-valued header is now passed to the swagger request 
validator individually.
    
    Closes #27099
    
    Co-authored-by: Claude <[email protected]>
---
 .../client/OpenApiRestClientRequestValidator.java  |  82 ++++++++++-
 .../OpenApiRestClientRequestValidatorTest.java     | 162 +++++++++++++++++++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_23.adoc    |  14 ++
 3 files changed, 257 insertions(+), 1 deletion(-)

diff --git 
a/components/camel-openapi-validator/src/main/java/org/apache/camel/component/rest/openapi/validator/client/OpenApiRestClientRequestValidator.java
 
b/components/camel-openapi-validator/src/main/java/org/apache/camel/component/rest/openapi/validator/client/OpenApiRestClientRequestValidator.java
index 97824172ceb1..c30de4fdb2fa 100644
--- 
a/components/camel-openapi-validator/src/main/java/org/apache/camel/component/rest/openapi/validator/client/OpenApiRestClientRequestValidator.java
+++ 
b/components/camel-openapi-validator/src/main/java/org/apache/camel/component/rest/openapi/validator/client/OpenApiRestClientRequestValidator.java
@@ -18,17 +18,26 @@ package 
org.apache.camel.component.rest.openapi.validator.client;
 
 import java.net.URLDecoder;
 import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collection;
 import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
 import java.util.Map;
 import java.util.Objects;
 
 import com.atlassian.oai.validator.OpenApiInteractionValidator;
+import com.atlassian.oai.validator.interaction.ApiOperationResolver;
+import com.atlassian.oai.validator.model.ApiOperationMatch;
+import com.atlassian.oai.validator.model.Request;
 import com.atlassian.oai.validator.model.SimpleRequest;
 import com.atlassian.oai.validator.report.JsonValidationReportFormat;
 import com.atlassian.oai.validator.report.LevelResolver;
 import com.atlassian.oai.validator.report.SimpleValidationReportFormat;
 import com.atlassian.oai.validator.report.ValidationReport;
 import io.swagger.v3.oas.models.OpenAPI;
+import io.swagger.v3.oas.models.media.ArraySchema;
+import io.swagger.v3.oas.models.parameters.Parameter;
 import org.apache.camel.Exchange;
 import org.apache.camel.component.rest.openapi.RestOpenApiComponent;
 import org.apache.camel.component.rest.openapi.RestOpenApiHelper;
@@ -51,6 +60,7 @@ public class OpenApiRestClientRequestValidator implements 
RestClientRequestValid
     private volatile OpenAPI cachedOpenAPI;
     private volatile Map<String, String> cachedLevels;
     private volatile OpenApiInteractionValidator cachedValidator;
+    private volatile CachedResolver cachedResolver;
 
     public OpenApiRestClientRequestValidator() {
         // add extra additional HTTP request headers to skip
@@ -104,7 +114,30 @@ public class OpenApiRestClientRequestValidator implements 
RestClientRequestValid
             boolean customHeader
                     = !startsWithIgnoreCase(key, "Camel") && 
!filter.applyFilterToCamelHeaders(key, value, exchange);
             if (customHeader) {
-                builder.withHeader(key, exchange.getMessage().getHeader(key, 
String.class));
+                if (value instanceof Collection<?> values) {
+                    // A header sent more than once arrives as a Collection 
(CollectionHelper.appendEntry).
+                    // Converting that to a single String would hand the 
validator the collection's
+                    // toString(), such as "[a, b]" - a value the client never 
sent - so the schema would
+                    // be checked against fabricated data, and a repeated 
scalar parameter would never be
+                    // reported. Pass the values on instead, as the query 
parameters below already do.
+                    List<String> headerValues = new ArrayList<>(values.size());
+                    for (Object headerValue : values) {
+                        String text = exchange.getContext().getTypeConverter()
+                                .convertTo(String.class, exchange, 
headerValue);
+                        if (text != null) {
+                            headerValues.add(text);
+                        }
+                    }
+                    if (headerValues.size() > 1 && isArrayHeader(openAPI, 
method, path, key)) {
+                        // RFC 9110 section 5.3: repeating a list-based field 
is equivalent to one field
+                        // with the values joined by commas, which is the form 
the validator expects
+                        builder.withHeader(key, String.join(",", 
headerValues));
+                    } else {
+                        builder.withHeader(key, headerValues);
+                    }
+                } else {
+                    builder.withHeader(key, 
exchange.getMessage().getHeader(key, String.class));
+                }
             }
         }
         // Use query parameters, if present
@@ -171,6 +204,53 @@ public class OpenApiRestClientRequestValidator implements 
RestClientRequestValid
         return v;
     }
 
+    /**
+     * Whether the operation the request resolves to declares the given header 
as an array. The operation is resolved
+     * the same way the validator resolves it, so this sees the parameters the 
validator checks.
+     */
+    private boolean isArrayHeader(OpenAPI openAPI, String method, String path, 
String headerName) {
+        if (method == null) {
+            return false;
+        }
+        ApiOperationMatch match;
+        try {
+            match = getOrCreateResolver(openAPI).findApiOperation(path,
+                    Request.Method.valueOf(method.toUpperCase(Locale.ROOT)));
+        } catch (IllegalArgumentException e) {
+            // unknown HTTP method, which the validator reports on its own
+            return false;
+        }
+        if (!match.isPathFound() || !match.isOperationAllowed()) {
+            return false;
+        }
+        List<Parameter> parameters = 
match.getApiOperation().getOperation().getParameters();
+        if (parameters == null) {
+            return false;
+        }
+        for (Parameter parameter : parameters) {
+            if ("header".equals(parameter.getIn()) && 
headerName.equalsIgnoreCase(parameter.getName())) {
+                // same check as the validator's ParameterValidator. An 
OpenAPI 3.1 contract is parsed into a
+                // JsonSchema instead, which the validator does not treat as 
an array either, so array headers
+                // of a 3.1 contract are not recognised here
+                return parameter.getSchema() instanceof ArraySchema;
+            }
+        }
+        return false;
+    }
+
+    private ApiOperationResolver getOrCreateResolver(OpenAPI openAPI) {
+        CachedResolver cached = cachedResolver;
+        if (cached != null && cached.openAPI() == openAPI) {
+            return cached.resolver();
+        }
+        ApiOperationResolver resolver = new ApiOperationResolver(openAPI, 
null, false);
+        cachedResolver = new CachedResolver(openAPI, resolver);
+        return resolver;
+    }
+
+    private record CachedResolver(OpenAPI openAPI, ApiOperationResolver 
resolver) {
+    }
+
     private static String urlDecode(String s) {
         try {
             return URLDecoder.decode(s, StandardCharsets.UTF_8);
diff --git 
a/components/camel-openapi-validator/src/test/java/org/apache/camel/component/rest/openapi/validator/client/OpenApiRestClientRequestValidatorTest.java
 
b/components/camel-openapi-validator/src/test/java/org/apache/camel/component/rest/openapi/validator/client/OpenApiRestClientRequestValidatorTest.java
index 4dcb6be0a90a..e9ebd6b3ec82 100644
--- 
a/components/camel-openapi-validator/src/test/java/org/apache/camel/component/rest/openapi/validator/client/OpenApiRestClientRequestValidatorTest.java
+++ 
b/components/camel-openapi-validator/src/test/java/org/apache/camel/component/rest/openapi/validator/client/OpenApiRestClientRequestValidatorTest.java
@@ -17,6 +17,8 @@
 package org.apache.camel.component.rest.openapi.validator.client;
 
 import java.io.IOException;
+import java.util.Arrays;
+import java.util.List;
 import java.util.Map;
 
 import io.swagger.v3.oas.models.OpenAPI;
@@ -33,6 +35,7 @@ import org.junit.jupiter.api.Test;
 public class OpenApiRestClientRequestValidatorTest extends ExchangeTestSupport 
{
 
     static OpenAPI openAPI;
+    static OpenAPI headerArrayOpenAPI;
     static OpenApiRestClientRequestValidator validator;
 
     @BeforeAll
@@ -41,6 +44,26 @@ public class OpenApiRestClientRequestValidatorTest extends 
ExchangeTestSupport {
         OpenAPIV3Parser parser = new OpenAPIV3Parser();
         SwaggerParseResult out = parser.readContents(data);
         openAPI = out.getOpenAPI();
+        headerArrayOpenAPI = parser.readContents("""
+                openapi: 3.0.3
+                info:
+                  title: header array
+                  version: 1.0.0
+                paths:
+                  /items:
+                    get:
+                      parameters:
+                        - name: X-Ids
+                          in: header
+                          required: true
+                          schema:
+                            type: array
+                            items:
+                              type: integer
+                      responses:
+                        '200':
+                          description: OK
+                """).getOpenAPI();
         validator = new OpenApiRestClientRequestValidator();
     }
 
@@ -123,4 +146,143 @@ public class OpenApiRestClientRequestValidatorTest 
extends ExchangeTestSupport {
                 "application/json", "application/json", true, null, null, 
null, null));
         Assertions.assertNull(error);
     }
+
+    @Test
+    public void testValidateRepeatedScalarHeader() {
+        exchange.setProperty(Exchange.REST_OPENAPI, openAPI);
+        exchange.setProperty(Exchange.CONTENT_TYPE, "application/json");
+        exchange.getMessage().setHeader(Exchange.HTTP_METHOD, "DELETE");
+        exchange.getMessage().setHeader(Exchange.HTTP_PATH, "pet/123");
+        exchange.getMessage().setHeader("Accept", "application/json");
+        exchange.getMessage().setBody("");
+
+        // A header sent more than once arrives as a List, exactly as 
CollectionHelper.appendEntry
+        // leaves it. api_key is declared "type": "string", so two values 
violate the contract.
+        exchange.getMessage().setHeader("api_key", List.of("key-one", 
"key-two"));
+
+        RestClientRequestValidator.ValidationError error
+                = validator.validate(exchange, new 
RestClientRequestValidator.ValidationContext(
+                        "application/json", "application/json", false, null, 
null, null, null));
+
+        Assertions.assertNotNull(error, "a repeated scalar header parameter 
must be reported");
+        Assertions.assertEquals(400, error.statusCode());
+        Assertions.assertFalse(error.body().contains("[key-one, key-two]"),
+                "the collection must not be stringified into the validated 
value");
+    }
+
+    @Test
+    public void testValidateSingleScalarHeaderStillPasses() {
+        exchange.setProperty(Exchange.REST_OPENAPI, openAPI);
+        exchange.setProperty(Exchange.CONTENT_TYPE, "application/json");
+        exchange.getMessage().setHeader(Exchange.HTTP_METHOD, "DELETE");
+        exchange.getMessage().setHeader(Exchange.HTTP_PATH, "pet/123");
+        exchange.getMessage().setHeader("Accept", "application/json");
+        exchange.getMessage().setHeader("api_key", "key-one");
+        exchange.getMessage().setBody("");
+
+        RestClientRequestValidator.ValidationError error
+                = validator.validate(exchange, new 
RestClientRequestValidator.ValidationContext(
+                        "application/json", "application/json", false, null, 
null, null, null));
+
+        Assertions.assertNull(error);
+    }
+
+    @Test
+    public void testValidateRepeatedArrayHeaderIsAccepted() {
+        exchange.setProperty(Exchange.REST_OPENAPI, openAPI);
+        exchange.setProperty(Exchange.CONTENT_TYPE, "application/json");
+        exchange.getMessage().setHeader(Exchange.HTTP_METHOD, "GET");
+        exchange.getMessage().setHeader(Exchange.HTTP_PATH, "pet/findByTags");
+        exchange.getMessage().setHeader("Accept", "application/json");
+        exchange.getMessage().setBody("");
+
+        // tags is "type": "array". Per RFC 9110 section 5.3 repeating a 
list-based header is
+        // equivalent to one header with the values joined by commas, so tags: 
dog + tags: cat
+        // means the same as tags: dog,cat
+        exchange.getMessage().setHeader("tags", List.of("dog", "cat"));
+
+        RestClientRequestValidator.ValidationError error
+                = validator.validate(exchange, new 
RestClientRequestValidator.ValidationContext(
+                        "application/json", "application/json", true, null, 
null, null, null));
+
+        Assertions.assertNull(error);
+    }
+
+    @Test
+    public void testValidateRepeatedArrayHeaderWithInvalidItemIsReported() {
+        exchange.setProperty(Exchange.REST_OPENAPI, headerArrayOpenAPI);
+        exchange.getMessage().setHeader(Exchange.HTTP_METHOD, "GET");
+        exchange.getMessage().setHeader(Exchange.HTTP_PATH, "items");
+        exchange.getMessage().setBody("");
+
+        // the values are joined, not waved through: each one is still checked 
against the items schema
+        exchange.getMessage().setHeader("X-Ids", List.of("1", "abc"));
+
+        RestClientRequestValidator.ValidationError error
+                = validator.validate(exchange, new 
RestClientRequestValidator.ValidationContext(
+                        "application/json", "application/json", false, null, 
null, null, null));
+
+        Assertions.assertNotNull(error);
+        Assertions.assertTrue(error.body().contains("@header.X-Ids"), 
error.body());
+
+        exchange.getMessage().setHeader("X-Ids", List.of("1", "2"));
+        error = validator.validate(exchange, new 
RestClientRequestValidator.ValidationContext(
+                "application/json", "application/json", false, null, null, 
null, null));
+
+        Assertions.assertNull(error);
+    }
+
+    @Test
+    public void testValidateRepeatedArrayHeaderWithNullValueIsSkipped() {
+        exchange.setProperty(Exchange.REST_OPENAPI, headerArrayOpenAPI);
+        exchange.getMessage().setHeader(Exchange.HTTP_METHOD, "GET");
+        exchange.getMessage().setHeader(Exchange.HTTP_PATH, "items");
+        exchange.getMessage().setBody("");
+
+        // a null element carries no value and must not be joined in as the 
text "null"
+        exchange.getMessage().setHeader("X-Ids", Arrays.asList("1", null, 
"2"));
+
+        RestClientRequestValidator.ValidationError error
+                = validator.validate(exchange, new 
RestClientRequestValidator.ValidationContext(
+                        "application/json", "application/json", false, null, 
null, null, null));
+
+        Assertions.assertNull(error);
+    }
+
+    @Test
+    public void testValidateArrayHeaderInSimpleStyleStillPasses() {
+        exchange.setProperty(Exchange.REST_OPENAPI, openAPI);
+        exchange.setProperty(Exchange.CONTENT_TYPE, "application/json");
+        exchange.getMessage().setHeader(Exchange.HTTP_METHOD, "GET");
+        exchange.getMessage().setHeader(Exchange.HTTP_PATH, "pet/findByTags");
+        exchange.getMessage().setHeader("Accept", "application/json");
+        exchange.getMessage().setHeader("tags", "dog,cat");
+        exchange.getMessage().setBody("");
+
+        RestClientRequestValidator.ValidationError error
+                = validator.validate(exchange, new 
RestClientRequestValidator.ValidationContext(
+                        "application/json", "application/json", true, null, 
null, null, null));
+
+        Assertions.assertNull(error, "the form the contract does describe must 
stay valid");
+    }
+
+    @Test
+    public void testValidateRepeatedScalarHeaderInMixedCase() {
+        exchange.setProperty(Exchange.REST_OPENAPI, openAPI);
+        exchange.setProperty(Exchange.CONTENT_TYPE, "application/json");
+        exchange.getMessage().setHeader(Exchange.HTTP_METHOD, "DELETE");
+        exchange.getMessage().setHeader(Exchange.HTTP_PATH, "pet/123");
+        exchange.getMessage().setHeader("Accept", "application/json");
+        exchange.getMessage().setBody("");
+
+        // HTTP header names are case-insensitive, and so is Camel's header 
map: a client repeating
+        // the header under a different spelling still produces one entry 
holding both values.
+        exchange.getMessage().setHeader("Api_Key", List.of("key-one", 
"key-two"));
+
+        RestClientRequestValidator.ValidationError error
+                = validator.validate(exchange, new 
RestClientRequestValidator.ValidationContext(
+                        "application/json", "application/json", false, null, 
null, null, null));
+
+        Assertions.assertNotNull(error, "the spelling on the wire must not 
decide whether it is checked");
+    }
 }
diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
index 7cb38d69733e..612cc228ccd9 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
@@ -3988,6 +3988,20 @@ JAXP external-access model and remain resolvable. 
Stylesheet `xsl:include` / `xs
 are resolved at compile time (they are part of the route definition, authored 
by the route author)
 and are unaffected by this change.
 
+=== camel-openapi-validator - repeated request headers are validated value by 
value
+
+A header that occurs more than once in a request used to be passed to the 
request validator as the
+text of the Java collection holding its values, such as `[a, b]`. The values 
are now passed as they
+were sent. As a result:
+
+* A header that the contract declares as a single value (not an array) and 
that occurs more than
+  once is now rejected with `400`, where it was accepted before.
+* A header that the contract declares as an array may be repeated. This is 
treated like one header
+  with the values joined by commas (RFC 9110, section 5.3), and each value is 
checked against the
+  item schema of the array. This applies to OpenAPI 3.0 contracts: for an 
OpenAPI 3.1 contract the
+  validator does not recognise a header parameter as an array, so a repeated 
header is rejected
+  like a single-value one.
+
 == ThrottlingExceptionRoutePolicy
 
 `ThrottlingExceptionRoutePolicy.setKeepOpen(true)` now opens the circuit 
immediately and synchronously (the consumer

Reply via email to