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