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 fac55782529b CAMEL-25043: camel-core - Tokenize language: fix bugs
found in a deep review (#26923)
fac55782529b is described below
commit fac55782529bee72ad083cc56c7c41ab85f28b62
Author: Claus Ibsen <[email protected]>
AuthorDate: Mon Sep 28 09:26:32 2026 +0200
CAMEL-25043: camel-core - Tokenize language: fix bugs found in a deep
review (#26923)
- a message without a body has no tokens instead of failing with a
NullPointerException
- skipFirst on an empty body no longer fails with NoSuchElementException
- pair mode reads from source (header, variable or property) instead of
always the body
- skipFirst applies in xml mode and pair mode
- pair mode with group joins the pairs without the start token between them
- a pair with the same start and end token is refused when the route is
created
- xml wrap mode (inheritNamespaceTagName=*) handles a comment, DOCTYPE or
CDATA before the
root, and multi-byte characters before the first token
- ValueBuilder/MockValueBuilder.tokenize(token, null, skipFirst) no longer
fails at startup
- the group delimiter is encoded in the exchange charset
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Signed-off-by: Claus Ibsen <[email protected]>
---
.../camel/component/mock/MockValueBuilder.java | 4 +-
.../camel/language/tokenizer/TokenizeLanguage.java | 32 ++--
.../language/tokenizer/TokenizeEdgeCasesTest.java | 181 +++++++++++++++++++++
.../org/apache/camel/processor/SplitterTest.java | 9 +-
.../support/builder/RecordableInputStreamTest.java | 15 ++
.../org/apache/camel/support/ExchangeHelper.java | 3 +-
.../apache/camel/support/GroupTokenIterator.java | 2 +-
.../camel/support/builder/ExpressionBuilder.java | 15 +-
.../support/builder/RecordableInputStream.java | 20 ++-
.../builder/TokenPairExpressionIterator.java | 10 ++
.../builder/TokenXMLExpressionIterator.java | 39 +++++
.../apache/camel/support/builder/ValueBuilder.java | 4 +-
.../ROOT/pages/camel-4x-upgrade-guide-4_23.adoc | 13 ++
13 files changed, 318 insertions(+), 29 deletions(-)
diff --git
a/components/camel-mock/src/main/java/org/apache/camel/component/mock/MockValueBuilder.java
b/components/camel-mock/src/main/java/org/apache/camel/component/mock/MockValueBuilder.java
index 4420a6180fb9..a42918a795cd 100644
---
a/components/camel-mock/src/main/java/org/apache/camel/component/mock/MockValueBuilder.java
+++
b/components/camel-mock/src/main/java/org/apache/camel/component/mock/MockValueBuilder.java
@@ -504,7 +504,9 @@ public class MockValueBuilder implements Expression,
Predicate {
// wrap in skip first (if group then it has its own skip-first
logic)
newExp = ExpressionBuilder.skipFirstExpression(newExp);
}
- newExp = ExpressionBuilder.groupIteratorExpression(newExp, token,
group, skipFirst);
+ if (group != null) {
+ newExp = ExpressionBuilder.groupIteratorExpression(newExp, token,
group, skipFirst);
+ }
return onNewValueBuilder(newExp);
}
diff --git
a/core/camel-core-languages/src/main/java/org/apache/camel/language/tokenizer/TokenizeLanguage.java
b/core/camel-core-languages/src/main/java/org/apache/camel/language/tokenizer/TokenizeLanguage.java
index 59d16652403c..feb0da7aed7b 100644
---
a/core/camel-core-languages/src/main/java/org/apache/camel/language/tokenizer/TokenizeLanguage.java
+++
b/core/camel-core-languages/src/main/java/org/apache/camel/language/tokenizer/TokenizeLanguage.java
@@ -71,32 +71,34 @@ public class TokenizeLanguage extends
SingleInputTypedLanguageSupport {
throw new IllegalArgumentException("The option includeTokens
requires endToken to be specified.");
}
- Expression answer = null;
+ Expression answer;
if (xml) {
answer = ExpressionBuilder.tokenizeXMLExpression(source, token,
inheritNamespaceTagName);
} else if (endToken != null) {
- answer = ExpressionBuilder.tokenizePairExpression(token, endToken,
includeTokens);
+ answer = ExpressionBuilder.tokenizePairExpression(source, token,
endToken, includeTokens);
+ } else if (regex) {
+ answer = ExpressionBuilder.regexTokenizeExpression(source, token);
+ } else {
+ answer = ExpressionBuilder.tokenizeExpression(source, token);
}
- if (answer == null) {
- // use the regular tokenizer
- if (regex) {
- answer = ExpressionBuilder.regexTokenizeExpression(source,
token);
- } else {
- answer = ExpressionBuilder.tokenizeExpression(source, token);
- }
- if (group == null && skipFirst) {
- // wrap in skip first (if group then it has its own skip first
logic)
- answer = ExpressionBuilder.skipFirstExpression(answer);
- }
+ if (group == null && skipFirst) {
+ // wrap in skip first (if group then it has its own skip first
logic)
+ answer = ExpressionBuilder.skipFirstExpression(answer);
}
// if group then wrap answer in group expression
if (group != null) {
if (xml) {
- answer = ExpressionBuilder.groupXmlIteratorExpression(answer,
group);
+ answer = ExpressionBuilder.groupXmlIteratorExpression(answer,
group, skipFirst);
} else {
- String delim = groupDelimiter != null ? groupDelimiter : token;
+ String delim = groupDelimiter;
+ if (delim == null) {
+ // pairs that include their tokens are joined without a
delimiter (as in xml mode), as the start
+ // token between them would make them look like an
unfinished pair; bare values keep the token
+ // as boundary
+ delim = endToken != null && includeTokens ? "" : token;
+ }
answer = ExpressionBuilder.groupIteratorExpression(answer,
delim, group, skipFirst);
}
}
diff --git
a/core/camel-core/src/test/java/org/apache/camel/language/tokenizer/TokenizeEdgeCasesTest.java
b/core/camel-core/src/test/java/org/apache/camel/language/tokenizer/TokenizeEdgeCasesTest.java
new file mode 100644
index 000000000000..92930a773ff7
--- /dev/null
+++
b/core/camel-core/src/test/java/org/apache/camel/language/tokenizer/TokenizeEdgeCasesTest.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.language.tokenizer;
+
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.function.Consumer;
+
+import org.apache.camel.ContextTestSupport;
+import org.apache.camel.Exchange;
+import org.apache.camel.Expression;
+import org.apache.camel.FailedToCreateRouteException;
+import org.apache.camel.builder.Builder;
+import org.apache.camel.builder.LanguageBuilderFactory;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+public class TokenizeEdgeCasesTest extends ContextTestSupport {
+
+ private static final String ORDERS = "<?xml
version=\"1.0\"?>%s<root%s><order>1</order><order>2</order></root>";
+
+ @Override
+ public boolean isUseRouteBuilder() {
+ return false;
+ }
+
+ private static LanguageBuilderFactory lang() {
+ return new LanguageBuilderFactory();
+ }
+
+ private MockEndpoint split(Expression expression, Object body, String
header, Object value) throws Exception {
+ return split(expression, body, e -> {
+ if (header != null) {
+ e.getMessage().setHeader(header, value);
+ }
+ });
+ }
+
+ private MockEndpoint split(Expression expression, Object body,
Consumer<Exchange> setup) throws Exception {
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+ from("direct:start").split(expression).to("mock:split");
+ }
+ });
+ context.start();
+ Exchange out = template.send("direct:start", e -> {
+ e.getMessage().setBody(body);
+ setup.accept(e);
+ });
+ assertThat(out.getException()).isNull();
+ return getMockEndpoint("mock:split");
+ }
+
+ private static List<String> bodies(MockEndpoint mock) {
+ return mock.getReceivedExchanges().stream().map(e ->
e.getMessage().getBody(String.class)).toList();
+ }
+
+ @Test
+ public void testNullBodyHasNoParts() throws Exception {
+ MockEndpoint mock = split(lang().tokenize().token(",").end(), null,
null, null);
+ assertThat(mock.getReceivedExchanges()).isEmpty();
+ }
+
+ @Test
+ public void testSkipFirstOnEmptyBody() throws Exception {
+ MockEndpoint mock =
split(lang().tokenize().token("\n").skipFirst(true).end(), "", null, null);
+ assertThat(mock.getReceivedExchanges()).isEmpty();
+ }
+
+ @Test
+ public void testSkipFirstWithoutGroupFromValueBuilder() throws Exception {
+ MockEndpoint mock = split(Builder.body().tokenize(",", (String) null,
true), "header,a,b", null, null);
+ assertThat(bodies(mock)).containsExactly("a", "b");
+ }
+
+ @Test
+ public void testPairFromSource() throws Exception {
+ MockEndpoint mock =
split(lang().tokenize().token("[").endToken("]").source("header:data").end(),
+ "[body1][body2]", "data", "[hdr1][hdr2]");
+ assertThat(bodies(mock)).containsExactly("hdr1", "hdr2");
+ }
+
+ @Test
+ public void testPairSkipFirst() throws Exception {
+ MockEndpoint mock =
split(lang().tokenize().token("[").endToken("]").skipFirst(true).end(),
"[1][2][3]", null, null);
+ assertThat(bodies(mock)).containsExactly("2", "3");
+ }
+
+ @Test
+ public void testPairGroupHasNoStartTokenBetweenParts() throws Exception {
+ MockEndpoint mock =
split(lang().tokenize().token("<a>").endToken("</a>").includeTokens(true).group(2).end(),
+ "<a>1</a><a>2</a><a>3</a>", null, null);
+ assertThat(bodies(mock)).containsExactly("<a>1</a><a>2</a>",
"<a>3</a>");
+ }
+
+ @Test
+ public void testPairGroupWithoutTokensKeepsStartTokenAsBoundary() throws
Exception {
+ MockEndpoint mock =
split(lang().tokenize().token("[").endToken("]").group(2).end(),
+ "[1][2][3]", null, null);
+ assertThat(bodies(mock)).containsExactly("1[2", "3");
+ }
+
+ @Test
+ public void testPairWithSameStartAndEndTokenIsRejected() {
+ assertThatThrownBy(() ->
split(lang().tokenize().token("'").endToken("'").end(), "'a' 'b'", null, null))
+ .isInstanceOf(FailedToCreateRouteException.class)
+ .rootCause().hasMessageContaining("The start and end token
must be different");
+ }
+
+ @Test
+ public void testXmlSkipFirst() throws Exception {
+ MockEndpoint mock =
split(lang().tokenize().token("order").xml(true).skipFirst(true).end(),
+ String.format(ORDERS, "", ""), null, null);
+ assertThat(bodies(mock)).containsExactly("<order>2</order>");
+ }
+
+ @Test
+ public void testXmlGroupSkipFirst() throws Exception {
+ MockEndpoint mock =
split(lang().tokenize().token("order").xml(true).group(2).skipFirst(true).end(),
+
"<root><order>1</order><order>2</order><order>3</order></root>", null, null);
+
assertThat(bodies(mock)).containsExactly("<order>2</order><order>3</order>");
+ }
+
+ @Test
+ public void testWrapWithCommentBeforeRoot() throws Exception {
+ MockEndpoint mock =
split(lang().tokenize().token("order").xml(true).inheritNamespaceTagName("*").end(),
+ String.format(ORDERS, "<!-- licensed to you -->", ""), null,
null);
+ assertThat(bodies(mock)).containsExactly(
+ "<?xml version=\"1.0\"?><!-- licensed to you
--><root><order>1</order></root>",
+ "<?xml version=\"1.0\"?><!-- licensed to you
--><root><order>2</order></root>");
+ }
+
+ @Test
+ public void testWrapWithDoctype() throws Exception {
+ String doctype = "<!DOCTYPE root [<!ELEMENT root (order*)>]>";
+ MockEndpoint mock =
split(lang().tokenize().token("order").xml(true).inheritNamespaceTagName("*").end(),
+ String.format(ORDERS, doctype, ""), null, null);
+ assertThat(bodies(mock)).containsExactly(
+ "<?xml version=\"1.0\"?>" + doctype +
"<root><order>1</order></root>",
+ "<?xml version=\"1.0\"?>" + doctype +
"<root><order>2</order></root>");
+ }
+
+ @Test
+ public void testWrapWithDoctypeWhitespaceBeforeClose() throws Exception {
+ String doctype = "<!DOCTYPE root [<!ELEMENT root (order*)>] \n>";
+ MockEndpoint mock =
split(lang().tokenize().token("order").xml(true).inheritNamespaceTagName("*").end(),
+ String.format(ORDERS, doctype, ""), null, null);
+ assertThat(bodies(mock)).containsExactly(
+ "<?xml version=\"1.0\"?>" + doctype +
"<root><order>1</order></root>",
+ "<?xml version=\"1.0\"?>" + doctype +
"<root><order>2</order></root>");
+ }
+
+ @Test
+ public void testWrapWithMultiByteCharactersBeforeToken() throws Exception {
+ byte[] body = String.format(ORDERS, "", "
name=\"æøå\"").getBytes(StandardCharsets.UTF_8);
+ MockEndpoint mock =
split(lang().tokenize().token("order").xml(true).inheritNamespaceTagName("*").end(),
+ body, e -> e.setProperty(Exchange.CHARSET_NAME, "UTF-8"));
+ assertThat(bodies(mock)).containsExactly(
+ "<?xml version=\"1.0\"?><root
name=\"æøå\"><order>1</order></root>",
+ "<?xml version=\"1.0\"?><root
name=\"æøå\"><order>2</order></root>");
+ }
+}
diff --git
a/core/camel-core/src/test/java/org/apache/camel/processor/SplitterTest.java
b/core/camel-core/src/test/java/org/apache/camel/processor/SplitterTest.java
index 9963b3c87df3..c907bdc5f6d2 100644
--- a/core/camel-core/src/test/java/org/apache/camel/processor/SplitterTest.java
+++ b/core/camel-core/src/test/java/org/apache/camel/processor/SplitterTest.java
@@ -94,14 +94,19 @@ public class SplitterTest extends ContextTestSupport {
}
@Test
- public void testEmptyBody() {
+ public void testEmptyBody() throws Exception {
+ MockEndpoint resultEndpoint = getMockEndpoint("mock:result");
+ resultEndpoint.expectedMessageCount(0);
+
Exchange result = template.request("direct:seqential", new Processor()
{
public void process(Exchange exchange) {
exchange.getIn().setHeader("foo", "bar");
}
});
- assertFalse(result.hasOut(), "Should not have out");
+ // no body has no parts to split
+ assertNull(result.getException(), "Should not fail");
+ assertMockEndpointsSatisfied();
}
@Test
diff --git
a/core/camel-core/src/test/java/org/apache/camel/support/builder/RecordableInputStreamTest.java
b/core/camel-core/src/test/java/org/apache/camel/support/builder/RecordableInputStreamTest.java
index 4de2bd9d16d6..356664aa2107 100644
---
a/core/camel-core/src/test/java/org/apache/camel/support/builder/RecordableInputStreamTest.java
+++
b/core/camel-core/src/test/java/org/apache/camel/support/builder/RecordableInputStreamTest.java
@@ -102,4 +102,19 @@ public class RecordableInputStreamTest {
ris.close();
}
+ @Test
+ public void testGetTextWithMultiByteCharacters() throws Exception {
+ byte[] data = "æøåabc".getBytes(StandardCharsets.UTF_8);
+ RecordableInputStream ris = new RecordableInputStream(new
ByteArrayInputStream(data), "utf-8");
+ byte[] buf = new byte[64];
+ assertEquals(data.length, ris.read(buf, 0, buf.length));
+
+ // the position is in characters, and the rest is kept
+ assertEquals("æøåa", ris.getText(4));
+ assertEquals(2, ris.size());
+ assertEquals("bc", ris.getText(2));
+ assertEquals(0, ris.size());
+
+ ris.close();
+ }
}
diff --git
a/core/camel-support/src/main/java/org/apache/camel/support/ExchangeHelper.java
b/core/camel-support/src/main/java/org/apache/camel/support/ExchangeHelper.java
index 64f83528f874..d7ecd3df23f8 100644
---
a/core/camel-support/src/main/java/org/apache/camel/support/ExchangeHelper.java
+++
b/core/camel-support/src/main/java/org/apache/camel/support/ExchangeHelper.java
@@ -1055,7 +1055,8 @@ public final class ExchangeHelper {
} else {
// value is not a suitable type, try to convert value to a
string
String text =
exchange.getContext().getTypeConverter().convertTo(String.class, exchange,
value);
- scanner = new Scanner(text, delimiter);
+ // a null value (such as no message body) has no tokens
+ scanner = new Scanner(text != null ? text : "", delimiter);
}
}
return scanner;
diff --git
a/core/camel-support/src/main/java/org/apache/camel/support/GroupTokenIterator.java
b/core/camel-support/src/main/java/org/apache/camel/support/GroupTokenIterator.java
index 386469f3c3a3..d3e8a28ac0cf 100644
---
a/core/camel-support/src/main/java/org/apache/camel/support/GroupTokenIterator.java
+++
b/core/camel-support/src/main/java/org/apache/camel/support/GroupTokenIterator.java
@@ -140,7 +140,7 @@ public final class GroupTokenIterator implements
Iterator<Object>, Closeable {
// include token in between
if (data != null && count > 0 && token != null) {
- bos.write(token.getBytes());
+ bos.write(token.getBytes(ExchangeHelper.getCharset(exchange)));
}
if (data instanceof InputStream is) {
IOHelper.copy(is, bos);
diff --git
a/core/camel-support/src/main/java/org/apache/camel/support/builder/ExpressionBuilder.java
b/core/camel-support/src/main/java/org/apache/camel/support/builder/ExpressionBuilder.java
index 49d63c3005b3..ab024a2afd9b 100644
---
a/core/camel-support/src/main/java/org/apache/camel/support/builder/ExpressionBuilder.java
+++
b/core/camel-support/src/main/java/org/apache/camel/support/builder/ExpressionBuilder.java
@@ -1789,8 +1789,10 @@ public class ExpressionBuilder {
Object value = expression.evaluate(exchange, Object.class);
Iterator<?> it = typeConverter.tryConvertTo(Iterator.class,
exchange, value);
if (it != null) {
- // skip first
- it.next();
+ // skip first (there is nothing to skip when there is no
content)
+ if (it.hasNext()) {
+ it.next();
+ }
return it;
} else {
return value;
@@ -1838,6 +1840,11 @@ public class ExpressionBuilder {
}
public static Expression groupXmlIteratorExpression(final Expression
expression, final String group) {
+ return groupXmlIteratorExpression(expression, group, false);
+ }
+
+ public static Expression groupXmlIteratorExpression(
+ final Expression expression, final String group, final boolean
skipFirst) {
return new ExpressionAdapter() {
private Expression groupExp;
@@ -1857,7 +1864,7 @@ public class ExpressionBuilder {
} else if (parts <= 0) {
throw new RuntimeExchangeException("Group must be a
positive number, was: " + parts, exchange);
}
- return new GroupTokenIterator(exchange, it, null, parts,
false);
+ return new GroupTokenIterator(exchange, it, null, parts,
skipFirst);
}
@Override
@@ -2632,7 +2639,7 @@ public class ExpressionBuilder {
*/
public static Expression tokenizePairExpression(
Expression source, String startToken, String endToken, boolean
includeTokens) {
- return new TokenPairExpressionIterator(startToken, endToken,
includeTokens);
+ return new TokenPairExpressionIterator(source, startToken, endToken,
includeTokens);
}
/**
diff --git
a/core/camel-support/src/main/java/org/apache/camel/support/builder/RecordableInputStream.java
b/core/camel-support/src/main/java/org/apache/camel/support/builder/RecordableInputStream.java
index 4b2a7b59c819..53e7d259221c 100644
---
a/core/camel-support/src/main/java/org/apache/camel/support/builder/RecordableInputStream.java
+++
b/core/camel-support/src/main/java/org/apache/camel/support/builder/RecordableInputStream.java
@@ -40,7 +40,7 @@ class RecordableInputStream extends FilterInputStream {
@Override
public int read() throws IOException {
int c = super.read();
- if (c > 0 && recording) {
+ if (c >= 0 && recording) {
buf.write(c);
}
return c;
@@ -55,19 +55,31 @@ class RecordableInputStream extends FilterInputStream {
return n;
}
+ /**
+ * Returns the recorded text before the given position, and stops
recording.
+ *
+ * @param pos the position in characters (not bytes) as the scanner
reports it
+ */
public String getText(int pos) {
String t = null;
+ int len = 0;
recording = false;
try {
+ // decode what was recorded and cut by characters, as a character
may take more than one byte
if (charset == null) {
- t = new String(buf.getByteArray(), 0, pos);
+ t = new String(buf.getByteArray(), 0, buf.size());
+ t = t.substring(0, Math.min(pos, t.length()));
+ len = t.getBytes().length;
} else {
- t = new String(buf.getByteArray(), 0, pos, charset);
+ t = new String(buf.getByteArray(), 0, buf.size(), charset);
+ t = t.substring(0, Math.min(pos, t.length()));
+ len = t.getBytes(charset).length;
}
} catch (UnsupportedEncodingException e) {
// ignore it as this encoding exception should have been caught
earlier while scanning.
} finally {
- buf.trim(pos, 0);
+ // keep what was recorded after the text
+ buf.trim(Math.min(len, buf.size()), 0);
}
return t;
diff --git
a/core/camel-support/src/main/java/org/apache/camel/support/builder/TokenPairExpressionIterator.java
b/core/camel-support/src/main/java/org/apache/camel/support/builder/TokenPairExpressionIterator.java
index 87dbc8e6718f..dcf4dfa98e8e 100644
---
a/core/camel-support/src/main/java/org/apache/camel/support/builder/TokenPairExpressionIterator.java
+++
b/core/camel-support/src/main/java/org/apache/camel/support/builder/TokenPairExpressionIterator.java
@@ -58,6 +58,7 @@ public class TokenPairExpressionIterator extends
ExpressionAdapter {
public TokenPairExpressionIterator(Expression source, String startToken,
String endToken, boolean includeTokens) {
StringHelper.notEmpty(startToken, "startToken");
StringHelper.notEmpty(endToken, "endToken");
+ checkTokensDiffer(startToken, endToken);
this.startToken = startToken;
this.endToken = endToken;
this.includeTokens = includeTokens;
@@ -140,11 +141,20 @@ public class TokenPairExpressionIterator extends
ExpressionAdapter {
if (endExp != null) {
end = endExp.evaluate(exchange, String.class);
}
+ checkTokensDiffer(start, end);
TokenPairIterator iterator = new TokenPairIterator(start, end,
includeTokens, in, charset);
iterator.init();
return iterator;
}
+ private static void checkTokensDiffer(String startToken, String endToken) {
+ // the end token is the delimiter of the scanner, so a start token
that is the same is never found
+ if (startToken != null && startToken.equals(endToken)) {
+ throw new IllegalArgumentException(
+ "The start and end token must be different, both are: " +
startToken);
+ }
+ }
+
@Override
public String toString() {
return "tokenize[body() using tokens: " + startToken + "..." +
endToken + "]";
diff --git
a/core/camel-support/src/main/java/org/apache/camel/support/builder/TokenXMLExpressionIterator.java
b/core/camel-support/src/main/java/org/apache/camel/support/builder/TokenXMLExpressionIterator.java
index 0cd00f8b8272..009db08f3242 100644
---
a/core/camel-support/src/main/java/org/apache/camel/support/builder/TokenXMLExpressionIterator.java
+++
b/core/camel-support/src/main/java/org/apache/camel/support/builder/TokenXMLExpressionIterator.java
@@ -374,6 +374,41 @@ public class TokenXMLExpressionIterator extends
ExpressionAdapter {
return namespaces;
}
+ private static int skipMarkupDeclaration(String xmlhead, int p) {
+ String end;
+ if (xmlhead.startsWith("<!--", p)) {
+ end = "-->";
+ } else if (xmlhead.startsWith("<![CDATA[", p)) {
+ end = "]]>";
+ } else {
+ // DOCTYPE which may have an internal subset with declarations of
its own
+ int bracket = xmlhead.indexOf('[', p);
+ int gt = xmlhead.indexOf('>', p);
+ if (bracket >= 0 && (gt < 0 || bracket < gt)) {
+ return skipInternalSubset(xmlhead, bracket);
+ }
+ end = ">";
+ }
+ int ep = xmlhead.indexOf(end, p);
+ return ep < 0 ? xmlhead.length() : ep + end.length();
+ }
+
+ private static int skipInternalSubset(String xmlhead, int bracket) {
+ // the internal subset ends with ']' S? '>'
+ int p = xmlhead.indexOf(']', bracket);
+ while (p >= 0) {
+ int q = p + 1;
+ while (q < xmlhead.length() &&
Character.isWhitespace(xmlhead.charAt(q))) {
+ q++;
+ }
+ if (q < xmlhead.length() && xmlhead.charAt(q) == '>') {
+ return q + 1;
+ }
+ p = xmlhead.indexOf(']', q);
+ }
+ return xmlhead.length();
+ }
+
private static String buildXMLTail(String xmlhead) {
// assume the input text is a portion of a well-formed xml
List<String> tags = new ArrayList<>();
@@ -387,6 +422,10 @@ public class TokenXMLExpressionIterator extends
ExpressionAdapter {
if (nc == '?') {
p++;
continue;
+ } else if (nc == '!') {
+ // a comment, CDATA or DOCTYPE is not a tag to close
+ p = skipMarkupDeclaration(xmlhead, p);
+ continue;
} else if (nc == '/') {
p++;
tags.remove(tags.size() - 1);
diff --git
a/core/camel-support/src/main/java/org/apache/camel/support/builder/ValueBuilder.java
b/core/camel-support/src/main/java/org/apache/camel/support/builder/ValueBuilder.java
index b787bc3b9ed0..87e8983781b1 100644
---
a/core/camel-support/src/main/java/org/apache/camel/support/builder/ValueBuilder.java
+++
b/core/camel-support/src/main/java/org/apache/camel/support/builder/ValueBuilder.java
@@ -195,7 +195,9 @@ public class ValueBuilder implements Expression, Predicate {
// wrap in skip first (if group then it has its own skip first
logic)
newExp = ExpressionBuilder.skipFirstExpression(newExp);
}
- newExp = ExpressionBuilder.groupIteratorExpression(newExp, token,
group, skipFirst);
+ if (group != null) {
+ newExp = ExpressionBuilder.groupIteratorExpression(newExp, token,
group, skipFirst);
+ }
return onNewValueBuilder(newExp);
}
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 ec73dfa2e638..b805c32b9c50 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
@@ -155,6 +155,19 @@ ratios that are all `0`, or ratios whose sum is greater
than `2147483647` now fa
`IllegalArgumentException`. Previously such a route started, but sending to it
could hang the caller, spin a CPU,
or send every message to the same endpoint.
+=== Tokenize language
+
+- A message without a body (or a `source` that has no value) now has no
tokens, the same as an empty body.
+ Prior to Camel 4.23 the tokenizer failed with a `NullPointerException`.
+- `skipFirst` now also skips the first token in `xml` mode and in pair mode
(`endToken`). Prior to Camel 4.23 the
+ option was ignored in those modes.
+- In pair mode with `group` and `includeTokens`, the grouped pairs are now
joined without a delimiter (as in
+ `xml` mode), unless `groupDelimiter` is set. Prior to Camel 4.23 the start
token was inserted between them, so a
+ group of `<a>1</a>` and `<a>2</a>` became `<a>1</a><a><a>2</a>`. Without
`includeTokens` the start token is still
+ used as the delimiter between the grouped values.
+- A pair with the same start and end token is now refused when the route is
created. Prior to Camel 4.23 it
+ silently had no tokens.
+
=== Context reload now re-applies placeholder based component options
When a context reload is triggered, for example by one of the vault components
detecting that a secret was rotated,