This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new b05ca5c23440 CAMEL-24873: camel-jbang - Custom simple functions in
Groovy with camel run --dev (#26678)
b05ca5c23440 is described below
commit b05ca5c23440258641ded078be0c020e71da049e
Author: Claus Ibsen <[email protected]>
AuthorDate: Mon Sep 21 21:12:59 2026 +0200
CAMEL-24873: camel-jbang - Custom simple functions in Groovy with camel run
--dev (#26678)
* CAMEL-24873: camel-jbang - Custom simple functions in Groovy with camel
run --dev
A custom SimpleFunction written in Groovy did not work end to end with the
Camel CLI, and an edited function bean was not picked up in dev mode:
- camel run adds camel-groovy when a .groovy file is given, so the file is
compiled without --dep=camel-groovy (before it was silently ignored)
- the Groovy script compiler runs the registered CompilePostProcessors on a
compiled class with class-level annotations, with a new instance, as the
Java DSL loader does for .java sources; in the CLI this binds a
@BindToRegistry class (and handles @Converter and the Spring/Quarkus
annotations), and binds it again on a reload in dev mode. Plain classes
and scripts are not instantiated.
- in the dev profile the simple language resolves a custom function backed
by a SimpleFunction bean again per evaluation, so an edited bean (reloaded
.groovy file or YAML bean) takes effect; the expression cache kept the
first bean until a restart before.
Documented a Groovy custom function (as a .groovy file and as an inline
YAML groovy bean) in simple-advanced and the camel-jbang beans page.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Signed-off-by: Claus Ibsen <[email protected]>
* CAMEL-24873: Address review feedback
- close the pre-compiled classloader in a finally when a post-processor
fails
- recognise camel:groovy:x.y.z and mvn:org.apache.camel:camel-groovy in the
camel run auto-add
- document that the .groovy annotation binding is done by the Camel CLI;
exported projects use the inline YAML bean or Java (follow-up CAMEL-24879)
- drop the dev-profile bug-fix paragraph from the 4.23 upgrade guide
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Signed-off-by: Claus Ibsen <[email protected]>
---------
Signed-off-by: Claus Ibsen <[email protected]>
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
.../apache/camel/catalog/docs/groovy-language.adoc | 6 ++
.../apache/camel/catalog/docs/simple-advanced.adoc | 102 ++++++++++++++++++++
.../src/main/docs/groovy-language.adoc | 6 ++
.../groovy/DefaultGroovyScriptCompiler.java | 65 +++++++++++--
.../groovy/GroovyCompilePostProcessorTest.java | 89 ++++++++++++++++++
.../MaskEmailFunction.groovy | 34 +++++++
.../resources/camel-groovy-annotated/Plain.groovy | 24 +++++
.../modules/languages/pages/simple-advanced.adoc | 102 ++++++++++++++++++++
.../language/simple/MiscExpressionBuilder.java | 18 +++-
.../simple/SimpleCustomFunctionDevReloadTest.java | 104 +++++++++++++++++++++
.../ROOT/pages/camel-4x-upgrade-guide-4_23.adoc | 10 ++
.../modules/ROOT/pages/camel-jbang-beans.adoc | 43 +++++++++
.../apache/camel/dsl/jbang/core/commands/Run.java | 5 +
13 files changed, 595 insertions(+), 13 deletions(-)
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/groovy-language.adoc
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/groovy-language.adoc
index 6dc429ec1ee3..6806dcf3ad4a 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/groovy-language.adoc
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/groovy-language.adoc
@@ -192,6 +192,12 @@ with Camel features that would support this such as in the
route DSL and elsewhe
However, there may be some features in Camel where this may not work (yet).
+A compiled class with class-level annotations is handed to the registered
`org.apache.camel.spi.CompilePostProcessor`s
+with a new instance, as the Java DSL does for `.java` sources. With Camel CLI
(`camel run`) that binds a
+`@BindToRegistry` class in the Registry and registers a `@Converter` class as
type converters (the Spring
+and Quarkus annotations are supported as well), so a Groovy source can provide
a bean such as a custom
+xref:languages:simple-advanced.adoc[simple function]. Plain classes without
annotations are not instantiated.
+
IMPORTANT: This feature is only intended to include smaller groovy sources as
small functions, DTOs
that makes it easier to use together with Camel for low-code integrations. It
is not
intended to support Groovy as a general purpose programming language for
Camel. For this kind
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/simple-advanced.adoc
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/simple-advanced.adoc
index 8925f33c254f..626317f7bac2 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/simple-advanced.adoc
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/simple-advanced.adoc
@@ -956,6 +956,108 @@ TIP: The custom function can then be made discoverable by
Camel by dependency in
If you use standalone Camel you can add `@BindToRegistry("foo-function")` to
the class.
For Spring Boot use `@Component` or `@Service` and Quarkus you can for example
use `@ApplicationScoped`.
+=== Custom functions in Groovy with Camel CLI
+
+With the xref:manual::camel-jbang.adoc[Camel CLI] the function does not have
to be Java: the same class can be a
+Groovy source file next to the route, and Camel CLI compiles it and honours
`@BindToRegistry`.
+
+._MaskEmailFunction.groovy_
+[source,groovy]
+----
+import org.apache.camel.BindToRegistry
+import org.apache.camel.Exchange
+import org.apache.camel.spi.SimpleFunction
+
+@BindToRegistry("mask-email-function")
+class MaskEmailFunction implements SimpleFunction {
+
+ String getName() { 'maskEmail' }
+
+ Object apply(Exchange exchange, Object input) {
+ def email = input.toString().trim()
+ int at = email.indexOf('@')
+ if (at <= 0 || at == email.length() - 1) return '***'
+ return email[0] + '***' + email.substring(at)
+ }
+}
+----
+
+The name returned by `getName()` is the function name in simple, the value of
`@BindToRegistry` is only the bean id:
+
+[source,yaml]
+----
+- route:
+ from:
+ uri: timer:demo
+ parameters:
+ repeatCount: 1
+ steps:
+ - setHeader:
+ name: customerEmail
+ expression:
+ constant:
+ expression: "[email protected]"
+ - setBody:
+ expression:
+ simple:
+ expression: "Contact: ${maskEmail(${header.customerEmail})}"
+ - log:
+ message: "${body}"
+----
+
+Run both files together (no `--dep` is needed, Camel CLI adds `camel-groovy`
when a `.groovy` file is given):
+
+[source,bash]
+----
+camel run route.camel.yaml MaskEmailFunction.groovy --dev
+----
+
+The function can also be a bean in the YAML file itself, created by a Groovy
script, so the whole example is one file.
+A Groovy map with the two methods of `SimpleFunction` is coerced to the
interface with `as SimpleFunction`:
+
+[source,yaml]
+----
+- beans:
+ - name: mask-email-function
+ scriptLanguage: groovy
+ script: |
+ import org.apache.camel.spi.SimpleFunction
+ return [
+ getName: { 'maskEmail' },
+ apply: { exchange, input ->
+ def email = input.toString().trim()
+ int at = email.indexOf('@')
+ if (at <= 0 || at == email.length() - 1) return '***'
+ return email[0] + '***' + email.substring(at)
+ }
+ ] as SimpleFunction
+- route:
+ from:
+ uri: timer:demo
+ parameters:
+ repeatCount: 1
+ steps:
+ - setHeader:
+ name: customerEmail
+ expression:
+ constant:
+ expression: "[email protected]"
+ - setBody:
+ expression:
+ simple:
+ expression: "Contact: ${maskEmail(${header.customerEmail})}"
+ - log:
+ message: "${body}"
+----
+
+In dev mode (`--dev`) an edit of the function, in the `.groovy` file or in the
YAML bean, is picked up on the next
+message: the bean is created again on reload and the simple expression
resolves the function again per evaluation
+when the `dev` profile is active.
+
+NOTE: The `@BindToRegistry` binding of a `.groovy` file is done by the Camel
CLI. In a project created with
+`camel export` the file is compiled, but the class is not bound as a bean, so
the function is unknown. For a project
+that is exported, use the inline YAML bean shown above, which works in every
runtime, or write the function in Java.
+
== JavaScript Validator
diff --git a/components/camel-groovy/src/main/docs/groovy-language.adoc
b/components/camel-groovy/src/main/docs/groovy-language.adoc
index 6dc429ec1ee3..6806dcf3ad4a 100644
--- a/components/camel-groovy/src/main/docs/groovy-language.adoc
+++ b/components/camel-groovy/src/main/docs/groovy-language.adoc
@@ -192,6 +192,12 @@ with Camel features that would support this such as in the
route DSL and elsewhe
However, there may be some features in Camel where this may not work (yet).
+A compiled class with class-level annotations is handed to the registered
`org.apache.camel.spi.CompilePostProcessor`s
+with a new instance, as the Java DSL does for `.java` sources. With Camel CLI
(`camel run`) that binds a
+`@BindToRegistry` class in the Registry and registers a `@Converter` class as
type converters (the Spring
+and Quarkus annotations are supported as well), so a Groovy source can provide
a bean such as a custom
+xref:languages:simple-advanced.adoc[simple function]. Plain classes without
annotations are not instantiated.
+
IMPORTANT: This feature is only intended to include smaller groovy sources as
small functions, DTOs
that makes it easier to use together with Camel for low-code integrations. It
is not
intended to support Groovy as a general purpose programming language for
Camel. For this kind
diff --git
a/components/camel-groovy/src/main/java/org/apache/camel/language/groovy/DefaultGroovyScriptCompiler.java
b/components/camel-groovy/src/main/java/org/apache/camel/language/groovy/DefaultGroovyScriptCompiler.java
index 9018cb75c97a..6d493c05ec1d 100644
---
a/components/camel-groovy/src/main/java/org/apache/camel/language/groovy/DefaultGroovyScriptCompiler.java
+++
b/components/camel-groovy/src/main/java/org/apache/camel/language/groovy/DefaultGroovyScriptCompiler.java
@@ -17,6 +17,7 @@
package org.apache.camel.language.groovy;
import java.io.File;
+import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
@@ -26,14 +27,18 @@ import java.util.Map;
import java.util.Set;
import groovy.lang.GroovyShell;
+import groovy.lang.Script;
+import org.apache.camel.BindToRegistry;
import org.apache.camel.CamelContext;
import org.apache.camel.CamelContextAware;
import org.apache.camel.Ordered;
+import org.apache.camel.RuntimeCamelException;
import org.apache.camel.StaticService;
import org.apache.camel.api.management.ManagedAttribute;
import org.apache.camel.api.management.ManagedOperation;
import org.apache.camel.api.management.ManagedResource;
import org.apache.camel.spi.CamelEvent;
+import org.apache.camel.spi.CompilePostProcessor;
import org.apache.camel.spi.CompileStrategy;
import org.apache.camel.spi.EventNotifier;
import org.apache.camel.spi.GroovyScriptCompiler;
@@ -45,6 +50,7 @@ import org.apache.camel.support.SimpleEventNotifierSupport;
import org.apache.camel.support.service.ServiceSupport;
import org.apache.camel.util.FileUtil;
import org.apache.camel.util.IOHelper;
+import org.apache.camel.util.ObjectHelper;
import org.apache.camel.util.StopWatch;
import org.apache.camel.util.StringHelper;
import org.codehaus.groovy.control.CompilationFailedException;
@@ -297,18 +303,25 @@ public class DefaultGroovyScriptCompiler extends
ServiceSupport
private Set<String> doPreloadClasses(Map<String, byte[]> classes) {
Set<String> answer = new HashSet<>();
- for (var entry : classes.entrySet()) {
- String name = entry.getKey();
- groovyPreCompiledClassLoader.addClass(name, entry.getValue());
- try {
- Class<?> clazz = groovyPreCompiledClassLoader.findClass(name);
- classLoader.addClass(clazz.getName(), clazz);
- answer.add(name);
- } catch (ClassNotFoundException e) {
- LOG.debug("Error loading pre-compiled class: {}. This
exception is ignored.", name, e);
+ try {
+ for (var entry : classes.entrySet()) {
+ String name = entry.getKey();
+ groovyPreCompiledClassLoader.addClass(name, entry.getValue());
+ try {
+ Class<?> clazz =
groovyPreCompiledClassLoader.findClass(name);
+ classLoader.addClass(clazz.getName(), clazz);
+ postCompile(clazz, entry.getValue());
+ answer.add(name);
+ } catch (ClassNotFoundException e) {
+ LOG.debug("Error loading pre-compiled class: {}. This
exception is ignored.", name, e);
+ } catch (Exception e) {
+ throw RuntimeCamelException.wrapRuntimeException(e);
+ }
}
+ } finally {
+ // also close if a post-processor failed on one of the classes
+ IOHelper.close(groovyPreCompiledClassLoader);
}
- IOHelper.close(groovyPreCompiledClassLoader);
preloadCounter = answer.size();
return answer;
}
@@ -353,12 +366,44 @@ public class DefaultGroovyScriptCompiler extends
ServiceSupport
// remove before adding in case it's recompiled
classLoader.removeClass(name);
classLoader.addClass(name, clazz);
+ postCompile(clazz, null);
}
}
taken += watch.taken();
last = System.currentTimeMillis();
}
+ /**
+ * Runs the registered {@link CompilePostProcessor}s on a compiled class,
as the Java DSL loader does for
+ * {@code .java} sources, so annotations such as {@link BindToRegistry}
and {@link org.apache.camel.Converter} (and
+ * the Spring and Quarkus equivalents camel-jbang registers) work in
Groovy sources as well. On a recompile (live
+ * reload) the bean is created and bound again, replacing the previous one.
+ */
+ private void postCompile(Class<?> clazz, byte[] byteCode) throws Exception
{
+ Set<CompilePostProcessor> posts =
camelContext.getRegistry().findByType(CompilePostProcessor.class);
+ if (posts == null || posts.isEmpty()) {
+ return;
+ }
+ // only annotated classes are instantiated: a plain groovy class or
script is a DTO or a
+ // function library, and creating it here would only run its
constructor for nothing
+ if (clazz.getAnnotations().length == 0 ||
Script.class.isAssignableFrom(clazz)) {
+ return;
+ }
+ Object instance = null;
+ BindToRegistry bir = clazz.getAnnotation(BindToRegistry.class);
+ boolean skip = clazz.isInterface() ||
Modifier.isAbstract(clazz.getModifiers())
+ || Modifier.isPrivate(clazz.getModifiers()) || (bir != null &&
bir.lazy());
+ if (!skip && ObjectHelper.hasDefaultNoArgConstructor(clazz)) {
+ instance = camelContext.getInjector().newInstance(clazz, false);
+ if (instance != null) {
+ CamelContextAware.trySetCamelContext(instance, camelContext);
+ }
+ }
+ for (CompilePostProcessor post : posts) {
+ post.postCompile(camelContext, clazz.getName(), clazz, byteCode,
instance);
+ }
+ }
+
@Override
protected void doStop() throws Exception {
super.doStop();
diff --git
a/components/camel-groovy/src/test/java/org/apache/camel/language/groovy/GroovyCompilePostProcessorTest.java
b/components/camel-groovy/src/test/java/org/apache/camel/language/groovy/GroovyCompilePostProcessorTest.java
new file mode 100644
index 000000000000..75441ff5eb69
--- /dev/null
+++
b/components/camel-groovy/src/test/java/org/apache/camel/language/groovy/GroovyCompilePostProcessorTest.java
@@ -0,0 +1,89 @@
+/*
+ * 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.groovy;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.apache.camel.BindToRegistry;
+import org.apache.camel.CamelContext;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.spi.CompilePostProcessor;
+import org.apache.camel.spi.Registry;
+import org.apache.camel.spi.SimpleFunction;
+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.assertInstanceOf;
+
+/**
+ * The registered compile post-processors see a compiled Groovy class with an
instance of it, as they see a compiled
+ * Java source, so a {@code @BindToRegistry} class in a groovy file (such as a
custom simple function) is bound the way
+ * it is in a java file. A plain class is not instantiated.
+ */
+public class GroovyCompilePostProcessorTest extends CamelTestSupport {
+
+ private final List<String> seen = new ArrayList<>();
+
+ @Override
+ protected CamelContext createCamelContext() throws Exception {
+ CamelContext context = super.createCamelContext();
+
+ DefaultGroovyScriptCompiler compiler = new
DefaultGroovyScriptCompiler();
+ compiler.setCamelContext(context);
+
compiler.setScriptPattern("file:src/test/resources/camel-groovy-annotated/*");
+ context.addService(compiler);
+
+ return context;
+ }
+
+ @Override
+ protected void bindToRegistry(Registry registry) {
+ // the camel-jbang runtime registers a post-processor that binds
@BindToRegistry classes
+ registry.bind("myPostProcessor", (CompilePostProcessor) (camelContext,
name, clazz, byteCode, instance) -> {
+ seen.add(name);
+ BindToRegistry bir = clazz.getAnnotation(BindToRegistry.class);
+ if (bir != null) {
+ camelContext.getRegistry().bind(bir.value(), instance);
+ }
+ });
+ }
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ from("direct:start")
+ .setBody().simple("${maskEmail(${body})}")
+ .to("mock:result");
+ }
+ };
+ }
+
+ @Test
+ public void testAnnotatedClassIsBound() throws Exception {
+ assertEquals(List.of("MaskEmailFunction"), seen);
+ assertInstanceOf(SimpleFunction.class,
context.getRegistry().lookupByName("mask-email-function"));
+
+
getMockEndpoint("mock:result").expectedBodiesReceived("j***@example.com");
+ template.sendBody("direct:start", "[email protected]");
+ MockEndpoint.assertIsSatisfied(context);
+ }
+}
diff --git
a/components/camel-groovy/src/test/resources/camel-groovy-annotated/MaskEmailFunction.groovy
b/components/camel-groovy/src/test/resources/camel-groovy-annotated/MaskEmailFunction.groovy
new file mode 100644
index 000000000000..c9e578c534fa
--- /dev/null
+++
b/components/camel-groovy/src/test/resources/camel-groovy-annotated/MaskEmailFunction.groovy
@@ -0,0 +1,34 @@
+/*
+ * 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.
+ */
+
+import org.apache.camel.BindToRegistry
+import org.apache.camel.Exchange
+import org.apache.camel.spi.SimpleFunction
+
+@BindToRegistry("mask-email-function")
+class MaskEmailFunction implements SimpleFunction {
+
+ String getName() { 'maskEmail' }
+
+ Object apply(Exchange exchange, Object input) {
+ def email = input.toString().trim()
+ int at = email.indexOf('@')
+ if (at <= 0 || at == email.length() - 1) return '***'
+ return email[0] + '***' + email.substring(at)
+ }
+
+}
diff --git
a/components/camel-groovy/src/test/resources/camel-groovy-annotated/Plain.groovy
b/components/camel-groovy/src/test/resources/camel-groovy-annotated/Plain.groovy
new file mode 100644
index 000000000000..27239a9a68e1
--- /dev/null
+++
b/components/camel-groovy/src/test/resources/camel-groovy-annotated/Plain.groovy
@@ -0,0 +1,24 @@
+/*
+ * 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.
+ */
+
+class Plain {
+
+ Plain() {
+ throw new IllegalStateException("a plain groovy class is not instantiated
after compiling")
+ }
+
+}
diff --git
a/core/camel-core-languages/src/main/docs/modules/languages/pages/simple-advanced.adoc
b/core/camel-core-languages/src/main/docs/modules/languages/pages/simple-advanced.adoc
index 8925f33c254f..626317f7bac2 100644
---
a/core/camel-core-languages/src/main/docs/modules/languages/pages/simple-advanced.adoc
+++
b/core/camel-core-languages/src/main/docs/modules/languages/pages/simple-advanced.adoc
@@ -956,6 +956,108 @@ TIP: The custom function can then be made discoverable by
Camel by dependency in
If you use standalone Camel you can add `@BindToRegistry("foo-function")` to
the class.
For Spring Boot use `@Component` or `@Service` and Quarkus you can for example
use `@ApplicationScoped`.
+=== Custom functions in Groovy with Camel CLI
+
+With the xref:manual::camel-jbang.adoc[Camel CLI] the function does not have
to be Java: the same class can be a
+Groovy source file next to the route, and Camel CLI compiles it and honours
`@BindToRegistry`.
+
+._MaskEmailFunction.groovy_
+[source,groovy]
+----
+import org.apache.camel.BindToRegistry
+import org.apache.camel.Exchange
+import org.apache.camel.spi.SimpleFunction
+
+@BindToRegistry("mask-email-function")
+class MaskEmailFunction implements SimpleFunction {
+
+ String getName() { 'maskEmail' }
+
+ Object apply(Exchange exchange, Object input) {
+ def email = input.toString().trim()
+ int at = email.indexOf('@')
+ if (at <= 0 || at == email.length() - 1) return '***'
+ return email[0] + '***' + email.substring(at)
+ }
+}
+----
+
+The name returned by `getName()` is the function name in simple, the value of
`@BindToRegistry` is only the bean id:
+
+[source,yaml]
+----
+- route:
+ from:
+ uri: timer:demo
+ parameters:
+ repeatCount: 1
+ steps:
+ - setHeader:
+ name: customerEmail
+ expression:
+ constant:
+ expression: "[email protected]"
+ - setBody:
+ expression:
+ simple:
+ expression: "Contact: ${maskEmail(${header.customerEmail})}"
+ - log:
+ message: "${body}"
+----
+
+Run both files together (no `--dep` is needed, Camel CLI adds `camel-groovy`
when a `.groovy` file is given):
+
+[source,bash]
+----
+camel run route.camel.yaml MaskEmailFunction.groovy --dev
+----
+
+The function can also be a bean in the YAML file itself, created by a Groovy
script, so the whole example is one file.
+A Groovy map with the two methods of `SimpleFunction` is coerced to the
interface with `as SimpleFunction`:
+
+[source,yaml]
+----
+- beans:
+ - name: mask-email-function
+ scriptLanguage: groovy
+ script: |
+ import org.apache.camel.spi.SimpleFunction
+ return [
+ getName: { 'maskEmail' },
+ apply: { exchange, input ->
+ def email = input.toString().trim()
+ int at = email.indexOf('@')
+ if (at <= 0 || at == email.length() - 1) return '***'
+ return email[0] + '***' + email.substring(at)
+ }
+ ] as SimpleFunction
+- route:
+ from:
+ uri: timer:demo
+ parameters:
+ repeatCount: 1
+ steps:
+ - setHeader:
+ name: customerEmail
+ expression:
+ constant:
+ expression: "[email protected]"
+ - setBody:
+ expression:
+ simple:
+ expression: "Contact: ${maskEmail(${header.customerEmail})}"
+ - log:
+ message: "${body}"
+----
+
+In dev mode (`--dev`) an edit of the function, in the `.groovy` file or in the
YAML bean, is picked up on the next
+message: the bean is created again on reload and the simple expression
resolves the function again per evaluation
+when the `dev` profile is active.
+
+NOTE: The `@BindToRegistry` binding of a `.groovy` file is done by the Camel
CLI. In a project created with
+`camel export` the file is compiled, but the class is not bound as a bean, so
the function is unknown. For a project
+that is exported, use the inline YAML bean shown above, which works in every
runtime, or write the function in Java.
+
== JavaScript Validator
diff --git
a/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/MiscExpressionBuilder.java
b/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/MiscExpressionBuilder.java
index 6114be621b1b..439a8cc7cb6f 100644
---
a/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/MiscExpressionBuilder.java
+++
b/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/MiscExpressionBuilder.java
@@ -1024,29 +1024,41 @@ public final class MiscExpressionBuilder {
*/
public static Expression customFunction(final String name, final String
parameter) {
return new ExpressionAdapter() {
+ private SimpleFunctionRegistry registry;
private Expression func;
private Expression exp;
+ private boolean dev;
@Override
public void init(CamelContext context) {
super.init(context);
- SimpleFunctionRegistry registry
- =
context.getCamelContextExtension().getContextPlugin(SimpleFunctionRegistry.class);
+ registry =
context.getCamelContextExtension().getContextPlugin(SimpleFunctionRegistry.class);
func = registry.getFunction(name);
if (func == null) {
throw new IllegalArgumentException("No custom simple
function with name: " + name);
}
+ // the simple language caches parsed expressions, so a route
reload reuses this adapter;
+ // in dev profile the function is resolved again per
evaluation so an edited
+ // SimpleFunction bean (live reload) takes effect
+ dev =
"dev".equals(context.getCamelContextExtension().getProfile());
exp = ExpressionBuilder.simpleExpression(parameter);
exp.init(context);
}
@Override
public Object evaluate(Exchange exchange) {
+ Expression target = func;
+ if (dev) {
+ Expression latest = registry.getFunction(name);
+ if (latest != null) {
+ target = latest;
+ }
+ }
final Object originalBody = exchange.getMessage().getBody();
try {
Object input = exp.evaluate(exchange, Object.class);
exchange.getMessage().setBody(input);
- return func.evaluate(exchange, Object.class);
+ return target.evaluate(exchange, Object.class);
} finally {
exchange.getMessage().setBody(originalBody);
}
diff --git
a/core/camel-core/src/test/java/org/apache/camel/language/simple/SimpleCustomFunctionDevReloadTest.java
b/core/camel-core/src/test/java/org/apache/camel/language/simple/SimpleCustomFunctionDevReloadTest.java
new file mode 100644
index 000000000000..d56682e8f178
--- /dev/null
+++
b/core/camel-core/src/test/java/org/apache/camel/language/simple/SimpleCustomFunctionDevReloadTest.java
@@ -0,0 +1,104 @@
+/*
+ * 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.simple;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.ContextTestSupport;
+import org.apache.camel.Exchange;
+import org.apache.camel.RoutesBuilder;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.spi.SimpleFunction;
+import org.junit.jupiter.api.Test;
+
+/**
+ * In the dev profile (camel run --dev) a custom simple function backed by a
{@link SimpleFunction} bean in the registry
+ * is resolved again per evaluation, so an edited bean takes effect on live
reload even though the simple language
+ * caches the parsed expression.
+ */
+public class SimpleCustomFunctionDevReloadTest extends ContextTestSupport {
+
+ @Override
+ protected CamelContext createCamelContext() throws Exception {
+ CamelContext answer = super.createCamelContext();
+ answer.getCamelContextExtension().setProfile("dev");
+ return answer;
+ }
+
+ @Test
+ public void testFunctionBeanReplaced() throws Exception {
+ getMockEndpoint("mock:result").expectedBodiesReceived("Hello World",
"Bye World");
+
+ template.sendBody("direct:start", "World");
+
+ // simulate a live reload that re-creates the bean with new behaviour
+ context.getRegistry().unbind("greet");
+ context.getRegistry().bind("greet", new GreetFunction("Bye"));
+
+ template.sendBody("direct:start", "World");
+ assertMockEndpointsSatisfied();
+ }
+
+ @Test
+ public void testFunctionBeanReplacedInChain() throws Exception {
+ getMockEndpoint("mock:result").expectedBodiesReceived("Hello Earth",
"Bye Earth");
+
+ template.sendBody("direct:chain", "Earth");
+
+ context.getRegistry().unbind("greet");
+ context.getRegistry().bind("greet", new GreetFunction("Bye"));
+
+ template.sendBody("direct:chain", "Earth");
+ assertMockEndpointsSatisfied();
+ }
+
+ @Override
+ protected RoutesBuilder createRouteBuilder() throws Exception {
+ return new RouteBuilder() {
+ @Override
+ public void configure() throws Exception {
+ context.getRegistry().bind("greet", new
GreetFunction("Hello"));
+
+ from("direct:start")
+ .setBody(simple("${greet(${body})}"))
+ .to("mock:result");
+
+ from("direct:chain")
+ .setBody(simple("${body} ~> ${greet}"))
+ .to("mock:result");
+ }
+ };
+ }
+
+ private static class GreetFunction implements SimpleFunction {
+
+ private final String greeting;
+
+ private GreetFunction(String greeting) {
+ this.greeting = greeting;
+ }
+
+ @Override
+ public String getName() {
+ return "greet";
+ }
+
+ @Override
+ public Object apply(Exchange exchange, Object input) {
+ return greeting + " " + input;
+ }
+ }
+}
diff --git
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
index 21b7ace4f84b..4476b9a1e99c 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
@@ -144,6 +144,12 @@ script languages and the Camel 4 API use; `request` and
`in` stay as its older n
provided its own global variable named `message` is now hidden by the exchange
variable, like the other exchange
variable names.
+The Groovy script compiler now runs the registered
`org.apache.camel.spi.CompilePostProcessor`s on a compiled
+class that has class-level annotations, with a new instance of the class, as
the Java DSL loader does for `.java`
+sources. In the Camel CLI this binds a `@BindToRegistry` class and registers a
`@Converter` class (and handles the
+Spring and Quarkus annotations) from a `.groovy` file, and binds it again on a
reload in dev mode. A Groovy class
+without annotations, and a Groovy script, is not instantiated, as before.
+
=== camel-djl (Breaking change)
DJL library dependency has been upgraded to 0.37+
@@ -847,6 +853,10 @@ found as `resource:classpath:mapping.groovy`, the
reference that also works in t
where before only `resource:file:mapping.groovy` worked in the CLI. A resource
that exists nowhere fails as before,
named as written.
+`camel run` adds `camel-groovy` as a dependency when a `.groovy` file is
given, so the file is compiled without
+`--dep=camel-groovy`; before, the file was silently ignored unless the
dependency was added. The dependency is
+also written to the run settings, so `camel export` includes it.
+
=== camel-jbang (MCP servers)
The Camel authoring tools for AI agents are now defined once, in
`camel-jbang-core`, and exposed under the
diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-beans.adoc
b/docs/user-manual/modules/ROOT/pages/camel-jbang-beans.adoc
index a9a622ebad08..8bbfa4e7edf5 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-beans.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-beans.adoc
@@ -40,6 +40,49 @@ These Jakarta/MicroProfile annotations work in Camel
standalone (no Quarkus cont
- `@ConfigProperty` on a field — injects a property placeholder
- `@Produces` on a method — creates a bean (`@Named` specifies the bean id)
+== Using Groovy source files
+
+A bean can be a Groovy source file instead of a Java one. The CLI adds
`camel-groovy` and compiles the
+`.groovy` files given to `camel run`, and the class-level annotations above
(`@BindToRegistry`,
+`@Converter`, and the Spring and Quarkus equivalents) work the same as in a
Java file:
+
+[source,groovy]
+----
+import org.apache.camel.BindToRegistry
+import org.apache.camel.Exchange
+import org.apache.camel.spi.SimpleFunction
+
+@BindToRegistry("mask-email-function")
+class MaskEmailFunction implements SimpleFunction {
+
+ String getName() { 'maskEmail' }
+
+ Object apply(Exchange exchange, Object input) {
+ def email = input.toString().trim()
+ int at = email.indexOf('@')
+ if (at <= 0 || at == email.length() - 1) return '***'
+ return email[0] + '***' + email.substring(at)
+ }
+}
+----
+
+[source,bash]
+----
+camel run route.camel.yaml MaskEmailFunction.groovy --dev
+----
+
+In dev mode a saved `.groovy` file is recompiled and its beans are bound
again, so the change takes effect
+without a restart. See xref:components:languages:simple-advanced.adoc[Simple
Advanced Features] for using this as a
+custom simple function.
+
+NOTE: A `.groovy` file is compiled after the beans of the YAML and XML files
have been created, so a
+`- beans:` entry cannot refer to a Groovy class by its `type`; use
`@BindToRegistry` on the class instead, or
+create the bean with an inline Groovy `script` in the `- beans:` entry.
+
+NOTE: The annotations on a `.groovy` file are handled by the Camel CLI only.
In a project created with
+`camel export` the file is compiled, but the class is not bound as a bean. For
a project that is exported, create
+the bean with an inline Groovy `script` in a `- beans:` entry, which works in
every runtime, or write it in Java.
+
== Defining beans in XML DSL
When using xref:components:others:java-xml-io-dsl.adoc[XML DSL], you can
declare beans
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Run.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Run.java
index 4ba4f527fe11..e7ebda91b4b3 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Run.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Run.java
@@ -1223,6 +1223,11 @@ public class Run extends CamelCommand {
if (sjGroovyFiles.length() > 0) {
main.addInitialProperty(GROOVY_FILES, sjGroovyFiles.toString());
writeSettings(GROOVY_FILES, sjGroovyFiles.toString());
+ // the groovy sources are compiled by camel-groovy, which nothing
else on the classpath pulls in
+ // (camel:groovy, camel:groovy:x.y.z,
mvn:org.apache.camel:camel-groovy:x.y.z)
+ if (dependencies.stream().noneMatch(d ->
d.startsWith("camel:groovy") || d.contains(":camel-groovy"))) {
+ dependencies.add("camel:groovy");
+ }
} else {
writeSetting(main, profileProperties, GROOVY_FILES, () -> null);
}