This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch camel-4.18.x
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/camel-4.18.x by this push:
new aeb7c2151add camel-google fixes backport to camel-4.18.x (CAMEL-24344,
CAMEL-24345)
aeb7c2151add is described below
commit aeb7c2151add8ff35e81998882ee881a9edcd7ca
Author: Andrea Cosentino <[email protected]>
AuthorDate: Thu Aug 27 16:46:19 2026 +0200
camel-google fixes backport to camel-4.18.x (CAMEL-24344, CAMEL-24345)
Backport to camel-4.18.x of two camel-google audit fixes that never
reached this branch (both already on main and camel-4.22.x, predating
the 4.22.0 cut):
- CAMEL-24344: camel-google-mail's stream consumer always requested the
FULL Gmail message format, so raw=true produced a null body, and the
non-raw path only read the first MIME part, losing non-multipart and
nested-multipart bodies. Cherry-pick of e8dc6b8 (#25357).
- CAMEL-24345: camel-google-vertexai's jsonMode option was declared but
never applied to the request MIME type, and buildRawPredictRequestBody
threw an NPE with no body set. Hand-ported (partial) from a2ab8280
(#25362) — the streamOutputMode half is left out since
generateChatStreaming is not implemented on this branch.
Closes #25814
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
components/camel-google/camel-google-mail/pom.xml | 5 +
.../mail/stream/GoogleMailStreamConsumer.java | 57 +++++++--
.../stream/GoogleMailStreamConsumerBodyTest.java | 128 +++++++++++++++++++++
.../camel-google/camel-google-vertexai/pom.xml | 5 +
.../google/vertexai/GoogleVertexAIOperations.java | 6 +-
.../google/vertexai/GoogleVertexAIProducer.java | 10 +-
.../GoogleVertexAIProducerOptionsTest.java | 66 +++++++++++
7 files changed, 265 insertions(+), 12 deletions(-)
diff --git a/components/camel-google/camel-google-mail/pom.xml
b/components/camel-google/camel-google-mail/pom.xml
index 01357022ac68..125e5ec8533f 100644
--- a/components/camel-google/camel-google-mail/pom.xml
+++ b/components/camel-google/camel-google-mail/pom.xml
@@ -150,6 +150,11 @@
<artifactId>commons-codec</artifactId>
<scope>test</scope>
</dependency>
+ <dependency>
+ <groupId>org.assertj</groupId>
+ <artifactId>assertj-core</artifactId>
+ <scope>test</scope>
+ </dependency>
</dependencies>
diff --git
a/components/camel-google/camel-google-mail/src/main/java/org/apache/camel/component/google/mail/stream/GoogleMailStreamConsumer.java
b/components/camel-google/camel-google-mail/src/main/java/org/apache/camel/component/google/mail/stream/GoogleMailStreamConsumer.java
index aba62302a233..1708e1042cd5 100644
---
a/components/camel-google/camel-google-mail/src/main/java/org/apache/camel/component/google/mail/stream/GoogleMailStreamConsumer.java
+++
b/components/camel-google/camel-google-mail/src/main/java/org/apache/camel/component/google/mail/stream/GoogleMailStreamConsumer.java
@@ -92,7 +92,8 @@ public class GoogleMailStreamConsumer extends
ScheduledBatchPollingConsumer {
if (c.getMessages() != null) {
for (Message message : c.getMessages()) {
- Message mess = getClient().users().messages().get("me",
message.getId()).setFormat("FULL").execute();
+ Message mess
+ = getClient().users().messages().get("me",
message.getId()).setFormat(messageFormat()).execute();
Exchange exchange =
createExchange(getEndpoint().getExchangePattern(), mess);
answer.add(exchange);
}
@@ -167,8 +168,12 @@ public class GoogleMailStreamConsumer extends
ScheduledBatchPollingConsumer {
* Strategy when processing the exchange failed.
*/
protected void processRollback(Exchange exchange, String unreadLabelId) {
+ if (!getConfiguration().isMarkAsRead()) {
+ // the mail was never marked as read, so there is nothing to roll
back
+ return;
+ }
try {
- LOG.warn("Exchange failed, so rolling back mail {} to un {}",
exchange, unreadLabelId);
+ LOG.warn("Exchange failed, so marking mail {} as unread again",
exchange);
List<String> add = new ArrayList<>();
add.add(unreadLabelId);
@@ -176,7 +181,8 @@ public class GoogleMailStreamConsumer extends
ScheduledBatchPollingConsumer {
getClient().users().messages()
.modify("me",
exchange.getIn().getHeader(GoogleMailStreamConstants.MAIL_ID, String.class),
mods).execute();
} catch (Exception e) {
- getExceptionHandler().handleException("Error occurred mark as read
mail. This exception is ignored.", exchange, e);
+ getExceptionHandler().handleException("Error occurred marking the
mail as unread. This exception is ignored.",
+ exchange, e);
}
}
@@ -188,17 +194,52 @@ public class GoogleMailStreamConsumer extends
ScheduledBatchPollingConsumer {
if (getConfiguration().isRaw()) {
message.setBody(mail.getRaw());
} else {
- List<MessagePart> parts = mail.getPayload().getParts();
- if (parts != null && parts.get(0).getBody().getData() != null) {
- byte[] bodyBytes =
Base64.decodeBase64(parts.get(0).getBody().getData().trim());
- String body = new String(bodyBytes, StandardCharsets.UTF_8);
+ String body = extractBody(mail.getPayload());
+ if (body != null) {
message.setBody(body);
}
}
- configureHeaders(message, mail.getPayload().getHeaders());
+ if (mail.getPayload() != null && mail.getPayload().getHeaders() !=
null) {
+ configureHeaders(message, mail.getPayload().getHeaders());
+ }
return exchange;
}
+ /**
+ * The message format the consumer has to ask for. The raw field of a
message is only populated when the RAW format
+ * is requested, the payload only when the FULL format is.
+ */
+ String messageFormat() {
+ return getConfiguration().isRaw() ? "RAW" : "FULL";
+ }
+
+ /**
+ * Returns the decoded content of the first part carrying data, walking
into nested multiparts. A message that is
+ * not multipart at all keeps its content directly on the payload.
+ */
+ private String extractBody(MessagePart part) {
+ if (part == null) {
+ return null;
+ }
+
+ if (part.getBody() != null && part.getBody().getData() != null) {
+ byte[] bodyBytes =
Base64.decodeBase64(part.getBody().getData().trim());
+ return new String(bodyBytes, StandardCharsets.UTF_8);
+ }
+
+ List<MessagePart> parts = part.getParts();
+ if (parts != null) {
+ for (MessagePart child : parts) {
+ String body = extractBody(child);
+ if (body != null) {
+ return body;
+ }
+ }
+ }
+
+ return null;
+ }
+
private void configureHeaders(org.apache.camel.Message message,
List<MessagePartHeader> headers) {
for (MessagePartHeader header : headers) {
String headerName = header.getName();
diff --git
a/components/camel-google/camel-google-mail/src/test/java/org/apache/camel/component/google/mail/stream/GoogleMailStreamConsumerBodyTest.java
b/components/camel-google/camel-google-mail/src/test/java/org/apache/camel/component/google/mail/stream/GoogleMailStreamConsumerBodyTest.java
new file mode 100644
index 000000000000..7b960e0752f3
--- /dev/null
+++
b/components/camel-google/camel-google-mail/src/test/java/org/apache/camel/component/google/mail/stream/GoogleMailStreamConsumerBodyTest.java
@@ -0,0 +1,128 @@
+/*
+ * 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.google.mail.stream;
+
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+
+import com.google.api.client.util.Base64;
+import com.google.api.services.gmail.model.Message;
+import com.google.api.services.gmail.model.MessagePart;
+import com.google.api.services.gmail.model.MessagePartBody;
+import com.google.api.services.gmail.model.MessagePartHeader;
+import org.apache.camel.Exchange;
+import org.apache.camel.ExchangePattern;
+import org.apache.camel.impl.DefaultCamelContext;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+
+/**
+ * Verifies how the stream consumer turns a Gmail message into an exchange:
which format it asks the API for, and where
+ * it picks the body up from.
+ */
+class GoogleMailStreamConsumerBodyTest {
+
+ private DefaultCamelContext context;
+
+ @AfterEach
+ void tearDown() {
+ if (context != null) {
+ context.stop();
+ }
+ }
+
+ private GoogleMailStreamConsumer consumer(boolean raw) throws Exception {
+ if (context != null) {
+ context.stop();
+ }
+ context = new DefaultCamelContext();
+ context.start();
+ GoogleMailStreamEndpoint endpoint = context.getEndpoint(
+
"google-mail-stream://index?clientId=id&clientSecret=secret&raw=" + raw,
+ GoogleMailStreamEndpoint.class);
+ return new GoogleMailStreamConsumer(endpoint, exchange -> {
+ }, "UNREAD", List.of());
+ }
+
+ private static MessagePartBody body(String content) {
+ return new
MessagePartBody().setData(Base64.encodeBase64URLSafeString(content.getBytes(StandardCharsets.UTF_8)));
+ }
+
+ @Test
+ void theRawOptionAsksForTheRawFormat() throws Exception {
+ // the raw field of a message is only returned for the RAW format,
asking for FULL always left it null
+ assertThat(consumer(true).messageFormat()).isEqualTo("RAW");
+ assertThat(consumer(false).messageFormat()).isEqualTo("FULL");
+ }
+
+ @Test
+ void aNonMultipartMessageKeepsItsBody() throws Exception {
+ Message mail = new Message().setId("1").setThreadId("t1")
+ .setPayload(new
MessagePart().setMimeType("text/plain").setBody(body("plain content")));
+
+ Exchange exchange =
consumer(false).createExchange(ExchangePattern.InOnly, mail);
+
+ assertThat(exchange.getIn().getBody()).isEqualTo("plain content");
+ }
+
+ @Test
+ void aMultipartMessageUsesTheFirstPartCarryingData() throws Exception {
+ Message mail = new Message().setId("2").setPayload(new
MessagePart().setMimeType("multipart/alternative")
+ .setParts(List.of(
+ new MessagePart().setMimeType("multipart/mixed")
+ .setParts(List.of(new
MessagePart().setMimeType("text/plain").setBody(body("nested")))),
+ new
MessagePart().setMimeType("text/html").setBody(body("<p>html</p>")))));
+
+ Exchange exchange =
consumer(false).createExchange(ExchangePattern.InOnly, mail);
+
+ assertThat(exchange.getIn().getBody()).isEqualTo("nested");
+ }
+
+ @Test
+ void aMessageWithoutPayloadIsNotAFailure() throws Exception {
+ Message mail = new Message().setId("3");
+
+ Exchange exchange =
consumer(false).createExchange(ExchangePattern.InOnly, mail);
+
+ assertThat(exchange.getIn().getBody()).isNull();
+
assertThat(exchange.getIn().getHeader(GoogleMailStreamConstants.MAIL_ID)).isEqualTo("3");
+ }
+
+ @Test
+ void headersAreMappedWhenPresent() throws Exception {
+ Message mail = new Message().setId("4").setPayload(new MessagePart()
+ .setBody(body("content"))
+ .setHeaders(List.of(
+ new MessagePartHeader().setName("Subject").setValue("a
subject"),
+ new
MessagePartHeader().setName("From").setValue("[email protected]"))));
+
+ Exchange exchange =
consumer(false).createExchange(ExchangePattern.InOnly, mail);
+
+
assertThat(exchange.getIn().getHeader(GoogleMailStreamConstants.MAIL_SUBJECT)).isEqualTo("a
subject");
+
assertThat(exchange.getIn().getHeader(GoogleMailStreamConstants.MAIL_FROM)).isEqualTo("[email protected]");
+ }
+
+ @Test
+ void aPayloadWithoutHeadersIsNotAFailure() throws Exception {
+ Message mail = new Message().setId("5").setPayload(new
MessagePart().setBody(body("content")));
+
+ assertThatCode(() ->
consumer(false).createExchange(ExchangePattern.InOnly,
mail)).doesNotThrowAnyException();
+ }
+}
diff --git a/components/camel-google/camel-google-vertexai/pom.xml
b/components/camel-google/camel-google-vertexai/pom.xml
index f8379e5eadca..444a07421533 100644
--- a/components/camel-google/camel-google-vertexai/pom.xml
+++ b/components/camel-google/camel-google-vertexai/pom.xml
@@ -99,5 +99,10 @@
<artifactId>camel-test-junit5</artifactId>
<scope>test</scope>
</dependency>
+ <dependency>
+ <groupId>org.assertj</groupId>
+ <artifactId>assertj-core</artifactId>
+ <scope>test</scope>
+ </dependency>
</dependencies>
</project>
diff --git
a/components/camel-google/camel-google-vertexai/src/main/java/org/apache/camel/component/google/vertexai/GoogleVertexAIOperations.java
b/components/camel-google/camel-google-vertexai/src/main/java/org/apache/camel/component/google/vertexai/GoogleVertexAIOperations.java
index 3644c852e615..429d3d791d20 100644
---
a/components/camel-google/camel-google-vertexai/src/main/java/org/apache/camel/component/google/vertexai/GoogleVertexAIOperations.java
+++
b/components/camel-google/camel-google-vertexai/src/main/java/org/apache/camel/component/google/vertexai/GoogleVertexAIOperations.java
@@ -30,7 +30,8 @@ public enum GoogleVertexAIOperations {
generateText,
/**
- * Generate chat response using Gemini models with conversation history.
+ * Generate a chat response using Gemini models. Alias of {@link
#generateText}: the request is built from the same
+ * prompt and configuration, the operation name only documents the intent
of the route.
*/
generateChat,
@@ -50,7 +51,8 @@ public enum GoogleVertexAIOperations {
generateEmbeddings,
/**
- * Generate code using Gemini or code-specialized models.
+ * Generate code using Gemini or code-specialized models. Alias of {@link
#generateText}: the model is selected with
+ * the modelId option, the operation name only documents the intent of the
route.
*/
generateCode,
diff --git
a/components/camel-google/camel-google-vertexai/src/main/java/org/apache/camel/component/google/vertexai/GoogleVertexAIProducer.java
b/components/camel-google/camel-google-vertexai/src/main/java/org/apache/camel/component/google/vertexai/GoogleVertexAIProducer.java
index 3bfb59ae94bc..9c60012d6adf 100644
---
a/components/camel-google/camel-google-vertexai/src/main/java/org/apache/camel/component/google/vertexai/GoogleVertexAIProducer.java
+++
b/components/camel-google/camel-google-vertexai/src/main/java/org/apache/camel/component/google/vertexai/GoogleVertexAIProducer.java
@@ -38,6 +38,7 @@ public class GoogleVertexAIProducer extends DefaultProducer {
private static final Logger LOG =
LoggerFactory.getLogger(GoogleVertexAIProducer.class);
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+ private static final String JSON_MIME_TYPE = "application/json";
private final GoogleVertexAIEndpoint endpoint;
@@ -260,7 +261,8 @@ public class GoogleVertexAIProducer extends DefaultProducer
{
}
throw new IllegalArgumentException(
- "Request body must be a JSON String, Map, or plain text
prompt. Got: " + body.getClass().getName());
+ "Request body must be a JSON String, Map, or plain text
prompt. Got: "
+ + (body == null ? "no body" :
body.getClass().getName()));
}
/**
@@ -355,7 +357,7 @@ public class GoogleVertexAIProducer extends DefaultProducer
{
return prompt;
}
- private GenerateContentConfig buildConfig(Exchange exchange) {
+ GenerateContentConfig buildConfig(Exchange exchange) {
GoogleVertexAIConfiguration config = endpoint.getConfiguration();
GenerateContentConfig.Builder configBuilder =
GenerateContentConfig.builder();
@@ -401,6 +403,10 @@ public class GoogleVertexAIProducer extends
DefaultProducer {
configBuilder.candidateCount(candidateCount);
}
+ if (config.isJsonMode()) {
+ configBuilder.responseMimeType(JSON_MIME_TYPE);
+ }
+
return configBuilder.build();
}
diff --git
a/components/camel-google/camel-google-vertexai/src/test/java/org/apache/camel/component/google/vertexai/GoogleVertexAIProducerOptionsTest.java
b/components/camel-google/camel-google-vertexai/src/test/java/org/apache/camel/component/google/vertexai/GoogleVertexAIProducerOptionsTest.java
new file mode 100644
index 000000000000..536728db7b8b
--- /dev/null
+++
b/components/camel-google/camel-google-vertexai/src/test/java/org/apache/camel/component/google/vertexai/GoogleVertexAIProducerOptionsTest.java
@@ -0,0 +1,66 @@
+/*
+ * 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.google.vertexai;
+
+import com.google.genai.types.GenerateContentConfig;
+import org.apache.camel.impl.DefaultCamelContext;
+import org.apache.camel.support.DefaultExchange;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Verifies that the producer applies the options that describe the shape of
the request and of the response. Uses
+ * direct object construction to avoid starting the endpoint (which requires
Google Cloud credentials).
+ */
+class GoogleVertexAIProducerOptionsTest {
+
+ private DefaultCamelContext context;
+
+ @AfterEach
+ void tearDown() {
+ if (context != null) {
+ context.stop();
+ }
+ }
+
+ private GoogleVertexAIProducer producer(GoogleVertexAIConfiguration
config) {
+ context = new DefaultCamelContext();
+ GoogleVertexAIComponent component = new
GoogleVertexAIComponent(context);
+ GoogleVertexAIEndpoint endpoint
+ = new
GoogleVertexAIEndpoint("google-vertexai:my-project:us-central1:gemini-2.0-flash",
component, config);
+ return new GoogleVertexAIProducer(endpoint);
+ }
+
+ @Test
+ void jsonModeAsksTheModelForJson() {
+ GoogleVertexAIConfiguration config = new GoogleVertexAIConfiguration();
+ config.setJsonMode(true);
+ GenerateContentConfig result = producer(config).buildConfig(new
DefaultExchange(context));
+
+ assertThat(result.responseMimeType()).contains("application/json");
+ }
+
+ @Test
+ void jsonModeIsOffByDefault() {
+ GoogleVertexAIConfiguration config = new GoogleVertexAIConfiguration();
+ GenerateContentConfig result = producer(config).buildConfig(new
DefaultExchange(context));
+
+ assertThat(result.responseMimeType()).isEmpty();
+ }
+}