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

Reply via email to