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 980a01202a31 CAMEL-26248: camel-azure-key-vault - deleteSecret sets 
the DeletedSecret as the body, and createSecret stores a Map, a List or a POJO 
as JSON (#28956)
980a01202a31 is described below

commit 980a01202a31a34bf25a73e3a507dffae7eb2204
Author: Andrea Cosentino <[email protected]>
AuthorDate: Mon Nov 2 23:21:58 2026 +0100

    CAMEL-26248: camel-azure-key-vault - deleteSecret sets the DeletedSecret as 
the body, and createSecret stores a Map, a List or a POJO as JSON (#28956)
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../camel/catalog/components/azure-key-vault.json  |   4 +-
 .../catalog/docs/azure-key-vault-component.adoc    |   8 +-
 .../component/azure/key/vault/azure-key-vault.json |   4 +-
 .../src/main/docs/azure-key-vault-component.adoc   |   8 +-
 .../azure/key/vault/KeyVaultEndpoint.java          |  23 +-
 .../azure/key/vault/KeyVaultProducer.java          |  75 ++++-
 .../azure/key/vault/KeyVaultProducerTest.java      | 310 +++++++++++++++++++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc    |  14 +
 8 files changed, 426 insertions(+), 20 deletions(-)

diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/azure-key-vault.json
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/azure-key-vault.json
index fd99a07c52ad..38cdbe72385d 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/azure-key-vault.json
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/azure-key-vault.json
@@ -34,8 +34,8 @@
     "CamelAzureKeyVaultSecretProperties": { "index": 2, "kind": "header", 
"displayName": "", "group": "producer", "label": "producer", "required": false, 
"javaType": "String", "deprecated": false, "deprecationNote": "", "autowired": 
false, "secret": false, "description": "The secret properties to be used in Key 
Vault for updating a secret", "constantName": 
"org.apache.camel.component.azure.key.vault.KeyVaultConstants#SECRET_PROPERTIES"
 }
   },
   "bodyTypes": {
-    "producerIn": { "carriers": [ "text", "bytes", "stream", "file" ], 
"byOption": [ { "option": "operation", "value": "getSecret", "carriers": [ 
"any" ] }, { "option": "operation", "value": "updateSecretProperties", 
"carriers": [ "any" ] }, { "option": "operation", "value": "deleteSecret", 
"carriers": [ "any" ] }, { "option": "operation", "value": 
"purgeDeletedSecret", "carriers": [ "any" ] } ], "description": "The value of 
the secret for createSecret, the default, read as text: a Map o [...]
-    "producerOut": { "carriers": [ "pojo" ], "javaType": 
"com.azure.security.keyvault.secrets.models.KeyVaultSecret", "byOption": [ { 
"option": "operation", "value": "getSecret", "carriers": [ "text" ], 
"javaType": "java.lang.String" }, { "option": "operation", "value": 
"updateSecretProperties", "carriers": [ "pojo" ], "javaType": 
"com.azure.security.keyvault.secrets.models.SecretProperties" }, { "option": 
"operation", "value": "deleteSecret", "carriers": [ "null" ] }, { "option": 
"opera [...]
+    "producerIn": { "carriers": [ "text", "bytes", "stream", "file", "map", 
"list", "pojo" ], "byOption": [ { "option": "operation", "value": "getSecret", 
"carriers": [ "any" ] }, { "option": "operation", "value": 
"updateSecretProperties", "carriers": [ "any" ] }, { "option": "operation", 
"value": "deleteSecret", "carriers": [ "any" ] }, { "option": "operation", 
"value": "purgeDeletedSecret", "carriers": [ "any" ] } ], "description": "The 
value of the secret for createSecret, the default [...]
+    "producerOut": { "carriers": [ "pojo" ], "javaType": 
"com.azure.security.keyvault.secrets.models.KeyVaultSecret", "byOption": [ { 
"option": "operation", "value": "getSecret", "carriers": [ "text" ], 
"javaType": "java.lang.String" }, { "option": "operation", "value": 
"updateSecretProperties", "carriers": [ "pojo" ], "javaType": 
"com.azure.security.keyvault.secrets.models.SecretProperties" }, { "option": 
"operation", "value": "deleteSecret", "carriers": [ "pojo" ], "javaType": 
"com.azu [...]
   },
   "properties": {
     "vaultName": { "index": 0, "kind": "path", "displayName": "Vault Name", 
"group": "producer", "label": "", "required": false, "type": "string", 
"javaType": "java.lang.String", "deprecated": false, "autowired": false, 
"secret": false, "configurationClass": 
"org.apache.camel.component.azure.key.vault.KeyVaultConfiguration", 
"configurationField": "configuration", "description": "Vault Name to be used" },
diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/azure-key-vault-component.adoc
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/azure-key-vault-component.adoc
index 656f79d557f3..83691c1c5bd3 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/azure-key-vault-component.adoc
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/azure-key-vault-component.adoc
@@ -596,7 +596,10 @@ Azure Key Vault component provides the following operation 
on the producer side:
 
 === Producer Examples
 
-- createSecret: this operation will create a secret in Azure Key Vault
+- createSecret: this operation will create a secret in Azure Key Vault. The 
message body is the value of the secret:
+text, bytes, a stream or a file is stored as its text, and a `Map`, a `List` 
or a POJO as JSON, such as
+`{"username":"admin","password":"secret"}`, of which the `azure:` properties 
function can read one field, as in
+`{{azure:database#username}}`. The message body after the operation is the 
`KeyVaultSecret` that was stored.
 
 [tabs]
 ====
@@ -695,7 +698,8 @@ YAML::
 ----
 ====
 
-- deleteSecret: this operation will delete a Secret from Azure Key Vault
+- deleteSecret: this operation will delete a Secret from Azure Key Vault. It 
waits until the secret is deleted, and the
+message body is then the `DeletedSecret`, with the recovery id and the dates 
of the deletion and of the scheduled purge.
 
 [tabs]
 ====
diff --git 
a/components/camel-azure/camel-azure-key-vault/src/generated/resources/META-INF/org/apache/camel/component/azure/key/vault/azure-key-vault.json
 
b/components/camel-azure/camel-azure-key-vault/src/generated/resources/META-INF/org/apache/camel/component/azure/key/vault/azure-key-vault.json
index fd99a07c52ad..38cdbe72385d 100644
--- 
a/components/camel-azure/camel-azure-key-vault/src/generated/resources/META-INF/org/apache/camel/component/azure/key/vault/azure-key-vault.json
+++ 
b/components/camel-azure/camel-azure-key-vault/src/generated/resources/META-INF/org/apache/camel/component/azure/key/vault/azure-key-vault.json
@@ -34,8 +34,8 @@
     "CamelAzureKeyVaultSecretProperties": { "index": 2, "kind": "header", 
"displayName": "", "group": "producer", "label": "producer", "required": false, 
"javaType": "String", "deprecated": false, "deprecationNote": "", "autowired": 
false, "secret": false, "description": "The secret properties to be used in Key 
Vault for updating a secret", "constantName": 
"org.apache.camel.component.azure.key.vault.KeyVaultConstants#SECRET_PROPERTIES"
 }
   },
   "bodyTypes": {
-    "producerIn": { "carriers": [ "text", "bytes", "stream", "file" ], 
"byOption": [ { "option": "operation", "value": "getSecret", "carriers": [ 
"any" ] }, { "option": "operation", "value": "updateSecretProperties", 
"carriers": [ "any" ] }, { "option": "operation", "value": "deleteSecret", 
"carriers": [ "any" ] }, { "option": "operation", "value": 
"purgeDeletedSecret", "carriers": [ "any" ] } ], "description": "The value of 
the secret for createSecret, the default, read as text: a Map o [...]
-    "producerOut": { "carriers": [ "pojo" ], "javaType": 
"com.azure.security.keyvault.secrets.models.KeyVaultSecret", "byOption": [ { 
"option": "operation", "value": "getSecret", "carriers": [ "text" ], 
"javaType": "java.lang.String" }, { "option": "operation", "value": 
"updateSecretProperties", "carriers": [ "pojo" ], "javaType": 
"com.azure.security.keyvault.secrets.models.SecretProperties" }, { "option": 
"operation", "value": "deleteSecret", "carriers": [ "null" ] }, { "option": 
"opera [...]
+    "producerIn": { "carriers": [ "text", "bytes", "stream", "file", "map", 
"list", "pojo" ], "byOption": [ { "option": "operation", "value": "getSecret", 
"carriers": [ "any" ] }, { "option": "operation", "value": 
"updateSecretProperties", "carriers": [ "any" ] }, { "option": "operation", 
"value": "deleteSecret", "carriers": [ "any" ] }, { "option": "operation", 
"value": "purgeDeletedSecret", "carriers": [ "any" ] } ], "description": "The 
value of the secret for createSecret, the default [...]
+    "producerOut": { "carriers": [ "pojo" ], "javaType": 
"com.azure.security.keyvault.secrets.models.KeyVaultSecret", "byOption": [ { 
"option": "operation", "value": "getSecret", "carriers": [ "text" ], 
"javaType": "java.lang.String" }, { "option": "operation", "value": 
"updateSecretProperties", "carriers": [ "pojo" ], "javaType": 
"com.azure.security.keyvault.secrets.models.SecretProperties" }, { "option": 
"operation", "value": "deleteSecret", "carriers": [ "pojo" ], "javaType": 
"com.azu [...]
   },
   "properties": {
     "vaultName": { "index": 0, "kind": "path", "displayName": "Vault Name", 
"group": "producer", "label": "", "required": false, "type": "string", 
"javaType": "java.lang.String", "deprecated": false, "autowired": false, 
"secret": false, "configurationClass": 
"org.apache.camel.component.azure.key.vault.KeyVaultConfiguration", 
"configurationField": "configuration", "description": "Vault Name to be used" },
diff --git 
a/components/camel-azure/camel-azure-key-vault/src/main/docs/azure-key-vault-component.adoc
 
b/components/camel-azure/camel-azure-key-vault/src/main/docs/azure-key-vault-component.adoc
index 656f79d557f3..83691c1c5bd3 100644
--- 
a/components/camel-azure/camel-azure-key-vault/src/main/docs/azure-key-vault-component.adoc
+++ 
b/components/camel-azure/camel-azure-key-vault/src/main/docs/azure-key-vault-component.adoc
@@ -596,7 +596,10 @@ Azure Key Vault component provides the following operation 
on the producer side:
 
 === Producer Examples
 
-- createSecret: this operation will create a secret in Azure Key Vault
+- createSecret: this operation will create a secret in Azure Key Vault. The 
message body is the value of the secret:
+text, bytes, a stream or a file is stored as its text, and a `Map`, a `List` 
or a POJO as JSON, such as
+`{"username":"admin","password":"secret"}`, of which the `azure:` properties 
function can read one field, as in
+`{{azure:database#username}}`. The message body after the operation is the 
`KeyVaultSecret` that was stored.
 
 [tabs]
 ====
@@ -695,7 +698,8 @@ YAML::
 ----
 ====
 
-- deleteSecret: this operation will delete a Secret from Azure Key Vault
+- deleteSecret: this operation will delete a Secret from Azure Key Vault. It 
waits until the secret is deleted, and the
+message body is then the `DeletedSecret`, with the recovery id and the dates 
of the deletion and of the scheduled purge.
 
 [tabs]
 ====
diff --git 
a/components/camel-azure/camel-azure-key-vault/src/main/java/org/apache/camel/component/azure/key/vault/KeyVaultEndpoint.java
 
b/components/camel-azure/camel-azure-key-vault/src/main/java/org/apache/camel/component/azure/key/vault/KeyVaultEndpoint.java
index cd63897caa30..17f6d59ad238 100644
--- 
a/components/camel-azure/camel-azure-key-vault/src/main/java/org/apache/camel/component/azure/key/vault/KeyVaultEndpoint.java
+++ 
b/components/camel-azure/camel-azure-key-vault/src/main/java/org/apache/camel/component/azure/key/vault/KeyVaultEndpoint.java
@@ -50,7 +50,8 @@ import org.apache.camel.util.ObjectHelper;
 @Metadata(annotations = {
         "vault=azure-key-vault",
 })
-@BodyTypes(producerIn = @BodyType(value = { Carrier.TEXT, Carrier.BYTES, 
Carrier.STREAM, Carrier.FILE },
+@BodyTypes(producerIn = @BodyType(value = {
+        Carrier.TEXT, Carrier.BYTES, Carrier.STREAM, Carrier.FILE, 
Carrier.MAP, Carrier.LIST, Carrier.POJO },
                                   byOption = {
                                           @BodyType.When(option = "operation", 
value = "getSecret", carriers = Carrier.ANY),
                                           @BodyType.When(option = "operation", 
value = "updateSecretProperties",
@@ -58,11 +59,13 @@ import org.apache.camel.util.ObjectHelper;
                                           @BodyType.When(option = "operation", 
value = "deleteSecret", carriers = Carrier.ANY),
                                           @BodyType.When(option = "operation", 
value = "purgeDeletedSecret",
                                                          carriers = 
Carrier.ANY) },
-                                  description = "The value of the secret for 
createSecret, the default, read as text: a Map or"
-                                                + " a POJO is stored as its 
toString(), and no body fails. The name of the secret"
-                                                + " is in the 
CamelAzureKeyVaultSecretName header, and updateSecretProperties"
-                                                + " takes the SecretProperties 
from the CamelAzureKeyVaultSecretProperties header:"
-                                                + " the other operations do 
not use the body"),
+                                  description = "The value of the secret for 
createSecret, the default: text, bytes, a stream"
+                                                + " or a file is read as text, 
and a Map, a List or a POJO is stored as JSON, of"
+                                                + " which the azure properties 
function can read a field. No body fails. The name"
+                                                + " of the secret is in the 
CamelAzureKeyVaultSecretName header, and"
+                                                + " updateSecretProperties 
takes the SecretProperties from the"
+                                                + " 
CamelAzureKeyVaultSecretProperties header: the other operations do not use"
+                                                + " the body"),
            producerOut = @BodyType(value = Carrier.POJO,
                                    javaType = 
"com.azure.security.keyvault.secrets.models.KeyVaultSecret",
                                    byOption = {
@@ -72,13 +75,15 @@ import org.apache.camel.util.ObjectHelper;
                                                           carriers = 
Carrier.POJO,
                                                           javaType = 
"com.azure.security.keyvault.secrets.models.SecretProperties"),
                                            @BodyType.When(option = 
"operation", value = "deleteSecret",
-                                                          carriers = 
Carrier.NULL),
+                                                          carriers = 
Carrier.POJO,
+                                                          javaType = 
"com.azure.security.keyvault.secrets.models.DeletedSecret"),
                                            @BodyType.When(option = 
"operation", value = "purgeDeletedSecret",
                                                           carriers = 
Carrier.UNCHANGED) },
                                    description = "The KeyVaultSecret of the 
SDK for createSecret, the default; the value of the"
                                                  + " secret as text for 
getSecret; the SecretProperties for"
-                                                 + " updateSecretProperties. 
No body after deleteSecret, which waits until the"
-                                                 + " secret is deleted"))
+                                                 + " updateSecretProperties; 
the DeletedSecret for deleteSecret, which waits until"
+                                                 + " the secret is deleted, 
with the recovery id and the dates of the deletion and"
+                                                 + " of the scheduled purge"))
 public class KeyVaultEndpoint extends DefaultEndpoint implements 
EndpointServiceLocation {
 
     private SecretClient secretClient;
diff --git 
a/components/camel-azure/camel-azure-key-vault/src/main/java/org/apache/camel/component/azure/key/vault/KeyVaultProducer.java
 
b/components/camel-azure/camel-azure-key-vault/src/main/java/org/apache/camel/component/azure/key/vault/KeyVaultProducer.java
index 23749cb1922e..a7b1d1a1693e 100644
--- 
a/components/camel-azure/camel-azure-key-vault/src/main/java/org/apache/camel/component/azure/key/vault/KeyVaultProducer.java
+++ 
b/components/camel-azure/camel-azure-key-vault/src/main/java/org/apache/camel/component/azure/key/vault/KeyVaultProducer.java
@@ -16,14 +16,31 @@
  */
 package org.apache.camel.component.azure.key.vault;
 
+import java.io.File;
+import java.io.InputStream;
+import java.io.Reader;
+import java.nio.file.Path;
+import java.util.Collection;
+import java.util.Map;
+
+import javax.xml.transform.Source;
+
 import com.azure.core.util.polling.SyncPoller;
 import com.azure.security.keyvault.secrets.models.DeletedSecret;
 import com.azure.security.keyvault.secrets.models.KeyVaultSecret;
 import com.azure.security.keyvault.secrets.models.SecretProperties;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializationFeature;
+import com.fasterxml.jackson.databind.json.JsonMapper;
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
 import org.apache.camel.Endpoint;
 import org.apache.camel.Exchange;
 import org.apache.camel.InvalidPayloadException;
 import org.apache.camel.Message;
+import org.apache.camel.StreamCache;
+import org.apache.camel.WrappedFile;
 import org.apache.camel.support.DefaultProducer;
 import org.apache.camel.util.ObjectHelper;
 
@@ -32,6 +49,12 @@ public class KeyVaultProducer extends DefaultProducer {
     public static final String MISSING_SECRET_NAME = "Secret Name must be 
specified";
     public static final String MISSING_SECRET_PROPERTIES = "Secret Properties 
must be specified";
 
+    // a date or a time, such as an OffsetDateTime in a Map, is written as 
ISO-8601 text
+    private static final ObjectMapper JSON_MAPPER = JsonMapper.builder()
+            .addModule(new JavaTimeModule())
+            .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
+            .build();
+
     public KeyVaultProducer(final Endpoint endpoint) {
         super(endpoint);
     }
@@ -66,11 +89,56 @@ public class KeyVaultProducer extends DefaultProducer {
             throw new IllegalArgumentException(MISSING_SECRET_NAME);
         }
         KeyVaultSecret p = getEndpoint().getSecretClient()
-                .setSecret(new KeyVaultSecret(secretName, 
exchange.getMessage().getMandatoryBody(String.class)));
+                .setSecret(new KeyVaultSecret(secretName, 
getSecretValue(exchange.getMessage())));
         Message message = getMessageForResponse(exchange);
         message.setBody(p);
     }
 
+    /**
+     * The value of the secret to create. Text, bytes, a stream or a file is 
read as text. A Map, a List or a POJO is
+     * stored as JSON, of which the azure properties function can read a 
field. Another value, such as a number, a UUID
+     * or an object that Jackson cannot serialize, is read as text, as before.
+     */
+    private static String getSecretValue(Message message) throws 
InvalidPayloadException {
+        final Object body = message.getBody();
+        if (body instanceof Map || body instanceof Collection) {
+            try {
+                return JSON_MAPPER.writeValueAsString(body);
+            } catch (JsonProcessingException e) {
+                throw new IllegalArgumentException(
+                        "The " + body.getClass().getName() + " body of 
createSecret cannot be stored as JSON", e);
+            }
+        }
+        if (body != null && !isPayload(body)) {
+            final JsonNode json = toJson(body);
+            // Jackson writes a value, such as a UUID, as a JSON string: it is 
stored as its text
+            if (json != null && json.isContainerNode()) {
+                return json.toString();
+            }
+        }
+        return message.getMandatoryBody(String.class);
+    }
+
+    /**
+     * Whether the body is a payload, which is read as text: text, bytes, a 
stream, a file, or an XML Source, which
+     * Jackson would write as a bean and not as the XML.
+     */
+    private static boolean isPayload(Object body) {
+        return body instanceof CharSequence || body instanceof byte[]
+                || body instanceof InputStream || body instanceof Reader || 
body instanceof StreamCache
+                || body instanceof File || body instanceof Path || body 
instanceof WrappedFile
+                || body instanceof Source;
+    }
+
+    private static JsonNode toJson(Object body) {
+        try {
+            return JSON_MAPPER.valueToTree(body);
+        } catch (IllegalArgumentException e) {
+            // not a POJO that Jackson can serialize, such as an object 
without properties
+            return null;
+        }
+    }
+
     private void getSecret(Exchange exchange) {
         final String secretName = 
exchange.getMessage().getHeader(KeyVaultConstants.SECRET_NAME, String.class);
         if (ObjectHelper.isEmpty(secretName)) {
@@ -101,9 +169,10 @@ public class KeyVaultProducer extends DefaultProducer {
         }
         SyncPoller<DeletedSecret, Void> p = getEndpoint().getSecretClient()
                 .beginDeleteSecret(secretName);
-        p.waitForCompletion();
+        // the final result of the poller is Void: the deleted secret is the 
value of the last poll response
+        DeletedSecret deletedSecret = p.waitForCompletion().getValue();
         Message message = getMessageForResponse(exchange);
-        message.setBody(p.getFinalResult());
+        message.setBody(deletedSecret);
     }
 
     private void purgeDeletedSecret(Exchange exchange) {
diff --git 
a/components/camel-azure/camel-azure-key-vault/src/test/java/org/apache/camel/component/azure/key/vault/KeyVaultProducerTest.java
 
b/components/camel-azure/camel-azure-key-vault/src/test/java/org/apache/camel/component/azure/key/vault/KeyVaultProducerTest.java
new file mode 100644
index 000000000000..725f40576e05
--- /dev/null
+++ 
b/components/camel-azure/camel-azure-key-vault/src/test/java/org/apache/camel/component/azure/key/vault/KeyVaultProducerTest.java
@@ -0,0 +1,310 @@
+/*
+ * 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.azure.key.vault;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+import javax.xml.transform.dom.DOMSource;
+
+import org.w3c.dom.Document;
+
+import com.azure.core.http.HttpClient;
+import com.azure.core.http.HttpPipelineBuilder;
+import com.azure.security.keyvault.secrets.SecretClient;
+import com.azure.security.keyvault.secrets.SecretClientBuilder;
+import com.azure.security.keyvault.secrets.models.DeletedSecret;
+import com.azure.security.keyvault.secrets.models.KeyVaultSecret;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+import org.apache.camel.BindToRegistry;
+import org.apache.camel.Exchange;
+import org.apache.camel.InvalidPayloadException;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+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.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The producer with the real SecretClient against a fake of the REST API of 
Key Vault. deleteSecret gives the
+ * DeletedSecret, and createSecret stores a Map, a List or a POJO as JSON, and 
text, bytes, a stream or a file as text.
+ */
+class KeyVaultProducerTest extends CamelTestSupport {
+
+    private static final ObjectMapper MAPPER = new ObjectMapper();
+    private static final long DELETED_ON = 1700000000L;
+    private static final long PURGE_ON = 1707776000L;
+    // as Key Vault sends them: the SDK needs the recovery level of a deleted 
secret
+    private static final Map<String, Object> ATTRIBUTES = Map.of(
+            "enabled", true, "created", DELETED_ON, "updated", DELETED_ON, 
"recoveryLevel", "Recoverable+Purgeable");
+
+    private final FakeKeyVault vault = new FakeKeyVault();
+
+    @BindToRegistry("secretClient")
+    private final SecretClient secretClient = new SecretClientBuilder()
+            .vaultUrl(vault.url())
+            .pipeline(new 
HttpPipelineBuilder().httpClient(HttpClient.createDefault()).build())
+            .buildClient();
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                from("direct:createSecret")
+                        .setHeader(KeyVaultConstants.SECRET_NAME, 
constant("mySecret"))
+                        
.to("azure-key-vault:myVault?secretClient=#secretClient&operation=createSecret");
+                from("direct:deleteSecret")
+                        .setHeader(KeyVaultConstants.SECRET_NAME, 
constant("mySecret"))
+                        
.to("azure-key-vault:myVault?secretClient=#secretClient&operation=deleteSecret");
+            }
+        };
+    }
+
+    @AfterEach
+    void stopVault() {
+        vault.stop();
+    }
+
+    @Test
+    void deleteSecretGivesTheDeletedSecret() {
+        Exchange exchange = template.send("direct:deleteSecret", e -> 
e.getIn().setBody("ignored"));
+
+        assertNull(exchange.getException());
+        DeletedSecret deleted = assertInstanceOf(DeletedSecret.class, 
exchange.getMessage().getBody());
+        assertEquals("mySecret", deleted.getName());
+        assertEquals(vault.url() + "/deletedsecrets/mySecret", 
deleted.getRecoveryId());
+        
assertEquals(OffsetDateTime.ofInstant(Instant.ofEpochSecond(DELETED_ON), 
ZoneOffset.UTC), deleted.getDeletedOn());
+        assertEquals(OffsetDateTime.ofInstant(Instant.ofEpochSecond(PURGE_ON), 
ZoneOffset.UTC),
+                deleted.getScheduledPurgeDate());
+        assertEquals(List.of("DELETE /secrets/mySecret", "GET 
/deletedsecrets/mySecret"), vault.requests);
+    }
+
+    @Test
+    void mapIsStoredAsJson() throws Exception {
+        Map<String, Object> credentials = new LinkedHashMap<>();
+        credentials.put("username", "admin");
+        credentials.put("password", "s3cr3t");
+        credentials.put("port", 5432);
+
+        Exchange exchange = template.send("direct:createSecret", e -> 
e.getIn().setBody(credentials));
+
+        assertNull(exchange.getException());
+        
assertEquals("{\"username\":\"admin\",\"password\":\"s3cr3t\",\"port\":5432}", 
vault.storedValue());
+        // the JSON reads back as the Map
+        assertEquals(credentials, MAPPER.readValue(vault.storedValue(), 
Map.class));
+        KeyVaultSecret secret = assertInstanceOf(KeyVaultSecret.class, 
exchange.getMessage().getBody());
+        assertEquals("mySecret", secret.getName());
+        assertEquals(vault.storedValue(), secret.getValue());
+    }
+
+    @Test
+    void pojoIsStoredAsJson() {
+        Exchange exchange = template.send("direct:createSecret",
+                e -> e.getIn().setBody(new Credentials("admin", "s3cr3t")));
+
+        assertNull(exchange.getException());
+        assertEquals("{\"username\":\"admin\",\"password\":\"s3cr3t\"}", 
vault.storedValue());
+    }
+
+    @Test
+    void listIsStoredAsJson() {
+        Exchange exchange = template.send("direct:createSecret", e -> 
e.getIn().setBody(List.of("10.0.0.1", "10.0.0.2")));
+
+        assertNull(exchange.getException());
+        assertEquals("[\"10.0.0.1\",\"10.0.0.2\"]", vault.storedValue());
+    }
+
+    @Test
+    void mapThatIsNotJsonFails() {
+        Map<String, Object> body = Map.of("value", new Object());
+
+        Exchange exchange = template.send("direct:createSecret", e -> 
e.getIn().setBody(body));
+
+        IllegalArgumentException error = 
assertInstanceOf(IllegalArgumentException.class, exchange.getException());
+        assertTrue(error.getMessage().contains("body of createSecret cannot be 
stored as JSON"), error.getMessage());
+        assertTrue(vault.requests.isEmpty());
+    }
+
+    @Test
+    void dateAndTimeInAMapOrPojoAreStoredAsIsoText() {
+        OffsetDateTime expires = OffsetDateTime.of(2026, 1, 1, 12, 30, 0, 0, 
ZoneOffset.UTC);
+        Map<String, Object> token = new LinkedHashMap<>();
+        token.put("token", "abc");
+        token.put("expires", expires);
+
+        Exchange exchange = template.send("direct:createSecret", e -> 
e.getIn().setBody(token));
+        assertNull(exchange.getException());
+        
assertEquals("{\"token\":\"abc\",\"expires\":\"2026-01-01T12:30:00Z\"}", 
vault.storedValue());
+
+        exchange = template.send("direct:createSecret", e -> 
e.getIn().setBody(new Lease("abc", LocalDate.of(2026, 1, 31))));
+        assertNull(exchange.getException());
+        assertEquals("{\"token\":\"abc\",\"until\":\"2026-01-31\"}", 
vault.storedValue());
+    }
+
+    @Test
+    void textBytesStreamAndFileAreStoredAsText(@TempDir Path dir) throws 
Exception {
+        String value = "pässwörd-€";
+
+        template.sendBody("direct:createSecret", value);
+        assertEquals(value, vault.storedValue());
+
+        template.sendBody("direct:createSecret", 
value.getBytes(StandardCharsets.UTF_8));
+        assertEquals(value, vault.storedValue());
+
+        template.sendBody("direct:createSecret", new 
ByteArrayInputStream(value.getBytes(StandardCharsets.UTF_8)));
+        assertEquals(value, vault.storedValue());
+
+        Path file = Files.writeString(dir.resolve("secret.txt"), value);
+        template.sendBody("direct:createSecret", file.toFile());
+        assertEquals(value, vault.storedValue());
+    }
+
+    @Test
+    void xmlIsStoredAsItsText() throws Exception {
+        String xml = "<credentials><username>admin</username></credentials>";
+        Document document = 
context.getTypeConverter().mandatoryConvertTo(Document.class, xml);
+
+        // the type converter gives the XML text of a Source, which Jackson 
would write as a bean
+        template.sendBody("direct:createSecret", new DOMSource(document));
+        assertEquals(xml, vault.storedValue());
+
+        // and of a DOM node, which Jackson writes as a JSON string
+        template.sendBody("direct:createSecret", document);
+        assertEquals(xml, vault.storedValue());
+    }
+
+    @Test
+    void valueIsStoredAsItsText() {
+        UUID uuid = UUID.randomUUID();
+        template.sendBody("direct:createSecret", uuid);
+        assertEquals(uuid.toString(), vault.storedValue());
+
+        template.sendBody("direct:createSecret", 42);
+        assertEquals("42", vault.storedValue());
+
+        OffsetDateTime dateTime = OffsetDateTime.of(2026, 1, 1, 12, 30, 0, 0, 
ZoneOffset.ofHours(1));
+        template.sendBody("direct:createSecret", dateTime);
+        assertEquals(dateTime.toString(), vault.storedValue());
+    }
+
+    @Test
+    void noBodyFails() {
+        Exchange exchange = template.send("direct:createSecret", e -> 
e.getIn().setBody(null));
+
+        assertInstanceOf(InvalidPayloadException.class, 
exchange.getException());
+        assertTrue(vault.requests.isEmpty());
+    }
+
+    record Credentials(String username, String password) {
+    }
+
+    record Lease(String token, LocalDate until) {
+    }
+
+    /**
+     * The REST API of Key Vault for setSecret, deleteSecret and the poll of 
the deleted secret.
+     */
+    static final class FakeKeyVault {
+
+        final List<String> requests = new CopyOnWriteArrayList<>();
+        private final List<String> storedValues = new CopyOnWriteArrayList<>();
+        private final HttpServer server;
+
+        FakeKeyVault() {
+            try {
+                server = HttpServer.create(new 
InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
+            } catch (IOException e) {
+                throw new IllegalStateException(e);
+            }
+            server.createContext("/", this::handle);
+            server.start();
+        }
+
+        String url() {
+            return "http://localhost:"; + server.getAddress().getPort();
+        }
+
+        String storedValue() {
+            assertFalse(storedValues.isEmpty(), "no secret was stored");
+            return storedValues.get(storedValues.size() - 1);
+        }
+
+        void stop() {
+            server.stop(0);
+        }
+
+        private void handle(HttpExchange exchange) throws IOException {
+            String method = exchange.getRequestMethod();
+            String path = exchange.getRequestURI().getPath();
+            requests.add(method + " " + path);
+            String name = path.substring(path.lastIndexOf('/') + 1);
+            String response;
+            if ("PUT".equals(method) && path.startsWith("/secrets/")) {
+                String value = 
MAPPER.readTree(exchange.getRequestBody()).get("value").asText();
+                storedValues.add(value);
+                Map<String, Object> bundle = new LinkedHashMap<>();
+                bundle.put("value", value);
+                bundle.put("id", url() + "/secrets/" + name + "/1");
+                bundle.put("attributes", ATTRIBUTES);
+                response = MAPPER.writeValueAsString(bundle);
+            } else if ("DELETE".equals(method) && path.startsWith("/secrets/")
+                    || "GET".equals(method) && 
path.startsWith("/deletedsecrets/")) {
+                Map<String, Object> bundle = new LinkedHashMap<>();
+                bundle.put("recoveryId", url() + "/deletedsecrets/" + name);
+                bundle.put("deletedDate", DELETED_ON);
+                bundle.put("scheduledPurgeDate", PURGE_ON);
+                bundle.put("id", url() + "/secrets/" + name + "/1");
+                bundle.put("attributes", ATTRIBUTES);
+                response = MAPPER.writeValueAsString(bundle);
+            } else {
+                exchange.sendResponseHeaders(404, -1);
+                exchange.close();
+                return;
+            }
+            byte[] bytes = response.getBytes(StandardCharsets.UTF_8);
+            exchange.getResponseHeaders().set("Content-Type", 
"application/json");
+            exchange.sendResponseHeaders(200, bytes.length);
+            try (OutputStream out = exchange.getResponseBody()) {
+                out.write(bytes);
+            }
+        }
+    }
+}
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 d140e03fee39..f94f49502e3e 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
@@ -3599,6 +3599,20 @@ in full and rejected by the broker.
 
 A settlement request the broker rejects is now reported through the consumer's 
exception handler.
 
+=== camel-azure-key-vault - the body after deleteSecret, and a Map or a POJO 
given to createSecret
+
+The `deleteSecret` operation now sets the `DeletedSecret` of the Azure SDK as 
the message body, with the recovery id and
+the dates of the deletion and of the scheduled purge. Before, the message body 
after `deleteSecret` was always `null`.
+A route that relied on an empty body after `deleteSecret` must now clear the 
body itself.
+
+The `createSecret` operation now stores a `Map`, a `List` or a POJO body as 
JSON, such as
+`{"username":"admin","password":"secret"}`, with dates and times as ISO-8601 
text. Before, it stored the text the type
+converter gives (usually `toString()`), such as `{username=admin, 
password=secret}`. A POJO that has its own type
+converter to `String`, or a JAXB object, is now stored as the JSON of its 
properties as well: to store the text of the
+converter, convert the body to a `String` first, for example with 
`convertBodyTo(String.class)`. A `Map` or a `List`
+that Jackson cannot serialize now fails with an `IllegalArgumentException`. 
Text, bytes, a stream or a file is stored
+as its text, as before.
+
 === camel-nats - the JetStream pull consumer subscribes again when its 
ephemeral consumer was deleted
 
 With `jetstreamEnabled=true` and a pull subscription (the default), the `nats` 
consumer kept fetching from a JetStream

Reply via email to