This is an automated email from the ASF dual-hosted git repository.
Croway 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 b3f7783ee543 Fix camel-openapi-validator: caching, query parsing, null
path, and docs
b3f7783ee543 is described below
commit b3f7783ee5435e570afcf44d12a8961e3d287533
Author: Croway <[email protected]>
AuthorDate: Wed Apr 29 13:27:09 2026 +0200
Fix camel-openapi-validator: caching, query parsing, null path, and docs
- Cache OpenApiInteractionValidator to avoid rebuilding on every call
- URL-decode query param keys/values and fix split to handle '=' in values
- Default null path to "/" in request validator (consistent with response)
- Respect requiredBody from ValidationContext via LevelResolver override
- Decouple response validation error format from Accept header
- Add integration test with real HTTP routes via platform-http-vertx
- Update documentation with Maven dependency and Java DSL examples
---
components/camel-openapi-validator/pom.xml | 11 ++
.../src/main/docs/openapi-validator.adoc | 48 ++++-
.../client/OpenApiRestClientRequestValidator.java | 76 +++++--
.../client/OpenApiRestClientResponseValidator.java | 59 +++---
.../OpenApiRestClientResponseValidatorTest.java | 2 +-
.../client/OpenApiValidatorIntegrationTest.java | 220 +++++++++++++++++++++
6 files changed, 372 insertions(+), 44 deletions(-)
diff --git a/components/camel-openapi-validator/pom.xml
b/components/camel-openapi-validator/pom.xml
index ec5e458cdc04..e4a214353caa 100644
--- a/components/camel-openapi-validator/pom.xml
+++ b/components/camel-openapi-validator/pom.xml
@@ -64,6 +64,17 @@
<artifactId>camel-mock</artifactId>
<scope>test</scope>
</dependency>
+ <dependency>
+ <groupId>org.apache.camel</groupId>
+ <artifactId>camel-platform-http-vertx</artifactId>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>io.rest-assured</groupId>
+ <artifactId>rest-assured</artifactId>
+ <version>${rest-assured-version}</version>
+ <scope>test</scope>
+ </dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
diff --git
a/components/camel-openapi-validator/src/main/docs/openapi-validator.adoc
b/components/camel-openapi-validator/src/main/docs/openapi-validator.adoc
index 7fa06027a79a..8db451fc2dcc 100644
--- a/components/camel-openapi-validator/src/main/docs/openapi-validator.adoc
+++ b/components/camel-openapi-validator/src/main/docs/openapi-validator.adoc
@@ -17,7 +17,43 @@ the default validator from `camel-core`.
== Auto-detection from classpath
-To use this implementation all you need to do is to add the
`camel-openapi-validator` dependency to the classpath.
+To use this implementation all you need to do is to add the
`camel-openapi-validator` dependency to the classpath:
+
+[source,xml]
+----
+<dependency>
+ <groupId>org.apache.camel</groupId>
+ <artifactId>camel-openapi-validator</artifactId>
+</dependency>
+----
+
+When this dependency is present, the OpenAPI validator is automatically
discovered and used instead of the default validator.
+No additional configuration is required.
+
+== Usage with REST DSL
+
+To enable request validation on incoming HTTP requests, use
`clientRequestValidation(true)` in the REST DSL
+and load the OpenAPI specification with `openApi()`:
+
+[source,java]
+----
+rest().clientRequestValidation(true)
+ .openApi()
+ .specification("petstore-v3.json")
+ .missingOperation("ignore");
+
+from("direct:updatePet")
+ .to("bean:petService?method=update");
+
+from("direct:findPetsByStatus")
+ .to("bean:petService?method=findByStatus");
+----
+
+With this setup, incoming requests are validated against the OpenAPI
specification before reaching the route handler.
+If validation fails, a `400 Bad Request` response is returned automatically
with a message describing the validation error.
+
+The validator checks request body schema (required fields, types, formats),
required query parameters,
+required headers, and content types — all based on what is defined in the
OpenAPI specification.
=== Configuring levels of errors
@@ -32,3 +68,13 @@ camel.rest.validation-levels[validation.schema.required] =
INFO
camel.rest.validation-levels[validation.request.parameter.query.missing] =
IGNORE
camel.rest.validation-levels[validation.response.body.missing] = WARN
----
+
+Or configure them in Java DSL:
+
+[source,java]
+----
+restConfiguration()
+ .clientRequestValidation(true)
+ .validationLevelProperty("validation.request.body.schema.required", "INFO")
+ .validationLevelProperty("validation.request.parameter.query.missing",
"IGNORE");
+----
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 a2c5f1c4380d..97824172ceb1 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
@@ -16,6 +16,12 @@
*/
package org.apache.camel.component.rest.openapi.validator.client;
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+
import com.atlassian.oai.validator.OpenApiInteractionValidator;
import com.atlassian.oai.validator.model.SimpleRequest;
import com.atlassian.oai.validator.report.JsonValidationReportFormat;
@@ -42,6 +48,10 @@ public class OpenApiRestClientRequestValidator implements
RestClientRequestValid
private final HttpHeaderFilterStrategy filter = new
HttpHeaderFilterStrategy();
+ private volatile OpenAPI cachedOpenAPI;
+ private volatile Map<String, String> cachedLevels;
+ private volatile OpenApiInteractionValidator cachedValidator;
+
public OpenApiRestClientRequestValidator() {
// add extra additional HTTP request headers to skip
filter.getOutFilter().add("accept");
@@ -69,6 +79,9 @@ public class OpenApiRestClientRequestValidator implements
RestClientRequestValid
if (path != null && path.startsWith(basePath)) {
path = path.substring(basePath.length());
}
+ if (path == null) {
+ path = "/";
+ }
String accept = exchange.getMessage().getHeader("Accept",
String.class);
String contentType = ExchangeHelper.getContentType(exchange);
@@ -99,34 +112,26 @@ public class OpenApiRestClientRequestValidator implements
RestClientRequestValid
if (query != null) {
String[] params = query.split("&");
for (String param : params) {
- String[] keyValue = param.split("=");
- if (keyValue.length == 2) {
- builder.withQueryParam(keyValue[0], keyValue[1]);
- } else if (keyValue.length == 1) {
- builder.withQueryParam(keyValue[0], "");
- }
+ String[] keyValue = param.split("=", 2);
+ String qKey = urlDecode(keyValue[0]);
+ String qValue = keyValue.length == 2 ? urlDecode(keyValue[1])
: "";
+ builder.withQueryParam(qKey, qValue);
}
}
- LevelResolver.Builder lr = LevelResolver.create();
+ Map<String, String> effectiveLevels = new HashMap<>();
RestConfiguration rc = exchange.getContext().getRestConfiguration();
if (rc.getValidationLevels() != null) {
- for (var e : rc.getValidationLevels().entrySet()) {
- String key = e.getKey();
- var level = ValidationReport.Level.valueOf(e.getValue());
- if ("defaultLevel".equalsIgnoreCase(key)) {
- lr.withDefaultLevel(level);
- } else {
- lr.withLevel(key, level);
- }
- }
+ effectiveLevels.putAll(rc.getValidationLevels());
}
- OpenApiInteractionValidator validator =
OpenApiInteractionValidator.createFor(openAPI)
- .withLevelResolver(lr.build())
- .build();
+ if (!validationContent.requiredBody()) {
+ effectiveLevels.put("validation.request.body.missing", "IGNORE");
+ }
+
+ OpenApiInteractionValidator validator = getOrCreateValidator(openAPI,
effectiveLevels);
ValidationReport report = validator.validateRequest(builder.build());
- // create report if error of DEBUG logging
+ // create report if error or DEBUG logging
if (report.hasErrors() || LOG.isDebugEnabled()) {
String msg;
if (accept != null && accept.contains("application/json")) {
@@ -143,6 +148,37 @@ public class OpenApiRestClientRequestValidator implements
RestClientRequestValid
return null;
}
+ private OpenApiInteractionValidator getOrCreateValidator(OpenAPI openAPI,
Map<String, String> levels) {
+ if (cachedValidator != null && cachedOpenAPI == openAPI &&
Objects.equals(cachedLevels, levels)) {
+ return cachedValidator;
+ }
+ LevelResolver.Builder lr = LevelResolver.create();
+ for (var e : levels.entrySet()) {
+ String key = e.getKey();
+ var level = ValidationReport.Level.valueOf(e.getValue());
+ if ("defaultLevel".equalsIgnoreCase(key)) {
+ lr.withDefaultLevel(level);
+ } else {
+ lr.withLevel(key, level);
+ }
+ }
+ OpenApiInteractionValidator v =
OpenApiInteractionValidator.createFor(openAPI)
+ .withLevelResolver(lr.build())
+ .build();
+ cachedOpenAPI = openAPI;
+ cachedLevels = new HashMap<>(levels);
+ cachedValidator = v;
+ return v;
+ }
+
+ private static String urlDecode(String s) {
+ try {
+ return URLDecoder.decode(s, StandardCharsets.UTF_8);
+ } catch (IllegalArgumentException e) {
+ return s;
+ }
+ }
+
private static boolean startsWithIgnoreCase(String s, String prefix) {
return s.regionMatches(true, 0, prefix, 0, prefix.length());
}
diff --git
a/components/camel-openapi-validator/src/main/java/org/apache/camel/component/rest/openapi/validator/client/OpenApiRestClientResponseValidator.java
b/components/camel-openapi-validator/src/main/java/org/apache/camel/component/rest/openapi/validator/client/OpenApiRestClientResponseValidator.java
index 36cd558dda6d..56f7dcd5e135 100644
---
a/components/camel-openapi-validator/src/main/java/org/apache/camel/component/rest/openapi/validator/client/OpenApiRestClientResponseValidator.java
+++
b/components/camel-openapi-validator/src/main/java/org/apache/camel/component/rest/openapi/validator/client/OpenApiRestClientResponseValidator.java
@@ -16,10 +16,13 @@
*/
package org.apache.camel.component.rest.openapi.validator.client;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+
import com.atlassian.oai.validator.OpenApiInteractionValidator;
import com.atlassian.oai.validator.model.Request;
import com.atlassian.oai.validator.model.SimpleResponse;
-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;
@@ -43,6 +46,10 @@ public class OpenApiRestClientResponseValidator implements
RestClientResponseVal
private final HttpHeaderFilterStrategy filter = new
HttpHeaderFilterStrategy();
+ private volatile OpenAPI cachedOpenAPI;
+ private volatile Map<String, String> cachedLevels;
+ private volatile OpenApiInteractionValidator cachedValidator;
+
public OpenApiRestClientResponseValidator() {
// add extra additional HTTP request headers to skip
filter.getOutFilter().add("accept");
@@ -76,7 +83,6 @@ public class OpenApiRestClientResponseValidator implements
RestClientResponseVal
path = "/";
}
- String accept = exchange.getMessage().getHeader("Accept",
String.class);
String contentType = ExchangeHelper.getContentType(exchange);
String body = MessageHelper.extractBodyAsString(exchange.getIn());
int status =
exchange.getMessage().getHeader(Exchange.HTTP_RESPONSE_CODE, 200, int.class);
@@ -99,32 +105,18 @@ public class OpenApiRestClientResponseValidator implements
RestClientResponseVal
}
}
- LevelResolver.Builder lr = LevelResolver.create();
+ Map<String, String> effectiveLevels = new HashMap<>();
RestConfiguration rc = exchange.getContext().getRestConfiguration();
if (rc.getValidationLevels() != null) {
- for (var e : rc.getValidationLevels().entrySet()) {
- String key = e.getKey();
- var level = ValidationReport.Level.valueOf(e.getValue());
- if ("defaultLevel".equalsIgnoreCase(key)) {
- lr.withDefaultLevel(level);
- } else {
- lr.withLevel(key, level);
- }
- }
+ effectiveLevels.putAll(rc.getValidationLevels());
}
- OpenApiInteractionValidator validator =
OpenApiInteractionValidator.createFor(openAPI)
- .withLevelResolver(lr.build())
- .build();
+
+ OpenApiInteractionValidator validator = getOrCreateValidator(openAPI,
effectiveLevels);
ValidationReport report = validator.validateResponse(path,
asMethod(method), builder.build());
- // create report if error of DEBUG logging
+ // create report if error or DEBUG logging
if (report.hasErrors() || LOG.isDebugEnabled()) {
- String msg;
- if (accept != null && accept.contains("application/json")) {
- msg = JsonValidationReportFormat.getInstance().apply(report);
- } else {
- msg = SimpleValidationReportFormat.getInstance().apply(report);
- }
+ String msg =
SimpleValidationReportFormat.getInstance().apply(report);
LOG.debug("Client Response Validation: {}", msg);
if (report.hasErrors()) {
return new ValidationError(500, msg);
@@ -134,6 +126,29 @@ public class OpenApiRestClientResponseValidator implements
RestClientResponseVal
return null;
}
+ private OpenApiInteractionValidator getOrCreateValidator(OpenAPI openAPI,
Map<String, String> levels) {
+ if (cachedValidator != null && cachedOpenAPI == openAPI &&
Objects.equals(cachedLevels, levels)) {
+ return cachedValidator;
+ }
+ LevelResolver.Builder lr = LevelResolver.create();
+ for (var e : levels.entrySet()) {
+ String key = e.getKey();
+ var level = ValidationReport.Level.valueOf(e.getValue());
+ if ("defaultLevel".equalsIgnoreCase(key)) {
+ lr.withDefaultLevel(level);
+ } else {
+ lr.withLevel(key, level);
+ }
+ }
+ OpenApiInteractionValidator v =
OpenApiInteractionValidator.createFor(openAPI)
+ .withLevelResolver(lr.build())
+ .build();
+ cachedOpenAPI = openAPI;
+ cachedLevels = new HashMap<>(levels);
+ cachedValidator = v;
+ return v;
+ }
+
private static boolean startsWithIgnoreCase(String s, String prefix) {
return s.regionMatches(true, 0, prefix, 0, prefix.length());
}
diff --git
a/components/camel-openapi-validator/src/test/java/org/apache/camel/component/rest/openapi/validator/client/OpenApiRestClientResponseValidatorTest.java
b/components/camel-openapi-validator/src/test/java/org/apache/camel/component/rest/openapi/validator/client/OpenApiRestClientResponseValidatorTest.java
index 8eb51f709e78..543b6322b7f2 100644
---
a/components/camel-openapi-validator/src/test/java/org/apache/camel/component/rest/openapi/validator/client/OpenApiRestClientResponseValidatorTest.java
+++
b/components/camel-openapi-validator/src/test/java/org/apache/camel/component/rest/openapi/validator/client/OpenApiRestClientResponseValidatorTest.java
@@ -63,7 +63,7 @@ public class OpenApiRestClientResponseValidatorTest extends
ExchangeTestSupport
exchange.getMessage().setBody("{ \"name\": \"tiger\" }");
error = validator.validate(exchange, new
RestClientResponseValidator.ValidationContext(
"application/json", "application/json", null, null));
- Assertions.assertTrue(error.body().contains("Object has missing
required properties ([\\\"photoUrls\\\"])"));
+ Assertions.assertTrue(error.body().contains("Object has missing
required properties ([\"photoUrls\"])"));
exchange.getMessage().setBody("{ \"name\": \"tiger\", \"photoUrls\":
[\"image.jpg\"] }");
error = validator.validate(exchange, new
RestClientResponseValidator.ValidationContext(
diff --git
a/components/camel-openapi-validator/src/test/java/org/apache/camel/component/rest/openapi/validator/client/OpenApiValidatorIntegrationTest.java
b/components/camel-openapi-validator/src/test/java/org/apache/camel/component/rest/openapi/validator/client/OpenApiValidatorIntegrationTest.java
new file mode 100644
index 000000000000..4b9de374074d
--- /dev/null
+++
b/components/camel-openapi-validator/src/test/java/org/apache/camel/component/rest/openapi/validator/client/OpenApiValidatorIntegrationTest.java
@@ -0,0 +1,220 @@
+/*
+ * 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.rest.openapi.validator.client;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.platform.http.vertx.VertxPlatformHttpServer;
+import
org.apache.camel.component.platform.http.vertx.VertxPlatformHttpServerConfiguration;
+import org.apache.camel.impl.DefaultCamelContext;
+import org.apache.camel.test.AvailablePortFinder;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+import static io.restassured.RestAssured.given;
+import static org.hamcrest.Matchers.containsString;
+
+/**
+ * Integration test that verifies the OpenAPI validator works end-to-end with
real HTTP requests through Camel REST DSL
+ * routes. The {@code camel-openapi-validator} is auto-discovered from the
classpath and used instead of the default
+ * validator, providing full OpenAPI schema validation via the Atlassian
Swagger Request Validator library.
+ */
+public class OpenApiValidatorIntegrationTest {
+
+ @RegisterExtension
+ AvailablePortFinder.Port port = AvailablePortFinder.find();
+
+ @Test
+ public void testValidRequestSucceeds() throws Exception {
+ CamelContext context = createCamelContext();
+ try {
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+ rest().clientRequestValidation(true)
+
.openApi().specification("petstore-v3.json").missingOperation("ignore");
+
+ from("direct:updatePet")
+ .setBody().constant("{\"name\": \"tiger\",
\"photoUrls\": [\"img.jpg\"]}");
+ }
+ });
+ context.start();
+
+ given().port(port.getPort())
+ .contentType("application/json")
+ .body("{\"name\": \"tiger\", \"photoUrls\":
[\"img.jpg\"]}")
+ .when()
+ .put("/api/v3/pet")
+ .then()
+ .statusCode(200);
+ } finally {
+ context.stop();
+ }
+ }
+
+ @Test
+ public void testMissingRequiredBodyReturns400() throws Exception {
+ CamelContext context = createCamelContext();
+ try {
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+ rest().clientRequestValidation(true)
+
.openApi().specification("petstore-v3.json").missingOperation("ignore");
+
+ from("direct:updatePet")
+ .setBody().constant("should not reach here");
+ }
+ });
+ context.start();
+
+ given().port(port.getPort())
+ .contentType("application/json")
+ .when()
+ .put("/api/v3/pet")
+ .then()
+ .statusCode(400)
+ .body(containsString("A request body is required but none
found"));
+ } finally {
+ context.stop();
+ }
+ }
+
+ @Test
+ public void testInvalidBodySchemaReturns400() throws Exception {
+ CamelContext context = createCamelContext();
+ try {
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+ rest().clientRequestValidation(true)
+
.openApi().specification("petstore-v3.json").missingOperation("ignore");
+
+ from("direct:updatePet")
+ .setBody().constant("should not reach here");
+ }
+ });
+ context.start();
+
+ given().port(port.getPort())
+ .contentType("application/json")
+ .body("{\"name\": \"tiger\"}")
+ .when()
+ .put("/api/v3/pet")
+ .then()
+ .statusCode(400)
+ .body(containsString("photoUrls"));
+ } finally {
+ context.stop();
+ }
+ }
+
+ @Test
+ public void testMissingRequiredQueryParamReturns400() throws Exception {
+ CamelContext context = createCamelContext();
+ try {
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+ rest().clientRequestValidation(true)
+
.openApi().specification("petstore-v3.json").missingOperation("ignore");
+
+ from("direct:findPetsByStatus")
+ .setBody().constant("[]");
+ }
+ });
+ context.start();
+
+ given().port(port.getPort())
+ .when()
+ .get("/api/v3/pet/findByStatus")
+ .then()
+ .statusCode(400)
+ .body(containsString("status"));
+ } finally {
+ context.stop();
+ }
+ }
+
+ @Test
+ public void testMissingRequiredHeaderReturns400() throws Exception {
+ CamelContext context = createCamelContext();
+ try {
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+ rest().clientRequestValidation(true)
+
.openApi().specification("petstore-v3.json").missingOperation("ignore");
+
+ from("direct:findPetsByTags")
+ .setBody().constant("[]");
+ }
+ });
+ context.start();
+
+ given().port(port.getPort())
+ .when()
+ .get("/api/v3/pet/findByTags")
+ .then()
+ .statusCode(400)
+ .body(containsString("tags"));
+ } finally {
+ context.stop();
+ }
+ }
+
+ @Test
+ public void testValidationLevelOverride() throws Exception {
+ CamelContext context = createCamelContext();
+ try {
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+ restConfiguration()
+ .clientRequestValidation(true)
+
.validationLevelProperty("validation.request.body.schema.required", "INFO");
+
+
rest().openApi().specification("petstore-v3.json").missingOperation("ignore");
+
+ from("direct:updatePet")
+ .setBody().constant("{\"name\": \"tiger\"}");
+ }
+ });
+ context.start();
+
+ // Body missing required "photoUrls" field, but the validation
level for
+ // schema.required is downgraded to INFO, so the request passes.
+ given().port(port.getPort())
+ .contentType("application/json")
+ .body("{\"name\": \"tiger\"}")
+ .when()
+ .put("/api/v3/pet")
+ .then()
+ .statusCode(200);
+ } finally {
+ context.stop();
+ }
+ }
+
+ private CamelContext createCamelContext() throws Exception {
+ VertxPlatformHttpServerConfiguration conf = new
VertxPlatformHttpServerConfiguration();
+ conf.setBindPort(port.getPort());
+ CamelContext context = new DefaultCamelContext();
+ context.addService(new VertxPlatformHttpServer(conf));
+ return context;
+ }
+}