This is an automated email from the ASF dual-hosted git repository.
gnodet 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 8629043381d0 CAMEL-23227: Enhance camel-datasonnet with utility
functions and standard library
8629043381d0 is described below
commit 8629043381d0245e14ca1b8f591488e2e2ce2138
Author: Guillaume Nodet <[email protected]>
AuthorDate: Mon Mar 23 09:19:52 2026 +0100
CAMEL-23227: Enhance camel-datasonnet with utility functions and standard
library
- Add null handling functions (defaultVal, isEmpty) to CML
- Add type coercion functions (toInteger, toDecimal, toBoolean)
- Add date/time functions (now, nowFmt, formatDate, parseDate)
- Add utility functions (uuid, typeOf)
- Add camel.libsonnet standard library with string, collection, and object
helpers
- Add comprehensive tests for all new functions
---
.../src/main/docs/datasonnet-language.adoc | 114 +++++++
.../org/apache/camel/language/datasonnet/CML.java | 250 +++++++++++++++-
.../src/main/resources/camel.libsonnet | 68 +++++
.../language/datasonnet/CamelLibsonnetTest.java | 269 +++++++++++++++++
.../language/datasonnet/CmlFunctionsTest.java | 333 +++++++++++++++++++++
5 files changed, 1025 insertions(+), 9 deletions(-)
diff --git a/components/camel-datasonnet/src/main/docs/datasonnet-language.adoc
b/components/camel-datasonnet/src/main/docs/datasonnet-language.adoc
index 27a3f74305aa..2d8bd5cd74ae 100644
--- a/components/camel-datasonnet/src/main/docs/datasonnet-language.adoc
+++ b/components/camel-datasonnet/src/main/docs/datasonnet-language.adoc
@@ -115,6 +115,120 @@ xref:ROOT:properties-component.adoc[Properties] component
(property placeholders
|cml.variable |the variable name |String |Will return the exchange variable.
|===
+=== Null Handling Functions
+
+[width="100%",cols="20%,30%,50%",options="header",]
+|===
+|Function |Example |Description
+
+|cml.defaultVal(value, fallback) |`cml.defaultVal(body.currency, 'USD')`
|Returns `value` if non-null, `fallback` otherwise.
+
+|cml.isEmpty(value) |`cml.isEmpty(body.name)` |Returns `true` if the value is
null, an empty string, or an empty array.
+|===
+
+=== Type Coercion Functions
+
+[width="100%",cols="20%,30%,50%",options="header",]
+|===
+|Function |Example |Description
+
+|cml.toInteger(value) |`cml.toInteger('42')` |Converts a string or number to
an integer. Returns null for null input.
+
+|cml.toDecimal(value) |`cml.toDecimal('3.14')` |Converts a string to a decimal
number. Returns null for null input.
+
+|cml.toBoolean(value) |`cml.toBoolean('true')` |Converts a string
(`true`/`false`/`yes`/`no`/`1`/`0`) or number to boolean. Returns null for null
input.
+|===
+
+=== Date/Time Functions
+
+[width="100%",cols="20%,30%,50%",options="header",]
+|===
+|Function |Example |Description
+
+|cml.now() |`cml.now()` |Returns the current timestamp as an ISO-8601 string.
+
+|cml.nowFmt(format) |`cml.nowFmt('yyyy-MM-dd')` |Returns the current UTC time
formatted with the given pattern.
+
+|cml.formatDate(value, format) |`cml.formatDate('2026-03-20T10:30:00Z',
'dd/MM/yyyy')` |Reformats an ISO-8601 date string using the given pattern.
Returns null for null input.
+
+|cml.parseDate(value, format) |`cml.parseDate('20/03/2026', 'dd/MM/yyyy')`
|Parses a date string into epoch milliseconds. Returns null for null input.
+|===
+
+=== Utility Functions
+
+[width="100%",cols="20%,30%,50%",options="header",]
+|===
+|Function |Example |Description
+
+|cml.uuid() |`cml.uuid()` |Generates a random UUID string.
+
+|cml.typeOf(value) |`cml.typeOf(body.x)` |Returns the type name: `string`,
`number`, `boolean`, `object`, `array`, `null`, or `function`.
+|===
+
+=== Camel Standard Library (`camel.libsonnet`)
+
+Camel ships a standard library of helper functions that can be imported in any
DataSonnet script:
+
+[source,java]
+----
+local c = import 'camel.libsonnet';
+{
+ total: c.sumBy(body.items, function(i) i.price * i.qty),
+ names: c.join(std.map(function(i) i.name, body.items), ', '),
+ topItem: c.first(c.sortBy(body.items, function(i) -i.price))
+}
+----
+
+==== String Helpers
+
+[width="100%",cols="30%,70%",options="header",]
+|===
+|Function |Description
+
+|`c.capitalize(s)` |Capitalizes the first letter.
+|`c.trim(s)` |Strips whitespace from both ends.
+|`c.split(s, sep)` |Splits a string by separator.
+|`c.join(arr, sep)` |Joins an array of strings with separator.
+|`c.contains(s, sub)` |Checks if string contains substring.
+|`c.startsWith(s, prefix)` |Checks if string starts with prefix.
+|`c.endsWith(s, suffix)` |Checks if string ends with suffix.
+|`c.replace(s, old, new)` |Replaces all occurrences.
+|`c.lower(s)` / `c.upper(s)` |Case conversion.
+|===
+
+==== Collection Helpers
+
+[width="100%",cols="30%,70%",options="header",]
+|===
+|Function |Description
+
+|`c.sum(arr)` |Sums all elements.
+|`c.sumBy(arr, f)` |Sums by applying function `f` to each element.
+|`c.first(arr)` / `c.last(arr)` |First/last element, or null if empty.
+|`c.count(arr)` |Array length.
+|`c.distinct(arr)` |Removes duplicates.
+|`c.flatMap(arr, f)` |Maps and flattens.
+|`c.sortBy(arr, f)` |Sorts by key function.
+|`c.groupBy(arr, f)` |Groups into object by key function.
+|`c.min(arr)` / `c.max(arr)` |Minimum/maximum element.
+|`c.take(arr, n)` / `c.drop(arr, n)` |First/last n elements.
+|`c.zip(a, b)` |Zips two arrays into pairs.
+|===
+
+==== Object Helpers
+
+[width="100%",cols="30%,70%",options="header",]
+|===
+|Function |Description
+
+|`c.pick(obj, keys)` |Selects only the listed keys.
+|`c.omit(obj, keys)` |Removes the listed keys.
+|`c.merge(a, b)` |Merges two objects (b overrides a).
+|`c.keys(obj)` / `c.values(obj)` |Object keys/values as arrays.
+|`c.entries(obj)` |Converts to `[{key, value}]` array.
+|`c.fromEntries(arr)` |Converts `[{key, value}]` array back to object.
+|===
+
Here's an example showing some of these functions in use:
[tabs]
diff --git
a/components/camel-datasonnet/src/main/java/org/apache/camel/language/datasonnet/CML.java
b/components/camel-datasonnet/src/main/java/org/apache/camel/language/datasonnet/CML.java
index 6c1db12711d9..50e2b2b5f9e6 100644
---
a/components/camel-datasonnet/src/main/java/org/apache/camel/language/datasonnet/CML.java
+++
b/components/camel-datasonnet/src/main/java/org/apache/camel/language/datasonnet/CML.java
@@ -16,10 +16,18 @@
*/
package org.apache.camel.language.datasonnet;
+import java.math.BigDecimal;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
+import java.util.UUID;
import com.datasonnet.document.DefaultDocument;
import com.datasonnet.document.Document;
@@ -60,29 +68,76 @@ public final class CML extends Library {
@Override
public Map<String, Val.Func> functions(DataFormatService dataFormats,
Header header) {
Map<String, Val.Func> answer = new HashMap<>();
+
+ // Existing Camel exchange access functions
answer.put("properties", makeSimpleFunc(
- Collections.singletonList("key"), //parameters list
+ Collections.singletonList("key"),
params -> properties(params.get(0))));
answer.put("header", makeSimpleFunc(
- Collections.singletonList("key"), //parameters list
+ Collections.singletonList("key"),
params -> header(params.get(0), dataFormats)));
answer.put("variable", makeSimpleFunc(
- Collections.singletonList("key"), //parameters list
+ Collections.singletonList("key"),
params -> variable(params.get(0), dataFormats)));
answer.put("exchangeProperty", makeSimpleFunc(
- Collections.singletonList("key"), //parameters list
+ Collections.singletonList("key"),
params -> exchangeProperty(params.get(0), dataFormats)));
+ // Null handling functions
+ answer.put("defaultVal", makeSimpleFunc(
+ Arrays.asList("value", "fallback"),
+ params -> defaultVal(params.get(0), params.get(1))));
+ answer.put("isEmpty", makeSimpleFunc(
+ Collections.singletonList("value"),
+ params -> isEmpty(params.get(0))));
+
+ // Type coercion functions
+ answer.put("toInteger", makeSimpleFunc(
+ Collections.singletonList("value"),
+ params -> toInteger(params.get(0))));
+ answer.put("toDecimal", makeSimpleFunc(
+ Collections.singletonList("value"),
+ params -> toDecimal(params.get(0))));
+ answer.put("toBoolean", makeSimpleFunc(
+ Collections.singletonList("value"),
+ params -> toBoolean(params.get(0))));
+
+ // Date/time functions
+ answer.put("now", makeSimpleFunc(
+ Collections.emptyList(),
+ params -> now()));
+ answer.put("nowFmt", makeSimpleFunc(
+ Collections.singletonList("format"),
+ params -> nowFmt(params.get(0))));
+ answer.put("formatDate", makeSimpleFunc(
+ Arrays.asList("value", "format"),
+ params -> formatDate(params.get(0), params.get(1))));
+ answer.put("parseDate", makeSimpleFunc(
+ Arrays.asList("value", "format"),
+ params -> parseDate(params.get(0), params.get(1))));
+
+ // Utility functions
+ answer.put("uuid", makeSimpleFunc(
+ Collections.emptyList(),
+ params -> uuid()));
+ answer.put("typeOf", makeSimpleFunc(
+ Collections.singletonList("value"),
+ params -> typeOf(params.get(0))));
+
return answer;
}
+ @Override
public Map<String, Val.Obj> modules(DataFormatService dataFormats, Header
header) {
return Collections.emptyMap();
}
+ // ---- Existing exchange access functions ----
+
private Val properties(Val key) {
- if (key instanceof Val.Str) {
- return new
Val.Str(exchange.get().getContext().resolvePropertyPlaceholders("{{" +
((Val.Str) key).value() + "}}"));
+ if (key instanceof Val.Str str) {
+ return new Val.Str(
+
exchange.get().getContext().resolvePropertyPlaceholders("{{" + str.value() +
"}}"));
}
throw new IllegalArgumentException("Expected String got: " +
key.prettyName());
}
@@ -108,12 +163,189 @@ public final class CML extends Library {
throw new IllegalArgumentException("Expected String got: " +
key.prettyName());
}
+ // ---- Null handling functions ----
+
+ private Val defaultVal(Val value, Val fallback) {
+ if (isNull(value)) {
+ return fallback;
+ }
+ return value;
+ }
+
+ private Val isEmpty(Val value) {
+ if (isNull(value)) {
+ return Val.True$.MODULE$;
+ }
+ if (value instanceof Val.Str str) {
+ return str.value().isEmpty() ? Val.True$.MODULE$ :
Val.False$.MODULE$;
+ }
+ if (value instanceof Val.Arr arr) {
+ return arr.value().isEmpty() ? Val.True$.MODULE$ :
Val.False$.MODULE$;
+ }
+ return Val.False$.MODULE$;
+ }
+
+ // ---- Type coercion functions ----
+
+ private Val toInteger(Val value) {
+ if (isNull(value)) {
+ return Val.Null$.MODULE$;
+ }
+ if (value instanceof Val.Num num) {
+ return new Val.Num((int) num.value());
+ }
+ if (value instanceof Val.Str str) {
+ return new Val.Num(Integer.parseInt(str.value().trim()));
+ }
+ if (value instanceof Val.Bool) {
+ return new Val.Num(value == Val.True$.MODULE$ ? 1 : 0);
+ }
+ throw new IllegalArgumentException("Cannot convert " +
value.prettyName() + " to integer");
+ }
+
+ private Val toDecimal(Val value) {
+ if (isNull(value)) {
+ return Val.Null$.MODULE$;
+ }
+ if (value instanceof Val.Num num) {
+ return value;
+ }
+ if (value instanceof Val.Str str) {
+ return new Val.Num(new
BigDecimal(str.value().trim()).doubleValue());
+ }
+ throw new IllegalArgumentException("Cannot convert " +
value.prettyName() + " to decimal");
+ }
+
+ private Val toBoolean(Val value) {
+ if (isNull(value)) {
+ return Val.Null$.MODULE$;
+ }
+ if (value instanceof Val.Bool) {
+ return value;
+ }
+ if (value instanceof Val.Str str) {
+ String s = str.value().trim().toLowerCase();
+ return switch (s) {
+ case "true", "1", "yes" -> Val.True$.MODULE$;
+ case "false", "0", "no" -> Val.False$.MODULE$;
+ default -> throw new IllegalArgumentException("Cannot convert
string '" + s + "' to boolean");
+ };
+ }
+ if (value instanceof Val.Num num) {
+ return num.value() != 0 ? Val.True$.MODULE$ : Val.False$.MODULE$;
+ }
+ throw new IllegalArgumentException("Cannot convert " +
value.prettyName() + " to boolean");
+ }
+
+ // ---- Date/time functions ----
+
+ private Val now() {
+ return new Val.Str(Instant.now().toString());
+ }
+
+ private Val nowFmt(Val format) {
+ if (!(format instanceof Val.Str str)) {
+ throw new IllegalArgumentException("Expected String format, got: "
+ format.prettyName());
+ }
+ DateTimeFormatter formatter = DateTimeFormatter.ofPattern(str.value());
+ return new
Val.Str(ZonedDateTime.now(ZoneId.of("UTC")).format(formatter));
+ }
+
+ private Val formatDate(Val value, Val format) {
+ if (isNull(value)) {
+ return Val.Null$.MODULE$;
+ }
+ if (!(value instanceof Val.Str valStr)) {
+ throw new IllegalArgumentException("Expected String date value,
got: " + value.prettyName());
+ }
+ if (!(format instanceof Val.Str fmtStr)) {
+ throw new IllegalArgumentException("Expected String format, got: "
+ format.prettyName());
+ }
+ ZonedDateTime dateTime = parseToZonedDateTime(valStr.value());
+ DateTimeFormatter formatter =
DateTimeFormatter.ofPattern(fmtStr.value());
+ return new Val.Str(dateTime.format(formatter));
+ }
+
+ private Val parseDate(Val value, Val format) {
+ if (isNull(value)) {
+ return Val.Null$.MODULE$;
+ }
+ if (!(value instanceof Val.Str valStr)) {
+ throw new IllegalArgumentException("Expected String date value,
got: " + value.prettyName());
+ }
+ if (!(format instanceof Val.Str fmtStr)) {
+ throw new IllegalArgumentException("Expected String format, got: "
+ format.prettyName());
+ }
+ DateTimeFormatter formatter =
DateTimeFormatter.ofPattern(fmtStr.value()).withZone(ZoneId.of("UTC"));
+ java.time.temporal.TemporalAccessor parsed =
formatter.parse(valStr.value());
+ Instant instant;
+ try {
+ instant = Instant.from(parsed);
+ } catch (java.time.DateTimeException e) {
+ // If the format has no time component, default to start of day
+ java.time.LocalDate date = java.time.LocalDate.from(parsed);
+ instant = date.atStartOfDay(ZoneId.of("UTC")).toInstant();
+ }
+ return new Val.Num(instant.toEpochMilli());
+ }
+
+ // ---- Utility functions ----
+
+ private Val uuid() {
+ return new Val.Str(UUID.randomUUID().toString());
+ }
+
+ private Val typeOf(Val value) {
+ if (isNull(value)) {
+ return new Val.Str("null");
+ }
+ if (value instanceof Val.Str) {
+ return new Val.Str("string");
+ }
+ if (value instanceof Val.Num) {
+ return new Val.Str("number");
+ }
+ if (value instanceof Val.Bool) {
+ return new Val.Str("boolean");
+ }
+ if (value instanceof Val.Arr) {
+ return new Val.Str("array");
+ }
+ if (value instanceof Val.Obj) {
+ return new Val.Str("object");
+ }
+ if (value instanceof Val.Func) {
+ return new Val.Str("function");
+ }
+ return new Val.Str("unknown");
+ }
+
+ // ---- Helper methods ----
+
+ private static boolean isNull(Val value) {
+ return value == null || value instanceof Val.Null$;
+ }
+
+ private static ZonedDateTime parseToZonedDateTime(String value) {
+ try {
+ return ZonedDateTime.parse(value);
+ } catch (DateTimeParseException e) {
+ // Try as instant
+ try {
+ return Instant.parse(value).atZone(ZoneId.of("UTC"));
+ } catch (DateTimeParseException e2) {
+ throw new IllegalArgumentException("Cannot parse date: " +
value, e2);
+ }
+ }
+ }
+
+ @SuppressWarnings("unchecked")
private Val valFrom(Object obj, DataFormatService dataformats) {
- Document doc;
- if (obj instanceof Document document) {
+ Document<?> doc;
+ if (obj instanceof Document<?> document) {
doc = document;
} else {
- doc = new DefaultDocument(obj, MediaTypes.APPLICATION_JAVA);
+ doc = new DefaultDocument<>(obj, MediaTypes.APPLICATION_JAVA);
}
try {
diff --git a/components/camel-datasonnet/src/main/resources/camel.libsonnet
b/components/camel-datasonnet/src/main/resources/camel.libsonnet
new file mode 100644
index 000000000000..9852088fd0b9
--- /dev/null
+++ b/components/camel-datasonnet/src/main/resources/camel.libsonnet
@@ -0,0 +1,68 @@
+/*
+ * 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.
+ */
+// Apache Camel standard library for DataSonnet
+// Auto-discovered from the classpath. Import with: local c = import
'camel.libsonnet';
+{
+ // String helpers
+ capitalize(s):: std.asciiUpper(s[0]) + s[1:],
+ trim(s):: std.stripChars(s, " \t\n\r"),
+ split(s, sep):: std.split(s, sep),
+ join(arr, sep):: std.join(sep, arr),
+ contains(s, sub):: std.length(std.findSubstr(sub, s)) > 0,
+ startsWith(s, prefix):: std.length(s) >= std.length(prefix) &&
s[:std.length(prefix)] == prefix,
+ endsWith(s, suffix):: std.length(s) >= std.length(suffix) && s[std.length(s)
- std.length(suffix):] == suffix,
+ replace(s, old, new):: std.strReplace(s, old, new),
+ lower(s):: std.asciiLower(s),
+ upper(s):: std.asciiUpper(s),
+
+ // Collection helpers
+ sum(arr):: std.foldl(function(acc, x) acc + x, arr, 0),
+ sumBy(arr, f):: std.foldl(function(acc, x) acc + f(x), arr, 0),
+ first(arr):: if std.length(arr) > 0 then arr[0] else null,
+ last(arr):: if std.length(arr) > 0 then arr[std.length(arr) - 1] else null,
+ count(arr):: std.length(arr),
+ distinct(arr):: std.foldl(
+ function(acc, x) if std.member(acc, x) then acc else acc + [x],
+ arr, []
+ ),
+ flatMap(arr, f):: std.flatMap(f, arr),
+ sortBy(arr, f):: std.sort(arr, keyF=f),
+ groupBy(arr, f):: std.foldl(
+ function(acc, x)
+ local k = f(x);
+ acc + (
+ if std.objectHas(acc, k)
+ then { [k]: acc[k] + [x] }
+ else { [k]: [x] }
+ ),
+ arr, {}
+ ),
+ min(arr):: std.foldl(function(acc, x) if acc == null || x < acc then x else
acc, arr, null),
+ max(arr):: std.foldl(function(acc, x) if acc == null || x > acc then x else
acc, arr, null),
+ zip(a, b):: std.mapWithIndex(function(i, x) [x, b[i]], a),
+ take(arr, n):: arr[:n],
+ drop(arr, n):: arr[n:],
+
+ // Object helpers
+ pick(obj, keys):: { [k]: obj[k] for k in keys if std.objectHas(obj, k) },
+ omit(obj, keys):: { [k]: obj[k] for k in std.objectFields(obj) if
!std.member(keys, k) },
+ merge(a, b):: a + b,
+ entries(obj):: [{ key: k, value: obj[k] } for k in std.objectFields(obj)],
+ fromEntries(arr):: { [e.key]: e.value for e in arr },
+ keys(obj):: std.objectFields(obj),
+ values(obj):: [obj[k] for k in std.objectFields(obj)],
+}
diff --git
a/components/camel-datasonnet/src/test/java/org/apache/camel/language/datasonnet/CamelLibsonnetTest.java
b/components/camel-datasonnet/src/test/java/org/apache/camel/language/datasonnet/CamelLibsonnetTest.java
new file mode 100644
index 000000000000..cea1d5669e75
--- /dev/null
+++
b/components/camel-datasonnet/src/test/java/org/apache/camel/language/datasonnet/CamelLibsonnetTest.java
@@ -0,0 +1,269 @@
+/*
+ * 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.datasonnet;
+
+import com.datasonnet.document.MediaTypes;
+import org.apache.camel.Exchange;
+import org.apache.camel.RoutesBuilder;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.Test;
+import org.skyscreamer.jsonassert.JSONAssert;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+public class CamelLibsonnetTest extends CamelTestSupport {
+
+ @Override
+ protected RoutesBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ // String helper tests
+ from("direct:capitalize")
+ .transform(datasonnet(
+ "local c = import 'camel.libsonnet';
c.capitalize('hello')",
+ String.class))
+ .to("mock:result");
+
+ from("direct:stringOps")
+ .transform(datasonnet(
+ "local c = import 'camel.libsonnet';"
+ + " { trimmed: c.trim(' hi '),"
+ + " parts: c.split('a,b,c',
','),"
+ + " joined:
c.join(['x','y','z'], '-'),"
+ + " has: c.contains('hello
world', 'world'),"
+ + " starts:
c.startsWith('hello', 'hel'),"
+ + " ends: c.endsWith('hello',
'llo'),"
+ + " replaced: c.replace('foo
bar foo', 'foo', 'baz'),"
+ + " low: c.lower('HELLO'),"
+ + " up: c.upper('hello')"
+ + " }",
+ String.class,
+ null, MediaTypes.APPLICATION_JSON_VALUE))
+ .to("mock:result");
+
+ // Collection helper tests
+ from("direct:collectionOps")
+ .transform(datasonnet(
+ "local c = import 'camel.libsonnet';"
+ + " { total: c.sum([1, 2, 3,
4]),"
+ + " totalBy: c.sumBy([{v: 10},
{v: 20}], function(x) x.v),"
+ + " head: c.first([5, 6, 7]),"
+ + " tail: c.last([5, 6, 7]),"
+ + " len: c.count([1, 2, 3]),"
+ + " unique: c.distinct([1, 2,
2, 3, 3, 3]),"
+ + " smallest: c.min([3, 1,
2]),"
+ + " biggest: c.max([3, 1, 2]),"
+ + " taken: c.take([1,2,3,4,5],
3),"
+ + " dropped:
c.drop([1,2,3,4,5], 2)"
+ + " }",
+ String.class,
+ null, MediaTypes.APPLICATION_JSON_VALUE))
+ .to("mock:result");
+
+ from("direct:firstEmpty")
+ .transform(datasonnet(
+ "local c = import 'camel.libsonnet';
c.first([])",
+ String.class))
+ .to("mock:result");
+
+ from("direct:groupBy")
+ .transform(datasonnet(
+ "local c = import 'camel.libsonnet';"
+ + "
c.groupBy([{t:'a',v:1},{t:'b',v:2},{t:'a',v:3}], function(x) x.t)",
+ String.class,
+ null, MediaTypes.APPLICATION_JSON_VALUE))
+ .to("mock:result");
+
+ from("direct:flatMap")
+ .transform(datasonnet(
+ "local c = import 'camel.libsonnet';"
+ + " c.flatMap([[1,2],[3,4]],
function(x) x)",
+ String.class,
+ null, MediaTypes.APPLICATION_JSON_VALUE))
+ .to("mock:result");
+
+ from("direct:sortBy")
+ .transform(datasonnet(
+ "local c = import 'camel.libsonnet';"
+ + "
c.sortBy([{n:3},{n:1},{n:2}], function(x) x.n)",
+ String.class,
+ null, MediaTypes.APPLICATION_JSON_VALUE))
+ .to("mock:result");
+
+ // Object helper tests
+ from("direct:objectOps")
+ .transform(datasonnet(
+ "local c = import 'camel.libsonnet';"
+ + " { picked: c.pick({a:1, b:2,
c:3}, ['a','c']),"
+ + " omitted: c.omit({a:1, b:2,
c:3}, ['b']),"
+ + " merged: c.merge({a:1},
{b:2}),"
+ + " k: c.keys({x:1, y:2}),"
+ + " v: c.values({x:1, y:2})"
+ + " }",
+ String.class,
+ null, MediaTypes.APPLICATION_JSON_VALUE))
+ .to("mock:result");
+
+ from("direct:entries")
+ .transform(datasonnet(
+ "local c = import 'camel.libsonnet';"
+ + " c.entries({a:1, b:2})",
+ String.class,
+ null, MediaTypes.APPLICATION_JSON_VALUE))
+ .to("mock:result");
+
+ from("direct:fromEntries")
+ .transform(datasonnet(
+ "local c = import 'camel.libsonnet';"
+ + "
c.fromEntries([{key:'a',value:1},{key:'b',value:2}])",
+ String.class,
+ null, MediaTypes.APPLICATION_JSON_VALUE))
+ .to("mock:result");
+
+ // Combined test with body data
+ from("direct:transformWithLib")
+ .transform(datasonnet(
+ "local c = import 'camel.libsonnet';"
+ + " { total: c.sumBy(body.items,
function(i) i.price * i.qty),"
+ + " names:
c.join(std.map(function(i) i.name, body.items), ', '),"
+ + " count: c.count(body.items)"
+ + " }",
+ String.class,
+ MediaTypes.APPLICATION_JSON_VALUE,
MediaTypes.APPLICATION_JSON_VALUE))
+ .to("mock:result");
+ }
+ };
+ }
+
+ @Test
+ public void testCapitalize() throws Exception {
+ assertEquals("Hello", sendAndGetString("direct:capitalize", ""));
+ }
+
+ @Test
+ public void testStringOps() throws Exception {
+ String result = sendAndGetString("direct:stringOps", "");
+ JSONAssert.assertEquals(
+ "{\"trimmed\":\"hi\","
+ + "\"parts\":[\"a\",\"b\",\"c\"],"
+ + "\"joined\":\"x-y-z\","
+ + "\"has\":true,"
+ + "\"starts\":true,"
+ + "\"ends\":true,"
+ + "\"replaced\":\"baz bar baz\","
+ + "\"low\":\"hello\","
+ + "\"up\":\"HELLO\"}",
+ result, false);
+ }
+
+ @Test
+ public void testCollectionOps() throws Exception {
+ String result = sendAndGetString("direct:collectionOps", "");
+ JSONAssert.assertEquals(
+ "{\"total\":10,"
+ + "\"totalBy\":30,"
+ + "\"head\":5,"
+ + "\"tail\":7,"
+ + "\"len\":3,"
+ + "\"unique\":[1,2,3],"
+ + "\"smallest\":1,"
+ + "\"biggest\":3,"
+ + "\"taken\":[1,2,3],"
+ + "\"dropped\":[3,4,5]}",
+ result, false);
+ }
+
+ @Test
+ public void testFirstEmpty() throws Exception {
+ Object result = sendAndGetBody("direct:firstEmpty", "");
+ // null result from first([])
+ assertEquals(null, result);
+ }
+
+ @Test
+ public void testGroupBy() throws Exception {
+ String result = sendAndGetString("direct:groupBy", "");
+ JSONAssert.assertEquals(
+
"{\"a\":[{\"t\":\"a\",\"v\":1},{\"t\":\"a\",\"v\":3}],\"b\":[{\"t\":\"b\",\"v\":2}]}",
+ result, false);
+ }
+
+ @Test
+ public void testFlatMap() throws Exception {
+ String result = sendAndGetString("direct:flatMap", "");
+ JSONAssert.assertEquals("[1,2,3,4]", result, false);
+ }
+
+ @Test
+ public void testSortBy() throws Exception {
+ String result = sendAndGetString("direct:sortBy", "");
+ JSONAssert.assertEquals("[{\"n\":1},{\"n\":2},{\"n\":3}]", result,
false);
+ }
+
+ @Test
+ public void testObjectOps() throws Exception {
+ String result = sendAndGetString("direct:objectOps", "");
+ JSONAssert.assertEquals(
+ "{\"picked\":{\"a\":1,\"c\":3},"
+ + "\"omitted\":{\"a\":1,\"c\":3},"
+ + "\"merged\":{\"a\":1,\"b\":2},"
+ + "\"k\":[\"x\",\"y\"],"
+ + "\"v\":[1,2]}",
+ result, false);
+ }
+
+ @Test
+ public void testEntries() throws Exception {
+ String result = sendAndGetString("direct:entries", "");
+ JSONAssert.assertEquals(
+ "[{\"key\":\"a\",\"value\":1},{\"key\":\"b\",\"value\":2}]",
+ result, false);
+ }
+
+ @Test
+ public void testFromEntries() throws Exception {
+ String result = sendAndGetString("direct:fromEntries", "");
+ JSONAssert.assertEquals("{\"a\":1,\"b\":2}", result, false);
+ }
+
+ @Test
+ public void testTransformWithLib() throws Exception {
+ String payload
+ =
"{\"items\":[{\"name\":\"Widget\",\"price\":10,\"qty\":3},{\"name\":\"Gadget\",\"price\":25,\"qty\":2}]}";
+ String result = sendAndGetString("direct:transformWithLib", payload);
+ JSONAssert.assertEquals(
+ "{\"total\":80,\"names\":\"Widget, Gadget\",\"count\":2}",
+ result, false);
+ }
+
+ private String sendAndGetString(String uri, Object body) {
+ template.sendBody(uri, body);
+ MockEndpoint mock = getMockEndpoint("mock:result");
+ Exchange exchange =
mock.assertExchangeReceived(mock.getReceivedCounter() - 1);
+ return exchange.getMessage().getBody(String.class);
+ }
+
+ private Object sendAndGetBody(String uri, Object body) {
+ template.sendBody(uri, body);
+ MockEndpoint mock = getMockEndpoint("mock:result");
+ Exchange exchange =
mock.assertExchangeReceived(mock.getReceivedCounter() - 1);
+ return exchange.getMessage().getBody();
+ }
+}
diff --git
a/components/camel-datasonnet/src/test/java/org/apache/camel/language/datasonnet/CmlFunctionsTest.java
b/components/camel-datasonnet/src/test/java/org/apache/camel/language/datasonnet/CmlFunctionsTest.java
new file mode 100644
index 000000000000..e435e35dd1aa
--- /dev/null
+++
b/components/camel-datasonnet/src/test/java/org/apache/camel/language/datasonnet/CmlFunctionsTest.java
@@ -0,0 +1,333 @@
+/*
+ * 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.datasonnet;
+
+import com.datasonnet.document.MediaTypes;
+import org.apache.camel.Exchange;
+import org.apache.camel.RoutesBuilder;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class CmlFunctionsTest extends CamelTestSupport {
+
+ @Override
+ protected RoutesBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ // Null handling routes
+ from("direct:defaultVal-null")
+ .transform(datasonnet("cml.defaultVal(null,
'fallback')", String.class))
+ .to("mock:result");
+ from("direct:defaultVal-present")
+ .transform(datasonnet("cml.defaultVal('hello',
'fallback')", String.class))
+ .to("mock:result");
+ from("direct:isEmpty-null")
+ .transform(datasonnet("cml.isEmpty(null)",
Boolean.class))
+ .to("mock:result");
+ from("direct:isEmpty-emptyString")
+ .transform(datasonnet("cml.isEmpty('')",
Boolean.class))
+ .to("mock:result");
+ from("direct:isEmpty-nonEmpty")
+ .transform(datasonnet("cml.isEmpty('hello')",
Boolean.class))
+ .to("mock:result");
+ from("direct:isEmpty-emptyArray")
+ .transform(datasonnet("cml.isEmpty([])",
Boolean.class))
+ .to("mock:result");
+
+ // Type coercion routes
+ from("direct:toInteger-string")
+ .transform(datasonnet("cml.toInteger('42')",
Integer.class))
+ .to("mock:result");
+ from("direct:toInteger-num")
+ .transform(datasonnet("cml.toInteger(3.7)",
Integer.class))
+ .to("mock:result");
+ from("direct:toInteger-null")
+ .transform(datasonnet("cml.toInteger(null)",
String.class))
+ .to("mock:result");
+ from("direct:toDecimal-string")
+ .transform(datasonnet("cml.toDecimal('3.14')",
Double.class))
+ .to("mock:result");
+ from("direct:toBoolean-true")
+ .transform(datasonnet("cml.toBoolean('true')",
Boolean.class))
+ .to("mock:result");
+ from("direct:toBoolean-yes")
+ .transform(datasonnet("cml.toBoolean('yes')",
Boolean.class))
+ .to("mock:result");
+ from("direct:toBoolean-false")
+ .transform(datasonnet("cml.toBoolean('false')",
Boolean.class))
+ .to("mock:result");
+ from("direct:toBoolean-zero")
+ .transform(datasonnet("cml.toBoolean('0')",
Boolean.class))
+ .to("mock:result");
+ from("direct:toBoolean-num")
+ .transform(datasonnet("cml.toBoolean(1)",
Boolean.class))
+ .to("mock:result");
+
+ // Date/time routes
+ from("direct:now")
+ .transform(datasonnet("cml.now()", String.class))
+ .to("mock:result");
+ from("direct:nowFmt")
+ .transform(datasonnet("cml.nowFmt('yyyy-MM-dd')",
String.class))
+ .to("mock:result");
+ from("direct:formatDate")
+
.transform(datasonnet("cml.formatDate('2026-03-20T10:30:00Z', 'dd/MM/yyyy')",
String.class))
+ .to("mock:result");
+ from("direct:parseDate")
+ .transform(datasonnet("cml.parseDate('20/03/2026',
'dd/MM/yyyy')", Double.class))
+ .to("mock:result");
+ from("direct:formatDate-null")
+ .transform(datasonnet("cml.formatDate(null,
'dd/MM/yyyy')", String.class))
+ .to("mock:result");
+
+ // Utility routes
+ from("direct:uuid")
+ .transform(datasonnet("cml.uuid()", String.class))
+ .to("mock:result");
+ from("direct:typeOf-string")
+ .transform(datasonnet("cml.typeOf('hello')",
String.class))
+ .to("mock:result");
+ from("direct:typeOf-number")
+ .transform(datasonnet("cml.typeOf(42)", String.class))
+ .to("mock:result");
+ from("direct:typeOf-boolean")
+ .transform(datasonnet("cml.typeOf(true)",
String.class))
+ .to("mock:result");
+ from("direct:typeOf-null")
+ .transform(datasonnet("cml.typeOf(null)",
String.class))
+ .to("mock:result");
+ from("direct:typeOf-array")
+ .transform(datasonnet("cml.typeOf([1,2,3])",
String.class))
+ .to("mock:result");
+ from("direct:typeOf-object")
+ .transform(datasonnet("cml.typeOf({a: 1})",
String.class))
+ .to("mock:result");
+
+ // Combined test: use body data with cml functions
+ from("direct:combined")
+ .transform(datasonnet(
+ "{ name: cml.defaultVal(body.name, 'unknown'),
active: cml.toBoolean(body.active) }",
+ String.class,
+ MediaTypes.APPLICATION_JSON_VALUE,
MediaTypes.APPLICATION_JSON_VALUE))
+ .to("mock:result");
+ }
+ };
+ }
+
+ // ---- Null handling tests ----
+
+ @Test
+ public void testDefaultValNull() throws Exception {
+ Object result = sendAndGetResult("direct:defaultVal-null", "");
+ assertEquals("fallback", result);
+ }
+
+ @Test
+ public void testDefaultValPresent() throws Exception {
+ Object result = sendAndGetResult("direct:defaultVal-present", "");
+ assertEquals("hello", result);
+ }
+
+ @Test
+ public void testIsEmptyNull() throws Exception {
+ Object result = sendAndGetResult("direct:isEmpty-null", "");
+ assertEquals(true, result);
+ }
+
+ @Test
+ public void testIsEmptyEmptyString() throws Exception {
+ Object result = sendAndGetResult("direct:isEmpty-emptyString", "");
+ assertEquals(true, result);
+ }
+
+ @Test
+ public void testIsEmptyNonEmpty() throws Exception {
+ Object result = sendAndGetResult("direct:isEmpty-nonEmpty", "");
+ assertEquals(false, result);
+ }
+
+ @Test
+ public void testIsEmptyEmptyArray() throws Exception {
+ Object result = sendAndGetResult("direct:isEmpty-emptyArray", "");
+ assertEquals(true, result);
+ }
+
+ // ---- Type coercion tests ----
+
+ @Test
+ public void testToIntegerString() throws Exception {
+ Object result = sendAndGetResult("direct:toInteger-string", "");
+ assertEquals(42, result);
+ }
+
+ @Test
+ public void testToIntegerNum() throws Exception {
+ Object result = sendAndGetResult("direct:toInteger-num", "");
+ assertEquals(3, result);
+ }
+
+ @Test
+ public void testToIntegerNull() throws Exception {
+ Object result = sendAndGetResult("direct:toInteger-null", "");
+ assertNull(result);
+ }
+
+ @Test
+ public void testToDecimalString() throws Exception {
+ Object result = sendAndGetResult("direct:toDecimal-string", "");
+ assertEquals(3.14, result);
+ }
+
+ @Test
+ public void testToBooleanTrue() throws Exception {
+ Object result = sendAndGetResult("direct:toBoolean-true", "");
+ assertEquals(true, result);
+ }
+
+ @Test
+ public void testToBooleanYes() throws Exception {
+ Object result = sendAndGetResult("direct:toBoolean-yes", "");
+ assertEquals(true, result);
+ }
+
+ @Test
+ public void testToBooleanFalse() throws Exception {
+ Object result = sendAndGetResult("direct:toBoolean-false", "");
+ assertEquals(false, result);
+ }
+
+ @Test
+ public void testToBooleanZero() throws Exception {
+ Object result = sendAndGetResult("direct:toBoolean-zero", "");
+ assertEquals(false, result);
+ }
+
+ @Test
+ public void testToBooleanNum() throws Exception {
+ Object result = sendAndGetResult("direct:toBoolean-num", "");
+ assertEquals(true, result);
+ }
+
+ // ---- Date/time tests ----
+
+ @Test
+ public void testNow() throws Exception {
+ Object result = sendAndGetResult("direct:now", "");
+ assertNotNull(result);
+ String str = result.toString();
+ // Should be ISO-8601 format
+ assertTrue(str.contains("T"), "Expected ISO-8601 format but got: " +
str);
+ }
+
+ @Test
+ public void testNowFmt() throws Exception {
+ Object result = sendAndGetResult("direct:nowFmt", "");
+ assertNotNull(result);
+ String str = result.toString();
+ // Should match yyyy-MM-dd format
+ assertTrue(str.matches("\\d{4}-\\d{2}-\\d{2}"), "Expected date format
but got: " + str);
+ }
+
+ @Test
+ public void testFormatDate() throws Exception {
+ Object result = sendAndGetResult("direct:formatDate", "");
+ assertEquals("20/03/2026", result);
+ }
+
+ @Test
+ public void testParseDate() throws Exception {
+ Object result = sendAndGetResult("direct:parseDate", "");
+ assertNotNull(result);
+ // 2026-03-20 00:00:00 UTC in epoch millis
+ assertTrue(((Number) result).longValue() > 0);
+ }
+
+ @Test
+ public void testFormatDateNull() throws Exception {
+ Object result = sendAndGetResult("direct:formatDate-null", "");
+ assertNull(result);
+ }
+
+ // ---- Utility tests ----
+
+ @Test
+ public void testUuid() throws Exception {
+ Object result = sendAndGetResult("direct:uuid", "");
+ assertNotNull(result);
+ String str = result.toString();
+ // UUID format: 8-4-4-4-12 hex digits
+
assertTrue(str.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"),
+ "Expected UUID format but got: " + str);
+ }
+
+ @Test
+ public void testTypeOfString() throws Exception {
+ assertEquals("string", sendAndGetResult("direct:typeOf-string", ""));
+ }
+
+ @Test
+ public void testTypeOfNumber() throws Exception {
+ assertEquals("number", sendAndGetResult("direct:typeOf-number", ""));
+ }
+
+ @Test
+ public void testTypeOfBoolean() throws Exception {
+ assertEquals("boolean", sendAndGetResult("direct:typeOf-boolean", ""));
+ }
+
+ @Test
+ public void testTypeOfNull() throws Exception {
+ assertEquals("null", sendAndGetResult("direct:typeOf-null", ""));
+ }
+
+ @Test
+ public void testTypeOfArray() throws Exception {
+ assertEquals("array", sendAndGetResult("direct:typeOf-array", ""));
+ }
+
+ @Test
+ public void testTypeOfObject() throws Exception {
+ assertEquals("object", sendAndGetResult("direct:typeOf-object", ""));
+ }
+
+ // ---- Combined tests ----
+
+ @Test
+ public void testCombined() throws Exception {
+ template.sendBody("direct:combined", "{\"name\": \"John\", \"active\":
\"true\"}");
+ MockEndpoint mock = getMockEndpoint("mock:result");
+ Exchange exchange =
mock.assertExchangeReceived(mock.getReceivedCounter() - 1);
+ String body = exchange.getMessage().getBody(String.class);
+ assertTrue(body.contains("\"name\":\"John\"") ||
body.contains("\"name\": \"John\""));
+ assertTrue(body.contains("\"active\":true") ||
body.contains("\"active\": true"));
+ }
+
+ private Object sendAndGetResult(String uri, Object body) {
+ template.sendBody(uri, body);
+ MockEndpoint mock = getMockEndpoint("mock:result");
+ Exchange exchange =
mock.assertExchangeReceived(mock.getReceivedCounter() - 1);
+ return exchange.getMessage().getBody();
+ }
+}