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(); + } +} 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); } }