This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch CAMEL-26200-ldif-connection
in repository https://gitbox.apache.org/repos/asf/camel.git

commit 94efd1701a1dce5f906a2f5a43a84042c6b786aa
Author: Claus Ibsen <[email protected]>
AuthorDate: Tue Nov 3 00:17:00 2026 +0100

    CAMEL-26200: camel-ldif - bind the connection for every message, give the 
result code and DN of a failed change, and an empty body for an empty body
    
    The producer closes the LdapConnection after each message. A connection bean
    that is not a prototype then reconnected without a bind, so every later
    message failed with "modifications require authentication". The producer now
    binds a connection that is not authenticated with its configured name and
    credentials, and closes the connection and readers in a finally block.
    
    A failed change now gives the LDAP result code and the DN of the entry, as
    OpenLDAP sends no message for some failures. An empty or null body gives an
    empty String instead of null, and a missing connection bean fails with a
    NoSuchBeanException instead of a NullPointerException.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
---
 .../org/apache/camel/catalog/components/ldif.json  |   4 +-
 .../apache/camel/catalog/docs/ldif-component.adoc  |  26 ++-
 .../org/apache/camel/component/ldif/ldif.json      |   4 +-
 .../camel-ldif/src/main/docs/ldif-component.adoc   |  26 ++-
 .../apache/camel/component/ldif/LdifEndpoint.java  |  10 +-
 .../apache/camel/component/ldif/LdifProducer.java  |  82 ++++++---
 .../camel/component/ldif/LdifConnectionTest.java   | 198 +++++++++++++++++++++
 .../apache/camel/component/ldif/LdifModDnTest.java |   4 +
 .../apache/camel/component/ldif/LdifRouteIT.java   |  36 +++-
 .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc    |  14 ++
 .../builder/endpoint/StaticEndpointBuilders.java   |   8 +-
 .../endpoint/dsl/LdifEndpointBuilderFactory.java   |   8 +-
 12 files changed, 367 insertions(+), 53 deletions(-)

diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/ldif.json
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/ldif.json
index 0f9640e447c8..b044f21c6025 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/ldif.json
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/ldif.json
@@ -29,10 +29,10 @@
   },
   "bodyTypes": {
     "producerIn": { "carriers": [ "text", "bytes", "stream", "file" ], 
"description": "The LDIF text, starting with version: 1, or bytes, a stream or 
a file of it. With allowUrlBody, a text that is not LDIF is read as the URL of 
the LDIF file; otherwise it fails, as does a Map or a POJO" },
-    "producerOut": { "carriers": [ "list", "null" ], "description": "A List of 
one status String per LDIF entry: success, or the error message from the 
server, which can be empty. No body or an empty body gives null" }
+    "producerOut": { "carriers": [ "list", "text" ], "description": "A List of 
one status String per LDIF entry: success, or the LDAP result code, the DN and 
the message from the server, such as entryAlreadyExists (68) on 
uid=a,dc=example,dc=org. No body or an empty body gives an empty String" }
   },
   "properties": {
-    "ldapConnectionName": { "index": 0, "kind": "path", "displayName": "Ldap 
Connection Name", "group": "producer", "label": "", "required": true, "type": 
"string", "javaType": "java.lang.String", "deprecated": false, 
"deprecationNote": "", "autowired": false, "secret": false, "description": "The 
name of the LdapConnection bean to pull from the registry. Note that this must 
be of scope prototype to avoid it being shared among threads or using a 
connection that has timed out." },
+    "ldapConnectionName": { "index": 0, "kind": "path", "displayName": "Ldap 
Connection Name", "group": "producer", "label": "", "required": true, "type": 
"string", "javaType": "java.lang.String", "deprecated": false, 
"deprecationNote": "", "autowired": false, "secret": false, "description": "The 
name of the LdapConnection bean to pull from the registry. Note that this must 
be of scope prototype to avoid it being shared among threads or using a 
connection that has timed out. The connecti [...]
     "lazyStartProducer": { "index": 1, "kind": "parameter", "displayName": 
"Lazy Start Producer", "group": "producer (advanced)", "label": 
"producer,advanced", "required": false, "type": "boolean", "javaType": 
"boolean", "deprecated": false, "autowired": false, "secret": false, 
"defaultValue": false, "description": "Whether the producer should be started 
lazy (on the first message). By starting lazy you can use this to allow 
CamelContext and routes to startup in situations where a produc [...]
     "allowUrlBody": { "index": 2, "kind": "parameter", "displayName": "Allow 
Url Body", "group": "security", "label": "security", "required": false, "type": 
"boolean", "javaType": "boolean", "deprecated": false, "autowired": false, 
"secret": false, "security": "insecure:dev", "defaultValue": false, 
"description": "Whether to allow a message body that is not LDIF content to be 
dereferenced as a URL and fetched. When disabled (default), a body that does 
not start with version: 1 is rejecte [...]
   }
diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ldif-component.adoc
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ldif-component.adoc
index 9b4018358e5d..c18b66fe06f5 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ldif-component.adoc
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ldif-component.adoc
@@ -76,7 +76,17 @@ By default, such a body is rejected.
 === Result
 
 The result is returned in the Out body as a `ArrayList<java.lang.String>` 
object.
-This contains either `success` or the error message for each LDIF entry.
+This contains a status for each LDIF entry: either `success`, or for a failed 
change the LDAP result code,
+the DN of the entry and the message from the server, such as:
+
+----
+entryAlreadyExists (68) on uid=test1,ou=test,dc=example,dc=org
+noSuchObject (32) on uid=test2,ou=test,dc=example,dc=org: no such entry
+----
+
+The message is left out when the server sends none (OpenLDAP sends none for an 
entry that already exists).
+
+An empty or null body is not sent to the server, and the result is an empty 
String.
 
 === LdapConnection
 
@@ -86,6 +96,10 @@ The URI, `ldif:ldapConnectionName`, references a bean with 
the ID,
 `prototype` to avoid the connection being shared or picking up a
 stale connection.
 
+The component closes the connection after each message. When the bean is not a 
prototype,
+the next message uses the same connection: the component then binds it again,
+with the name and credentials of its `LdapConnectionConfig`, before the 
changes are applied.
+
 The `LdapConnection` bean may be defined as follows in Spring XML:
 
 [source,xml]
@@ -123,9 +137,9 @@ In YAML, a Groovy script can make the connection:
         new 
org.apache.directory.ldap.client.api.DefaultLdapConnectionFactory(config).newLdapConnection()
 ----
 
-A YAML bean is created once, and the component closes the connection after 
each message.
-The YAML route in the examples below therefore binds the connection again 
before each message.
-This works when the route processes one message at a time.
+A YAML bean is created once, and the component closes the connection after 
each message,
+and binds it again for the next message.
+As the connection is shared, this works when the route processes one message 
at a time.
 
 == Examples
 
@@ -157,10 +171,6 @@ and logs the result:
     from:
       uri: file:ldif
       steps:
-        # the component closes the connection after each message, so bind it 
again
-        - bean:
-            ref: ldapConnection
-            method: "bind()"
         - to:
             uri: ldif:ldapConnection
         - log:
diff --git 
a/components/camel-ldif/src/generated/resources/META-INF/org/apache/camel/component/ldif/ldif.json
 
b/components/camel-ldif/src/generated/resources/META-INF/org/apache/camel/component/ldif/ldif.json
index 0f9640e447c8..b044f21c6025 100644
--- 
a/components/camel-ldif/src/generated/resources/META-INF/org/apache/camel/component/ldif/ldif.json
+++ 
b/components/camel-ldif/src/generated/resources/META-INF/org/apache/camel/component/ldif/ldif.json
@@ -29,10 +29,10 @@
   },
   "bodyTypes": {
     "producerIn": { "carriers": [ "text", "bytes", "stream", "file" ], 
"description": "The LDIF text, starting with version: 1, or bytes, a stream or 
a file of it. With allowUrlBody, a text that is not LDIF is read as the URL of 
the LDIF file; otherwise it fails, as does a Map or a POJO" },
-    "producerOut": { "carriers": [ "list", "null" ], "description": "A List of 
one status String per LDIF entry: success, or the error message from the 
server, which can be empty. No body or an empty body gives null" }
+    "producerOut": { "carriers": [ "list", "text" ], "description": "A List of 
one status String per LDIF entry: success, or the LDAP result code, the DN and 
the message from the server, such as entryAlreadyExists (68) on 
uid=a,dc=example,dc=org. No body or an empty body gives an empty String" }
   },
   "properties": {
-    "ldapConnectionName": { "index": 0, "kind": "path", "displayName": "Ldap 
Connection Name", "group": "producer", "label": "", "required": true, "type": 
"string", "javaType": "java.lang.String", "deprecated": false, 
"deprecationNote": "", "autowired": false, "secret": false, "description": "The 
name of the LdapConnection bean to pull from the registry. Note that this must 
be of scope prototype to avoid it being shared among threads or using a 
connection that has timed out." },
+    "ldapConnectionName": { "index": 0, "kind": "path", "displayName": "Ldap 
Connection Name", "group": "producer", "label": "", "required": true, "type": 
"string", "javaType": "java.lang.String", "deprecated": false, 
"deprecationNote": "", "autowired": false, "secret": false, "description": "The 
name of the LdapConnection bean to pull from the registry. Note that this must 
be of scope prototype to avoid it being shared among threads or using a 
connection that has timed out. The connecti [...]
     "lazyStartProducer": { "index": 1, "kind": "parameter", "displayName": 
"Lazy Start Producer", "group": "producer (advanced)", "label": 
"producer,advanced", "required": false, "type": "boolean", "javaType": 
"boolean", "deprecated": false, "autowired": false, "secret": false, 
"defaultValue": false, "description": "Whether the producer should be started 
lazy (on the first message). By starting lazy you can use this to allow 
CamelContext and routes to startup in situations where a produc [...]
     "allowUrlBody": { "index": 2, "kind": "parameter", "displayName": "Allow 
Url Body", "group": "security", "label": "security", "required": false, "type": 
"boolean", "javaType": "boolean", "deprecated": false, "autowired": false, 
"secret": false, "security": "insecure:dev", "defaultValue": false, 
"description": "Whether to allow a message body that is not LDIF content to be 
dereferenced as a URL and fetched. When disabled (default), a body that does 
not start with version: 1 is rejecte [...]
   }
diff --git a/components/camel-ldif/src/main/docs/ldif-component.adoc 
b/components/camel-ldif/src/main/docs/ldif-component.adoc
index 9b4018358e5d..c18b66fe06f5 100644
--- a/components/camel-ldif/src/main/docs/ldif-component.adoc
+++ b/components/camel-ldif/src/main/docs/ldif-component.adoc
@@ -76,7 +76,17 @@ By default, such a body is rejected.
 === Result
 
 The result is returned in the Out body as a `ArrayList<java.lang.String>` 
object.
-This contains either `success` or the error message for each LDIF entry.
+This contains a status for each LDIF entry: either `success`, or for a failed 
change the LDAP result code,
+the DN of the entry and the message from the server, such as:
+
+----
+entryAlreadyExists (68) on uid=test1,ou=test,dc=example,dc=org
+noSuchObject (32) on uid=test2,ou=test,dc=example,dc=org: no such entry
+----
+
+The message is left out when the server sends none (OpenLDAP sends none for an 
entry that already exists).
+
+An empty or null body is not sent to the server, and the result is an empty 
String.
 
 === LdapConnection
 
@@ -86,6 +96,10 @@ The URI, `ldif:ldapConnectionName`, references a bean with 
the ID,
 `prototype` to avoid the connection being shared or picking up a
 stale connection.
 
+The component closes the connection after each message. When the bean is not a 
prototype,
+the next message uses the same connection: the component then binds it again,
+with the name and credentials of its `LdapConnectionConfig`, before the 
changes are applied.
+
 The `LdapConnection` bean may be defined as follows in Spring XML:
 
 [source,xml]
@@ -123,9 +137,9 @@ In YAML, a Groovy script can make the connection:
         new 
org.apache.directory.ldap.client.api.DefaultLdapConnectionFactory(config).newLdapConnection()
 ----
 
-A YAML bean is created once, and the component closes the connection after 
each message.
-The YAML route in the examples below therefore binds the connection again 
before each message.
-This works when the route processes one message at a time.
+A YAML bean is created once, and the component closes the connection after 
each message,
+and binds it again for the next message.
+As the connection is shared, this works when the route processes one message 
at a time.
 
 == Examples
 
@@ -157,10 +171,6 @@ and logs the result:
     from:
       uri: file:ldif
       steps:
-        # the component closes the connection after each message, so bind it 
again
-        - bean:
-            ref: ldapConnection
-            method: "bind()"
         - to:
             uri: ldif:ldapConnection
         - log:
diff --git 
a/components/camel-ldif/src/main/java/org/apache/camel/component/ldif/LdifEndpoint.java
 
b/components/camel-ldif/src/main/java/org/apache/camel/component/ldif/LdifEndpoint.java
index 190174b34fbd..99fcf44b71a9 100644
--- 
a/components/camel-ldif/src/main/java/org/apache/camel/component/ldif/LdifEndpoint.java
+++ 
b/components/camel-ldif/src/main/java/org/apache/camel/component/ldif/LdifEndpoint.java
@@ -38,9 +38,10 @@ import org.apache.camel.support.DefaultEndpoint;
                                   description = "The LDIF text, starting with 
version: 1, or bytes, a stream or a file of it."
                                                 + " With allowUrlBody, a text 
that is not LDIF is read as the URL of the LDIF"
                                                 + " file; otherwise it fails, 
as does a Map or a POJO"),
-           producerOut = @BodyType(value = { Carrier.LIST, Carrier.NULL },
-                                   description = "A List of one status String 
per LDIF entry: success, or the error message from"
-                                                 + " the server, which can be 
empty. No body or an empty body gives null"))
+           producerOut = @BodyType(value = { Carrier.LIST, Carrier.TEXT },
+                                   description = "A List of one status String 
per LDIF entry: success, or the LDAP result code,"
+                                                 + " the DN and the message 
from the server, such as entryAlreadyExists (68) on"
+                                                 + " uid=a,dc=example,dc=org. 
No body or an empty body gives an empty String"))
 public class LdifEndpoint extends DefaultEndpoint {
     @UriPath
     @Metadata(required = true)
@@ -69,7 +70,8 @@ public class LdifEndpoint extends DefaultEndpoint {
 
     /**
      * The name of the LdapConnection bean to pull from the registry. Note 
that this must be of scope "prototype" to
-     * avoid it being shared among threads or using a connection that has 
timed out.
+     * avoid it being shared among threads or using a connection that has 
timed out. The connection is closed after each
+     * message, and bound again with its configured name and credentials when 
it is used for the next message.
      */
     public void setLdapConnectionName(String ldapConnectionName) {
         this.ldapConnectionName = ldapConnectionName;
diff --git 
a/components/camel-ldif/src/main/java/org/apache/camel/component/ldif/LdifProducer.java
 
b/components/camel-ldif/src/main/java/org/apache/camel/component/ldif/LdifProducer.java
index 0476b13821b8..3cc3958a6245 100644
--- 
a/components/camel-ldif/src/main/java/org/apache/camel/component/ldif/LdifProducer.java
+++ 
b/components/camel-ldif/src/main/java/org/apache/camel/component/ldif/LdifProducer.java
@@ -28,12 +28,15 @@ import java.util.List;
 import org.apache.camel.CamelException;
 import org.apache.camel.Exchange;
 import org.apache.camel.InvalidPayloadException;
+import org.apache.camel.support.CamelContextHelper;
 import org.apache.camel.support.DefaultProducer;
 import org.apache.camel.util.IOHelper;
 import org.apache.camel.util.ObjectHelper;
 import org.apache.directory.api.ldap.model.exception.LdapException;
+import org.apache.directory.api.ldap.model.exception.LdapOperationException;
 import org.apache.directory.api.ldap.model.ldif.LdifEntry;
 import org.apache.directory.api.ldap.model.ldif.LdifReader;
+import org.apache.directory.api.ldap.model.message.ResultCodeEnum;
 import org.apache.directory.api.ldap.model.name.Dn;
 import org.apache.directory.api.ldap.model.name.Rdn;
 import org.apache.directory.ldap.client.api.LdapConnection;
@@ -68,7 +71,7 @@ public class LdifProducer extends DefaultProducer {
     @Override
     public void process(Exchange exchange) throws Exception {
         String body = exchange.getIn().getBody(String.class);
-        List<String> result = null;
+        List<String> result;
 
         // Pass through everything
         exchange.setMessage(exchange.getIn());
@@ -76,7 +79,10 @@ public class LdifProducer extends DefaultProducer {
         // If nothing to do, then return an empty body
         if (ObjectHelper.isEmpty(body)) {
             exchange.getMessage().setBody("");
-        } else if (body.startsWith(LDIF_HEADER)) {
+            return;
+        }
+
+        if (body.startsWith(LDIF_HEADER)) {
             LOG.debug("Reading from LDIF body");
             result = processLdif(new StringReader(body));
         } else if (((LdifEndpoint) getEndpoint()).isAllowUrlBody()) {
@@ -107,33 +113,44 @@ public class LdifProducer extends DefaultProducer {
      * @return The created LDAP connection.
      */
     protected LdapConnection getLdapConnection() {
-        return (LdapConnection) 
getEndpoint().getCamelContext().getRegistry().lookupByName(ldapConnectionName);
+        return 
CamelContextHelper.mandatoryLookup(getEndpoint().getCamelContext(), 
ldapConnectionName, LdapConnection.class);
     }
 
     /**
      * Process an LDIF file from a reader.
      */
     private List<String> processLdif(Reader reader) throws CamelException {
-        LdapConnection conn = getLdapConnection();
-        LdifReader ldifReader;
-
-        List<String> results = new ArrayList<>();
-
-        // Create the reader
+        LdapConnection conn = null;
+        LdifReader ldifReader = null;
         try {
-            ldifReader = new LdifReader(reader);
-        } catch (LdapException e) {
-            throw new CamelException("Unable to create LDIF reader", e);
-        }
-
-        // Process each entry
-        for (LdifEntry e : ldifReader) {
-            results.add(processLdifEntry(conn, e));
-        }
+            conn = getLdapConnection();
+            // the connection is closed after each message. A connection bean 
that is looked up again (not a prototype)
+            // then reconnects without a bind, so bind it again with its 
configured name and credentials
+            if (!conn.isAuthenticated()) {
+                LOG.debug("Binding the LDAP connection {}", 
ldapConnectionName);
+                try {
+                    conn.bind();
+                } catch (LdapException e) {
+                    throw new CamelException("Unable to bind the LDAP 
connection " + ldapConnectionName, e);
+                }
+            }
 
-        IOHelper.close(conn, ldifReader, reader);
+            // Create the reader
+            try {
+                ldifReader = new LdifReader(reader);
+            } catch (LdapException e) {
+                throw new CamelException("Unable to create LDIF reader", e);
+            }
 
-        return results;
+            // Process each entry
+            List<String> results = new ArrayList<>();
+            for (LdifEntry e : ldifReader) {
+                results.add(processLdifEntry(conn, e));
+            }
+            return results;
+        } finally {
+            IOHelper.close(conn, ldifReader, reader);
+        }
     }
 
     /**
@@ -178,14 +195,33 @@ public class LdifProducer extends DefaultProducer {
             return "success";
         } catch (LdapException e) {
             LOG.debug("failed to apply ldif", e);
-            return getRootCause(e);
+            return getFailureStatus(ldifEntry, e);
+        }
+    }
+
+    /**
+     * The status of a failed change: the LDAP result code (when the server 
sent one), the DN of the entry, and the
+     * message of the root cause (when there is one). OpenLDAP sends no 
message for some failures, such as an entry that
+     * already exists, so the result code tells what went wrong.
+     */
+    private static String getFailureStatus(LdifEntry ldifEntry, LdapException 
e) {
+        StringBuilder sb = new StringBuilder();
+        if (e instanceof LdapOperationException ope && ope.getResultCode() != 
null) {
+            ResultCodeEnum code = ope.getResultCode();
+            sb.append(code.getMessage()).append(" 
(").append(code.getResultCode()).append(") on ");
+        }
+        sb.append(ldifEntry.getDn());
+        String message = getRootCause(e).getMessage();
+        if (ObjectHelper.isNotEmpty(message)) {
+            sb.append(": ").append(message);
         }
+        return sb.toString();
     }
 
     /**
      * Get the root cause of an exception
      */
-    private String getRootCause(LdapException e) {
+    private static Throwable getRootCause(LdapException e) {
         Throwable oldt;
         Throwable thist;
 
@@ -194,6 +230,6 @@ public class LdifProducer extends DefaultProducer {
             oldt = thist;
             thist = thist.getCause();
         }
-        return oldt.getMessage();
+        return oldt;
     }
 }
diff --git 
a/components/camel-ldif/src/test/java/org/apache/camel/component/ldif/LdifConnectionTest.java
 
b/components/camel-ldif/src/test/java/org/apache/camel/component/ldif/LdifConnectionTest.java
new file mode 100644
index 000000000000..61f91a2fceaa
--- /dev/null
+++ 
b/components/camel-ldif/src/test/java/org/apache/camel/component/ldif/LdifConnectionTest.java
@@ -0,0 +1,198 @@
+/*
+ * 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.component.ldif;
+
+import java.lang.reflect.Proxy;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+import org.apache.camel.BindToRegistry;
+import org.apache.camel.CamelExecutionException;
+import org.apache.camel.Exchange;
+import org.apache.camel.NoSuchBeanException;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.apache.directory.api.ldap.model.entry.Entry;
+import 
org.apache.directory.api.ldap.model.exception.LdapEntryAlreadyExistsException;
+import org.apache.directory.api.ldap.model.exception.LdapNoSuchObjectException;
+import org.apache.directory.api.ldap.model.exception.LdapOperationException;
+import org.apache.directory.api.ldap.model.message.ResultCodeEnum;
+import org.apache.directory.api.ldap.model.name.Dn;
+import org.apache.directory.ldap.client.api.LdapConnection;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The producer closes the connection after each message. A connection bean 
that is looked up again (not a prototype)
+ * must be bound again for the next message, a failed change gives the LDAP 
result code and the DN, and an empty body
+ * gives an empty body. The connection is a fake that behaves like an LDAP 
connection to a server that refuses anonymous
+ * changes, so this test needs no LDAP server.
+ */
+class LdifConnectionTest extends CamelTestSupport {
+
+    private final FakeConnection fake = new FakeConnection();
+
+    @BindToRegistry("conn")
+    private final LdapConnection conn = (LdapConnection) 
Proxy.newProxyInstance(
+            LdapConnection.class.getClassLoader(), new Class<?>[] { 
LdapConnection.class },
+            (proxy, method, args) -> fake.invoke(proxy, method.getName(), 
args));
+
+    @Test
+    void everyMessageRunsWithTheBindOfTheConnection() {
+        assertEquals(List.of("success"), send(add("uid=a,dc=example,dc=org")));
+        assertEquals(List.of("success"), send(add("uid=b,dc=example,dc=org")));
+        assertEquals(List.of("success"), 
send(delete("uid=a,dc=example,dc=org")));
+
+        assertEquals(Set.of("uid=b,dc=example,dc=org"), fake.entries);
+        assertEquals(3, fake.closed, "the connection is closed after each 
message");
+        assertFalse(fake.connected);
+    }
+
+    @Test
+    void failureStatusHasTheResultCodeAndTheDn() {
+        send(add("uid=a,dc=example,dc=org"));
+
+        assertEquals(List.of("entryAlreadyExists (68) on 
uid=a,dc=example,dc=org"), send(add("uid=a,dc=example,dc=org")));
+        assertEquals(List.of("noSuchObject (32) on uid=x,dc=example,dc=org: no 
such entry"),
+                send(delete("uid=x,dc=example,dc=org")));
+    }
+
+    @Test
+    void emptyBodyGivesEmptyBody() {
+        assertEquals("", template.requestBody("direct:ldif", ""));
+        assertEquals("", template.requestBody("direct:ldif", (Object) null));
+        assertEquals(0, fake.closed, "the connection is not used");
+    }
+
+    @Test
+    void connectionIsClosedWhenTheChangeFails() {
+        Exchange out = template.request("direct:ldif", e -> 
e.getIn().setBody(add("uid=boom,dc=example,dc=org")));
+
+        assertInstanceOf(IllegalStateException.class, out.getException());
+        assertEquals(1, fake.closed);
+    }
+
+    @Test
+    void missingConnectionBean() {
+        CamelExecutionException e = assertThrows(CamelExecutionException.class,
+                () -> template.requestBody("direct:missing", 
add("uid=a,dc=example,dc=org")));
+
+        NoSuchBeanException cause = 
assertInstanceOf(NoSuchBeanException.class, e.getCause());
+        assertTrue(cause.getMessage().contains("missing"), cause.getMessage());
+    }
+
+    private List<?> send(String ldif) {
+        return template.requestBody("direct:ldif", ldif, List.class);
+    }
+
+    private static String add(String dn) {
+        return "version: 1\n"
+               + "dn: " + dn + "\n"
+               + "changetype: add\n"
+               + "objectClass: top\n"
+               + "objectClass: person\n"
+               + "cn: test\n"
+               + "sn: test\n";
+    }
+
+    private static String delete(String dn) {
+        return "version: 1\n"
+               + "dn: " + dn + "\n"
+               + "changetype: delete\n";
+    }
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                from("direct:ldif").to("ldif:conn");
+                from("direct:missing").to("ldif:missing");
+            }
+        };
+    }
+
+    /**
+     * Behaves like LdapNetworkConnection to a server that refuses anonymous 
changes: the bean starts bound (as made by
+     * DefaultLdapConnectionFactory), close() drops the bind, a change on a 
closed connection reconnects without a bind,
+     * and the server sends no message for an entry that already exists.
+     */
+    private static final class FakeConnection {
+        private final Set<String> entries = new HashSet<>();
+        private boolean connected = true;
+        private boolean authenticated = true;
+        private int closed;
+
+        Object invoke(Object proxy, String method, Object[] args) throws 
Exception {
+            switch (method) {
+                case "hashCode":
+                    return System.identityHashCode(proxy);
+                case "equals":
+                    return proxy == args[0];
+                case "toString":
+                    return "LdapConnection fake";
+                case "isConnected":
+                    return connected;
+                case "isAuthenticated":
+                    return connected && authenticated;
+                case "bind":
+                    connected = true;
+                    authenticated = true;
+                    return null;
+                case "close":
+                    closed++;
+                    connected = false;
+                    authenticated = false;
+                    return null;
+                case "add":
+                    change();
+                    String dn = ((Entry) args[0]).getDn().getName();
+                    if (dn.startsWith("uid=boom")) {
+                        throw new IllegalStateException("boom");
+                    }
+                    if (!entries.add(dn)) {
+                        throw new LdapEntryAlreadyExistsException("");
+                    }
+                    return null;
+                case "delete":
+                    change();
+                    if (!entries.remove(((Dn) args[0]).getName())) {
+                        throw new LdapNoSuchObjectException("no such entry");
+                    }
+                    return null;
+                default:
+                    throw new UnsupportedOperationException(method);
+            }
+        }
+
+        private void change() throws LdapOperationException {
+            if (!connected) {
+                // the client reconnects on its own, without a bind
+                connected = true;
+            }
+            if (!authenticated) {
+                throw new 
LdapOperationException(ResultCodeEnum.STRONG_AUTH_REQUIRED, "modifications 
require authentication");
+            }
+        }
+    }
+}
diff --git 
a/components/camel-ldif/src/test/java/org/apache/camel/component/ldif/LdifModDnTest.java
 
b/components/camel-ldif/src/test/java/org/apache/camel/component/ldif/LdifModDnTest.java
index 43ea3dea3346..8eecade0baad 100644
--- 
a/components/camel-ldif/src/test/java/org/apache/camel/component/ldif/LdifModDnTest.java
+++ 
b/components/camel-ldif/src/test/java/org/apache/camel/component/ldif/LdifModDnTest.java
@@ -60,6 +60,10 @@ class LdifModDnTest extends CamelTestSupport {
                     }
                     case "moveAndRename" ->
                         renames.add(args[1] + " deleteOldRdn=" + args[2]);
+                    case "isAuthenticated" -> {
+                        // the connection is bound, so the producer does not 
bind it again
+                        return Boolean.TRUE;
+                    }
                     case "close" -> {
                         // the producer closes the connection after the LDIF 
is processed
                     }
diff --git 
a/components/camel-ldif/src/test/java/org/apache/camel/component/ldif/LdifRouteIT.java
 
b/components/camel-ldif/src/test/java/org/apache/camel/component/ldif/LdifRouteIT.java
index 81159d936b64..f0727320cb94 100644
--- 
a/components/camel-ldif/src/test/java/org/apache/camel/component/ldif/LdifRouteIT.java
+++ 
b/components/camel-ldif/src/test/java/org/apache/camel/component/ldif/LdifRouteIT.java
@@ -55,6 +55,7 @@ import static org.hamcrest.CoreMatchers.equalTo;
 import static org.hamcrest.CoreMatchers.hasItem;
 import static org.hamcrest.CoreMatchers.not;
 import static org.hamcrest.CoreMatchers.notNullValue;
+import static org.hamcrest.CoreMatchers.startsWith;
 import static org.hamcrest.MatcherAssert.assertThat;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 
@@ -202,7 +203,37 @@ public class LdifRouteIT extends LdifTestSupport {
         List<String> ldifResults = defaultLdapModuleOutAssertions(out);
         assertThat(ldifResults, notNullValue());
         assertThat(ldifResults.size(), equalTo(1));
-        assertThat(ldifResults.get(0), not(equalTo("success")));
+        // OpenLDAP sends no message for an entry that already exists, so the 
status has the result code and the DN
+        assertThat(ldifResults.get(0), equalTo("entryAlreadyExists (68) on 
uid=test3,ou=test,dc=example,dc=org"));
+    }
+
+    @Test
+    public void twoMessagesWithTheSameConnection() throws Exception {
+        camel.addRoutes(createRouteBuilder(ENDPOINT_LDIF));
+        camel.start();
+
+        // the connection bean is a singleton, which the producer closes after 
the first message: the second message
+        // must run with the bind of the connection as well
+        URL loc = 
this.getClass().getResource("/org/apache/camel/component/ldif/AddDuplicateSetup.ldif");
+        List<?> first = template.requestBody(ENDPOINT_START, readUrl(loc), 
List.class);
+        assertThat(first.size(), equalTo(2));
+        first.forEach(result -> assertThat(String.valueOf(result),
+                anyOf(equalTo("success"), startsWith("entryAlreadyExists 
(68)"))));
+
+        String ldif = "version: 1\n"
+                      + "dn: uid=test8,ou=test,dc=example,dc=org\n"
+                      + "changetype: add\n"
+                      + "objectClass: top\n"
+                      + "objectClass: organizationalPerson\n"
+                      + "objectclass: inetOrgPerson\n"
+                      + "cn: Test 8\n"
+                      + "uid: test8\n"
+                      + "sn: 8\n";
+        List<?> second = template.requestBody(ENDPOINT_START, ldif, 
List.class);
+        assertThat(second, equalTo(List.of("success")));
+
+        NamingEnumeration<SearchResult> searchResults = 
ldapContext.search("dc=example,dc=org", "(uid=test8)", SEARCH_CONTROLS);
+        checkDN("uid=test8", searchResults);
     }
 
     @Test
@@ -376,7 +407,8 @@ public class LdifRouteIT extends LdifTestSupport {
         Exchange setupOut = setupTemplate.send(endpoint, exchange);
         List<String> setupResults = defaultLdapModuleOutAssertions(setupOut);
 
-        setupResults.forEach(result -> assertThat(result, 
anyOf(equalTo("success"), equalTo(""))));
+        // the entries of the setup may exist from an earlier test
+        setupResults.forEach(result -> assertThat(result, 
anyOf(equalTo("success"), startsWith("entryAlreadyExists (68)"))));
 
         setupCamel.stop();
     }
diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index 10f5a47c1b89..14c50ba95b83 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -2979,6 +2979,20 @@ silently can use the `upsert` operation, or handle the 
exception, for example wi
 The producer also closes the Kudu session of each operation. The sessions 
stayed registered with the Kudu client until
 the client was closed, one more for every exchange.
 
+=== camel-ldif - the connection is bound for every message, and the status of 
a failed change
+
+The producer closes the `LdapConnection` after each message. A connection bean 
that is not a prototype then
+reconnected without a bind for the next message, so its changes failed with 
`modifications require authentication`.
+The producer now binds such a connection again, with the name and credentials 
of its `LdapConnectionConfig`.
+A route that called `bind()` on the connection before each message can drop 
that step.
+
+The status of a failed change is now the LDAP result code, the DN of the entry 
and the message from the server, such as
+`entryAlreadyExists (68) on uid=test1,ou=test,dc=example,dc=org`, instead of 
only the message (which OpenLDAP leaves
+empty for some failures). A successful change is still `success`.
+
+An empty or null body now gives an empty String as the result, instead of 
`null`. A missing connection bean fails with
+a `NoSuchBeanException` instead of a `NullPointerException`.
+
 === camel-mllp - the producer verifies the host name of the server over TLS
 
 When `sslContextParameters` is set (on the endpoint, or with 
`useGlobalSslContextParameters` on the component), the
diff --git 
a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/StaticEndpointBuilders.java
 
b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/StaticEndpointBuilders.java
index b6304de7d42b..1bf6fab67791 100644
--- 
a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/StaticEndpointBuilders.java
+++ 
b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/StaticEndpointBuilders.java
@@ -11471,7 +11471,9 @@ public class StaticEndpointBuilders {
      * Path parameter: ldapConnectionName (required)
      * The name of the LdapConnection bean to pull from the registry. Note that
      * this must be of scope prototype to avoid it being shared among threads 
or
-     * using a connection that has timed out.
+     * using a connection that has timed out. The connection is closed after
+     * each message, and bound again with its configured name and credentials
+     * when it is used for the next message.
      * 
      * @param path ldapConnectionName
      * @return the dsl builder
@@ -11492,7 +11494,9 @@ public class StaticEndpointBuilders {
      * Path parameter: ldapConnectionName (required)
      * The name of the LdapConnection bean to pull from the registry. Note that
      * this must be of scope prototype to avoid it being shared among threads 
or
-     * using a connection that has timed out.
+     * using a connection that has timed out. The connection is closed after
+     * each message, and bound again with its configured name and credentials
+     * when it is used for the next message.
      * 
      * @param componentName to use a custom component name for the endpoint
      * instead of the default name
diff --git 
a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/LdifEndpointBuilderFactory.java
 
b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/LdifEndpointBuilderFactory.java
index 293173ed2898..6e1df2e771e4 100644
--- 
a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/LdifEndpointBuilderFactory.java
+++ 
b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/LdifEndpointBuilderFactory.java
@@ -158,7 +158,9 @@ public interface LdifEndpointBuilderFactory {
          * Path parameter: ldapConnectionName (required)
          * The name of the LdapConnection bean to pull from the registry. Note
          * that this must be of scope prototype to avoid it being shared among
-         * threads or using a connection that has timed out.
+         * threads or using a connection that has timed out. The connection is
+         * closed after each message, and bound again with its configured name
+         * and credentials when it is used for the next message.
          * 
          * @param path ldapConnectionName
          * @return the dsl builder
@@ -179,7 +181,9 @@ public interface LdifEndpointBuilderFactory {
          * Path parameter: ldapConnectionName (required)
          * The name of the LdapConnection bean to pull from the registry. Note
          * that this must be of scope prototype to avoid it being shared among
-         * threads or using a connection that has timed out.
+         * threads or using a connection that has timed out. The connection is
+         * closed after each message, and bound again with its configured name
+         * and credentials when it is used for the next message.
          * 
          * @param componentName to use a custom component name for the endpoint
          * instead of the default name

Reply via email to