From f1a39e4b1d8c21968ab8bd746df63e72fd1cd7d5 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Fri, 20 Mar 2026 13:03:47 +0100 Subject: [PATCH 1/2] CAMEL-23222: Filter inbound Camel headers in MailHeaderFilterStrategy Add inbound filtering of Camel-prefixed headers to prevent external mail headers from injecting internal Camel headers (e.g. CamelFileName). The outbound filter was already in place; this adds the same protection for inbound messages. Co-Authored-By: Claude Opus 4.6 --- .../apache/camel/component/mail/MailHeaderFilterStrategy.java | 1 + 1 file changed, 1 insertion(+) diff --git a/components/camel-mail/src/main/java/org/apache/camel/component/mail/MailHeaderFilterStrategy.java b/components/camel-mail/src/main/java/org/apache/camel/component/mail/MailHeaderFilterStrategy.java index 7c35a9f95e610..659cdcfbb0391 100644 --- a/components/camel-mail/src/main/java/org/apache/camel/component/mail/MailHeaderFilterStrategy.java +++ b/components/camel-mail/src/main/java/org/apache/camel/component/mail/MailHeaderFilterStrategy.java @@ -28,6 +28,7 @@ protected void initialize() { setLowerCase(true); // filter headers begin with "Camel" or "org.apache.camel" setOutFilterStartsWith(CAMEL_FILTER_STARTS_WITH); + setInFilterStartsWith(CAMEL_FILTER_STARTS_WITH); } } From f62ba2e7d57a49870870e37f0d92fecb97da56da Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Fri, 20 Mar 2026 22:39:59 +0100 Subject: [PATCH 2/2] Enhance camel-datasonnet with utility functions and standard library Add new CML functions for null handling (defaultVal, isEmpty), type coercion (toInteger, toDecimal, toBoolean), date/time (now, nowFmt, formatDate, parseDate), and utilities (uuid, typeOf). Ship a camel.libsonnet standard library with string helpers (capitalize, trim, split, join, contains, startsWith, endsWith, replace, lower, upper), collection helpers (sum, sumBy, first, last, count, distinct, flatMap, sortBy, groupBy, min, max, take, drop, zip), and object helpers (pick, omit, merge, keys, values, entries, fromEntries). Update documentation with full reference for all new functions and the standard library. Co-Authored-By: Claude Opus 4.6 --- .../src/main/docs/datasonnet-language.adoc | 114 ++++++ .../apache/camel/language/datasonnet/CML.java | 250 ++++++++++++- .../src/main/resources/camel.libsonnet | 68 ++++ .../datasonnet/CamelLibsonnetTest.java | 269 ++++++++++++++ .../language/datasonnet/CmlFunctionsTest.java | 333 ++++++++++++++++++ 5 files changed, 1025 insertions(+), 9 deletions(-) create mode 100644 components/camel-datasonnet/src/main/resources/camel.libsonnet create mode 100644 components/camel-datasonnet/src/test/java/org/apache/camel/language/datasonnet/CamelLibsonnetTest.java create mode 100644 components/camel-datasonnet/src/test/java/org/apache/camel/language/datasonnet/CmlFunctionsTest.java diff --git a/components/camel-datasonnet/src/main/docs/datasonnet-language.adoc b/components/camel-datasonnet/src/main/docs/datasonnet-language.adoc index 27a3f74305aa8..2d8bd5cd74ae9 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 6c1db12711d9e..50e2b2b5f9e61 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 Set libsonnets() { @Override public Map functions(DataFormatService dataFormats, Header header) { Map 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 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 @@ private Val exchangeProperty(Val key, DataFormatService dataformats) { 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 0000000000000..9852088fd0b93 --- /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 0000000000000..cea1d5669e75a --- /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 0000000000000..e435e35dd1aa8 --- /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(); + } +}