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