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 6eec69b21a39 CAMEL-26134: camel-test-citrus - Validate byte[], file
and stream bodies by their content (#28909)
6eec69b21a39 is described below
commit 6eec69b21a3925423856da66a5e35e1b2839f003
Author: Claus Ibsen <[email protected]>
AuthorDate: Mon Nov 2 20:41:52 2026 +0100
CAMEL-26134: camel-test-citrus - Validate byte[], file and stream bodies by
their content (#28909)
…ies by their content
The citrus endpoint only read a StreamCache body as text. A byte[] body was
compared as [123, 34, ...], a File or Path as its path, and a plain
InputStream
(stream caching off) was read by Citrus and not reset, so the next step got
an
empty stream.
Now a plain InputStream or Reader is cached and set back on the message
first,
and byte[], File, Path, WrappedFile and stream cache bodies are read as
text, or
as bytes for the binary, binary_base64, gzip and gzip_base64 message types.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Claus Ibsen <[email protected]>
---
.../apache/camel/catalog/components/citrus.json | 2 +-
.../camel/catalog/docs/citrus-component.adoc | 10 ++
.../org/apache/camel/component/citrus/citrus.json | 2 +-
.../src/main/docs/citrus-component.adoc | 10 ++
.../camel/component/citrus/CitrusEndpoint.java | 11 +-
.../citrus/CitrusValidationProcessor.java | 38 ++++-
.../citrus/CitrusValidateBodyTypesTest.java | 181 +++++++++++++++++++++
7 files changed, 240 insertions(+), 14 deletions(-)
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/citrus.json
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/citrus.json
index 606d7d689b0c..56550fbcced6 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/citrus.json
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/citrus.json
@@ -30,7 +30,7 @@
"exchangeFormatter": { "index": 3, "kind": "property", "displayName":
"Exchange Formatter", "group": "advanced", "label": "advanced", "required":
false, "type": "object", "javaType": "org.apache.camel.spi.ExchangeFormatter",
"deprecated": false, "autowired": true, "secret": false, "description": "To use
a custom ExchangeFormatter to format the Exchange into a String suitable for
logging." }
},
"bodyTypes": {
- "producerIn": { "carriers": [ "text", "stream" ], "description": "The body
Citrus validates, as text; a stream cache is read as text and reset. Any other
body is compared by its toString(): bytes as a list of numbers, a file as its
path, a Map as {key=value}, so convert it to a String first" },
+ "producerIn": { "carriers": [ "text", "bytes", "stream", "file" ],
"description": "The body Citrus validates, read as text, or as bytes for the
binary, binary_base64, gzip and gzip_base64 message types. A plain stream is
cached first, so the next step can still read it. Any other body is compared by
its toString(), such as a Map as {key=value}, so convert it to a String first"
},
"producerOut": { "carriers": [ "unchanged" ], "byOption": [ { "option":
"operation", "value": "receive", "carriers": [ "text", "unchanged" ] } ],
"description": "validate leaves the body as it is. receive sets the body of the
simulated response, as text with its Citrus variables resolved (or what the
MessagePayloadBuilder given to it builds), and leaves the body as it is when
the response has no body or no spec matches the exchange" }
},
"properties": {
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/citrus-component.adoc
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/citrus-component.adoc
index 3d8a62731daf..cbebc64e5fc9 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/citrus-component.adoc
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/citrus-component.adoc
@@ -232,6 +232,16 @@ YAML validation compares YAML documents structurally,
similar to JSON validation
* **Path expressions** for ignoring specific elements in the YAML structure.
* **`@ignore@` and validation matchers** within YAML values.
+==== Message body types
+
+The endpoint reads the message body as text before it validates it: a
`String`, a `byte[]`, a file
+(`java.io.File`, `java.nio.file.Path` or the file of a file consumer) or a
stream. For the
+`binary`, `binary_base64`, `gzip` and `gzip_base64` message types it reads the
body as bytes instead.
+
+A stream body (an `InputStream` or a `Reader`) is cached first when stream
caching is turned off, so the
+next step in the route can still read it. Any other body (such as a `Map`) is
handed to Citrus as is, and
+compared by its `toString()`, so convert it to a `String` first.
+
=== Validation specification API
The endpoint provides a fluent API for specifying validation expectations. You
can define a single
diff --git
a/components/camel-test/camel-test-citrus/src/generated/resources/META-INF/org/apache/camel/component/citrus/citrus.json
b/components/camel-test/camel-test-citrus/src/generated/resources/META-INF/org/apache/camel/component/citrus/citrus.json
index 606d7d689b0c..56550fbcced6 100644
---
a/components/camel-test/camel-test-citrus/src/generated/resources/META-INF/org/apache/camel/component/citrus/citrus.json
+++
b/components/camel-test/camel-test-citrus/src/generated/resources/META-INF/org/apache/camel/component/citrus/citrus.json
@@ -30,7 +30,7 @@
"exchangeFormatter": { "index": 3, "kind": "property", "displayName":
"Exchange Formatter", "group": "advanced", "label": "advanced", "required":
false, "type": "object", "javaType": "org.apache.camel.spi.ExchangeFormatter",
"deprecated": false, "autowired": true, "secret": false, "description": "To use
a custom ExchangeFormatter to format the Exchange into a String suitable for
logging." }
},
"bodyTypes": {
- "producerIn": { "carriers": [ "text", "stream" ], "description": "The body
Citrus validates, as text; a stream cache is read as text and reset. Any other
body is compared by its toString(): bytes as a list of numbers, a file as its
path, a Map as {key=value}, so convert it to a String first" },
+ "producerIn": { "carriers": [ "text", "bytes", "stream", "file" ],
"description": "The body Citrus validates, read as text, or as bytes for the
binary, binary_base64, gzip and gzip_base64 message types. A plain stream is
cached first, so the next step can still read it. Any other body is compared by
its toString(), such as a Map as {key=value}, so convert it to a String first"
},
"producerOut": { "carriers": [ "unchanged" ], "byOption": [ { "option":
"operation", "value": "receive", "carriers": [ "text", "unchanged" ] } ],
"description": "validate leaves the body as it is. receive sets the body of the
simulated response, as text with its Citrus variables resolved (or what the
MessagePayloadBuilder given to it builds), and leaves the body as it is when
the response has no body or no spec matches the exchange" }
},
"properties": {
diff --git
a/components/camel-test/camel-test-citrus/src/main/docs/citrus-component.adoc
b/components/camel-test/camel-test-citrus/src/main/docs/citrus-component.adoc
index 3d8a62731daf..cbebc64e5fc9 100644
---
a/components/camel-test/camel-test-citrus/src/main/docs/citrus-component.adoc
+++
b/components/camel-test/camel-test-citrus/src/main/docs/citrus-component.adoc
@@ -232,6 +232,16 @@ YAML validation compares YAML documents structurally,
similar to JSON validation
* **Path expressions** for ignoring specific elements in the YAML structure.
* **`@ignore@` and validation matchers** within YAML values.
+==== Message body types
+
+The endpoint reads the message body as text before it validates it: a
`String`, a `byte[]`, a file
+(`java.io.File`, `java.nio.file.Path` or the file of a file consumer) or a
stream. For the
+`binary`, `binary_base64`, `gzip` and `gzip_base64` message types it reads the
body as bytes instead.
+
+A stream body (an `InputStream` or a `Reader`) is cached first when stream
caching is turned off, so the
+next step in the route can still read it. Any other body (such as a `Map`) is
handed to Citrus as is, and
+compared by its `toString()`, so convert it to a `String` first.
+
=== Validation specification API
The endpoint provides a fluent API for specifying validation expectations. You
can define a single
diff --git
a/components/camel-test/camel-test-citrus/src/main/java/org/apache/camel/component/citrus/CitrusEndpoint.java
b/components/camel-test/camel-test-citrus/src/main/java/org/apache/camel/component/citrus/CitrusEndpoint.java
index 949af01e2d4a..b9d5f0a7e7b5 100644
---
a/components/camel-test/camel-test-citrus/src/main/java/org/apache/camel/component/citrus/CitrusEndpoint.java
+++
b/components/camel-test/camel-test-citrus/src/main/java/org/apache/camel/component/citrus/CitrusEndpoint.java
@@ -35,11 +35,12 @@ import
org.citrusframework.validation.context.ValidationContext;
@UriEndpoint(firstVersion = "4.24.0", scheme = "citrus", title = "Citrus",
syntax = "citrus:operation/name",
producerOnly = true, remote = false, category = {
Category.TESTING })
-@BodyTypes(producerIn = @BodyType(value = { Carrier.TEXT, Carrier.STREAM },
- description = "The body Citrus validates, as
text; a stream cache is read as text and"
- + " reset. Any other body is
compared by its toString(): bytes as a list of"
- + " numbers, a file as its
path, a Map as {key=value}, so convert it to a String"
- + " first"),
+@BodyTypes(producerIn = @BodyType(value = { Carrier.TEXT, Carrier.BYTES,
Carrier.STREAM, Carrier.FILE },
+ description = "The body Citrus validates,
read as text, or as bytes for the binary,"
+ + " binary_base64, gzip and
gzip_base64 message types. A plain stream is"
+ + " cached first, so the next
step can still read it. Any other body is"
+ + " compared by its
toString(), such as a Map as {key=value}, so convert it to"
+ + " a String first"),
producerOut = @BodyType(value = Carrier.UNCHANGED,
byOption = @BodyType.When(option =
"operation", value = "receive",
carriers = {
Carrier.TEXT, Carrier.UNCHANGED }),
diff --git
a/components/camel-test/camel-test-citrus/src/main/java/org/apache/camel/component/citrus/CitrusValidationProcessor.java
b/components/camel-test/camel-test-citrus/src/main/java/org/apache/camel/component/citrus/CitrusValidationProcessor.java
index 29d491d79cb2..46f98f27bb72 100644
---
a/components/camel-test/camel-test-citrus/src/main/java/org/apache/camel/component/citrus/CitrusValidationProcessor.java
+++
b/components/camel-test/camel-test-citrus/src/main/java/org/apache/camel/component/citrus/CitrusValidationProcessor.java
@@ -16,6 +16,10 @@
*/
package org.apache.camel.component.citrus;
+import java.io.File;
+import java.io.InputStream;
+import java.io.Reader;
+import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
@@ -23,11 +27,14 @@ import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
+import java.util.Set;
import java.util.function.Supplier;
import org.apache.camel.Exchange;
+import org.apache.camel.Message;
import org.apache.camel.Processor;
import org.apache.camel.StreamCache;
+import org.apache.camel.WrappedFile;
import org.citrusframework.context.TestContext;
import org.citrusframework.message.DefaultMessage;
import org.citrusframework.message.MessagePayloadBuilder;
@@ -45,6 +52,10 @@ class CitrusValidationProcessor implements Processor {
private static final Logger LOG =
LoggerFactory.getLogger(CitrusValidationProcessor.class);
+ private static final Set<String> BINARY_MESSAGE_TYPES = Set.of(
+ MessageType.BINARY.name(), MessageType.BINARY_BASE64.name(),
+ MessageType.GZIP.name(), MessageType.GZIP_BASE64.name());
+
private final Supplier<TestContext> testContextSupplier;
private final String messageType;
private final MessageValidationSpec spec;
@@ -72,10 +83,9 @@ class CitrusValidationProcessor implements Processor {
try {
if (spec != null) {
- DefaultMessage receivedMessage =
buildReceivedMessage(exchange);
- DefaultMessage controlMessage = buildControlMessage(spec,
testContext);
-
String resolvedType = resolveMessageType(spec);
+ DefaultMessage receivedMessage =
buildReceivedMessage(exchange, resolvedType);
+ DefaultMessage controlMessage = buildControlMessage(spec,
testContext);
controlMessage.setType(resolvedType);
List<ValidationContext> validationContexts =
buildValidationContexts(spec);
@@ -92,13 +102,27 @@ class CitrusValidationProcessor implements Processor {
// --- Citrus validation helpers ---
- private DefaultMessage buildReceivedMessage(Exchange exchange) {
- Object body = exchange.getIn().getBody();
+ private DefaultMessage buildReceivedMessage(Exchange exchange, String
resolvedType) {
+ Message in = exchange.getIn();
+ Object body = in.getBody();
+ if ((body instanceof InputStream || body instanceof Reader) && !(body
instanceof StreamCache)) {
+ // cache a plain stream, so the validation does not consume it for
the next step in the route
+ StreamCache cache =
exchange.getContext().getTypeConverter().convertTo(StreamCache.class, exchange,
body);
+ if (cache != null) {
+ in.setBody(cache);
+ body = cache;
+ }
+ }
+ // the binary validators compare bytes, the others compare text
+ Class<?> payloadType = BINARY_MESSAGE_TYPES.contains(resolvedType) ?
byte[].class : String.class;
if (body instanceof StreamCache sc) {
- body = exchange.getIn().getBody(String.class);
+ body = in.getBody(payloadType);
sc.reset();
+ } else if (body instanceof byte[] || body instanceof File || body
instanceof Path || body instanceof WrappedFile) {
+ // read the content, as Citrus would otherwise compare a byte[] as
[123, 34, ...] and a file as its path
+ body = in.getBody(payloadType);
}
- Map<String, Object> headers = new
LinkedHashMap<>(exchange.getIn().getHeaders());
+ Map<String, Object> headers = new LinkedHashMap<>(in.getHeaders());
return new DefaultMessage(body, headers);
}
diff --git
a/components/camel-test/camel-test-citrus/src/test/java/org/apache/camel/component/citrus/CitrusValidateBodyTypesTest.java
b/components/camel-test/camel-test-citrus/src/test/java/org/apache/camel/component/citrus/CitrusValidateBodyTypesTest.java
new file mode 100644
index 000000000000..6e3eaae2347b
--- /dev/null
+++
b/components/camel-test/camel-test-citrus/src/test/java/org/apache/camel/component/citrus/CitrusValidateBodyTypesTest.java
@@ -0,0 +1,181 @@
+/*
+ * 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.citrus;
+
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.InputStream;
+import java.io.StringReader;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Base64;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.RoutesBuilder;
+import org.apache.camel.StreamCache;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.converter.stream.InputStreamCache;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.citrusframework.exceptions.ValidationException;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Bodies that are not a String or a stream cache: they are read as text (or
as bytes for the binary message types), and
+ * a plain stream is not consumed by the validation.
+ */
+class CitrusValidateBodyTypesTest extends CamelTestSupport {
+
+ private static final String JSON = "{\"status\": \"ok\"}";
+
+ @TempDir
+ Path tempDir;
+
+ @Override
+ protected CamelContext createCamelContext() throws Exception {
+ CamelContext context = super.createCamelContext();
+ // without stream caching a plain InputStream reaches the citrus
endpoint as is
+ context.setStreamCaching(false);
+ return context;
+ }
+
+ @Test
+ void shouldValidateJsonByteArrayBody() throws Exception {
+ CitrusEndpoint citrus =
context.getEndpoint("citrus:validate?messageType=json", CitrusEndpoint.class);
+ citrus.validateAll().body(JSON);
+ citrus.expectedMessageCount(1);
+
+ template.sendBody("direct:json",
JSON.getBytes(StandardCharsets.UTF_8));
+
+ citrus.assertIsSatisfied();
+ }
+
+ @Test
+ void shouldFailOnJsonByteArrayBodyMismatch() {
+ CitrusEndpoint citrus =
context.getEndpoint("citrus:validate?messageType=json", CitrusEndpoint.class);
+ citrus.validateAll().body(JSON);
+ citrus.expectedMessageCount(1);
+
+ template.sendBody("direct:json", "{\"status\":
\"failed\"}".getBytes(StandardCharsets.UTF_8));
+
+ assertThatThrownBy(citrus::assertIsSatisfied)
+ .isInstanceOf(AssertionError.class)
+ .hasCauseInstanceOf(ValidationException.class);
+ }
+
+ @Test
+ void shouldValidatePlainTextByteArrayBody() throws Exception {
+ CitrusEndpoint citrus =
context.getEndpoint("citrus:validate/text?messageType=plaintext",
CitrusEndpoint.class);
+ citrus.validateAll().body("Hello Camel!");
+ citrus.expectedMessageCount(1);
+
+ template.sendBody("direct:text", "Hello
Camel!".getBytes(StandardCharsets.UTF_8));
+
+ citrus.assertIsSatisfied();
+ }
+
+ @Test
+ void shouldValidateFileBodyByContent() throws Exception {
+ File file = Files.writeString(tempDir.resolve("status.json"),
JSON).toFile();
+
+ CitrusEndpoint citrus =
context.getEndpoint("citrus:validate?messageType=json", CitrusEndpoint.class);
+ citrus.validateAll().body(JSON);
+ citrus.expectedMessageCount(1);
+
+ template.sendBody("direct:json", file);
+
+ citrus.assertIsSatisfied();
+ }
+
+ @Test
+ void shouldValidatePathBodyByContent() throws Exception {
+ Path path = Files.writeString(tempDir.resolve("hello.txt"), "Hello
Camel!");
+
+ CitrusEndpoint citrus =
context.getEndpoint("citrus:validate/text?messageType=plaintext",
CitrusEndpoint.class);
+ citrus.validateAll().body("Hello Camel!");
+ citrus.expectedMessageCount(1);
+
+ template.sendBody("direct:text", path);
+
+ citrus.assertIsSatisfied();
+ }
+
+ @Test
+ void shouldNotConsumePlainInputStream() throws Exception {
+ CitrusEndpoint citrus =
context.getEndpoint("citrus:validate/stream?messageType=plaintext",
CitrusEndpoint.class);
+ citrus.validateAll().body("Hello Camel!");
+ citrus.expectedMessageCount(1);
+ MockEndpoint result = getMockEndpoint("mock:result");
+ result.expectedBodiesReceived("Hello Camel!");
+
+ InputStream body = new ByteArrayInputStream("Hello
Camel!".getBytes(StandardCharsets.UTF_8));
+ template.sendBody("direct:stream", body);
+
+ citrus.assertIsSatisfied();
+ result.assertIsSatisfied();
+
assertThat(result.getReceivedExchanges().get(0).getIn().getBody()).isInstanceOf(StreamCache.class);
+ }
+
+ @Test
+ void shouldNotConsumePlainReader() throws Exception {
+ CitrusEndpoint citrus =
context.getEndpoint("citrus:validate/stream?messageType=plaintext",
CitrusEndpoint.class);
+ citrus.validateAll().body("Hello Camel!");
+ citrus.expectedMessageCount(1);
+ MockEndpoint result = getMockEndpoint("mock:result");
+ result.expectedBodiesReceived("Hello Camel!");
+
+ template.sendBody("direct:stream", new StringReader("Hello Camel!"));
+
+ citrus.assertIsSatisfied();
+ result.assertIsSatisfied();
+ }
+
+ @Test
+ void shouldValidateStreamCacheAsBytesForBinaryBase64() throws Exception {
+ byte[] data = new byte[] { 0, 1, 2, (byte) 0xCA, (byte) 0xFE };
+
+ CitrusEndpoint citrus =
context.getEndpoint("citrus:validate/binary?messageType=binary_base64",
CitrusEndpoint.class);
+ citrus.validateAll().body(Base64.getEncoder().encodeToString(data));
+ citrus.expectedMessageCount(1);
+ MockEndpoint result = getMockEndpoint("mock:binary");
+ result.expectedMessageCount(1);
+
+ template.sendBody("direct:binary", new InputStreamCache(data));
+
+ citrus.assertIsSatisfied();
+ result.assertIsSatisfied();
+
assertThat(result.getReceivedExchanges().get(0).getIn().getBody(byte[].class)).isEqualTo(data);
+ }
+
+ @Override
+ protected RoutesBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ from("direct:json").to("citrus:validate?messageType=json");
+
from("direct:text").to("citrus:validate/text?messageType=plaintext");
+
from("direct:stream").to("citrus:validate/stream?messageType=plaintext").to("mock:result");
+
from("direct:binary").to("citrus:validate/binary?messageType=binary_base64").to("mock:binary");
+ }
+ };
+ }
+}