This is an automated email from the ASF dual-hosted git repository.
oscerd pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel-kafka-connector.git
The following commit(s) were added to refs/heads/main by this push:
new 27fd4a2e06 Fix #1861: give Map and List record values a schema
converters can serialize (#1863)
27fd4a2e06 is described below
commit 27fd4a2e06eb9b12a6fa8c85fd36afa24e0c0125
Author: Andrea Cosentino <[email protected]>
AuthorDate: Thu Nov 5 12:02:03 2026 +0100
Fix #1861: give Map and List record values a schema converters can
serialize (#1863)
SchemaHelper gives a Map or List value a MAP or ARRAY schema without key
or value schema: its fallback builds one, and copySchemaBasics drops
them from an inferred schema. The JsonConverter then fails with a
NullPointerException on any source record whose body is a Map or List,
for example form fields from a webhook or an unmarshalled document.
CamelSourceTask now gives such a key or value the schema Kafka Connect
infers for it, which is complete, or no schema when the types cannot be
inferred, which converters handle as schemaless data.
Signed-off-by: Andrea Cosentino <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
---
.../camel/kafkaconnector/CamelSourceTask.java | 21 +++++++++-
.../camel/kafkaconnector/CamelSourceTaskTest.java | 46 ++++++++++++++++++++++
2 files changed, 65 insertions(+), 2 deletions(-)
diff --git
a/core/src/main/java/org/apache/camel/kafkaconnector/CamelSourceTask.java
b/core/src/main/java/org/apache/camel/kafkaconnector/CamelSourceTask.java
index 30597b3741..27dcbc8f30 100644
--- a/core/src/main/java/org/apache/camel/kafkaconnector/CamelSourceTask.java
+++ b/core/src/main/java/org/apache/camel/kafkaconnector/CamelSourceTask.java
@@ -40,6 +40,7 @@ import org.apache.camel.kafkaconnector.utils.TaskHelper;
import org.apache.camel.support.UnitOfWorkHelper;
import org.apache.kafka.clients.producer.RecordMetadata;
import org.apache.kafka.connect.data.Schema;
+import org.apache.kafka.connect.data.Values;
import org.apache.kafka.connect.errors.ConnectException;
import org.apache.kafka.connect.source.SourceRecord;
import org.apache.kafka.connect.source.SourceTask;
@@ -233,8 +234,8 @@ public class CamelSourceTask extends SourceTask {
final Object messageHeaderKey = camelMessageHeaderKey != null ?
exchange.getMessage().getHeader(camelMessageHeaderKey) : null;
final Object messageBodyValue = toRecordValue(exchange);
- final Schema messageKeySchema = messageHeaderKey != null ?
SchemaHelper.buildSchemaBuilderForType(messageHeaderKey) : null;
- final Schema messageBodySchema = messageBodyValue != null ?
SchemaHelper.buildSchemaBuilderForType(messageBodyValue) : null;
+ final Schema messageKeySchema = recordSchema(messageHeaderKey);
+ final Schema messageBodySchema = recordSchema(messageBodyValue);
final long timestamp = calculateTimestamp(exchange);
@@ -418,6 +419,22 @@ public class CamelSourceTask extends SourceTask {
}
}
+ /**
+ * Returns the schema of a record key or value. {@link SchemaHelper} gives
a {@link Map} or a {@link List} a MAP or
+ * ARRAY schema without key or value schema, which converters such as the
JsonConverter cannot serialize. Such a
+ * value gets the schema Kafka Connect infers for it, which is complete,
or no schema when its key and value types
+ * cannot be inferred, for example for a JSON object with values of
different types.
+ */
+ private static Schema recordSchema(Object value) {
+ if (value == null) {
+ return null;
+ }
+ if (value instanceof Map || value instanceof List) {
+ return Values.inferSchema(value);
+ }
+ return SchemaHelper.buildSchemaBuilderForType(value);
+ }
+
private String getLocalUrlWithPollingOptions(long
pollingConsumerQueueSize, long pollingConsumerBlockTimeout, boolean
pollingConsumerBlockWhenFull) {
return LOCAL_URL + "?pollingConsumerQueueSize=" +
pollingConsumerQueueSize + "&pollingConsumerBlockTimeout=" +
pollingConsumerBlockTimeout
+ "&pollingConsumerBlockWhenFull=" +
pollingConsumerBlockWhenFull;
diff --git
a/core/src/test/java/org/apache/camel/kafkaconnector/CamelSourceTaskTest.java
b/core/src/test/java/org/apache/camel/kafkaconnector/CamelSourceTaskTest.java
index 8defdbb799..e7a771d990 100644
---
a/core/src/test/java/org/apache/camel/kafkaconnector/CamelSourceTaskTest.java
+++
b/core/src/test/java/org/apache/camel/kafkaconnector/CamelSourceTaskTest.java
@@ -20,8 +20,11 @@ import java.io.ByteArrayInputStream;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutorService;
@@ -39,6 +42,8 @@ import org.apache.camel.support.SynchronizationAdapter;
import org.apache.kafka.connect.data.Decimal;
import org.apache.kafka.connect.data.Schema;
import org.apache.kafka.connect.header.Header;
+import org.apache.kafka.connect.json.JsonConverter;
+import org.apache.kafka.connect.json.JsonConverterConfig;
import org.apache.kafka.connect.source.SourceRecord;
import org.junit.jupiter.api.Test;
@@ -142,6 +147,47 @@ public class CamelSourceTaskTest {
sourceTask.stop();
}
+ @Test
+ public void testSourcePollingWithMapAndListBody() {
+ Map<String, String> props = new HashMap<>();
+ props.put(CamelSourceConnectorConfig.TOPIC_CONF, TOPIC_NAME);
+ props.put(CamelSourceConnectorConfig.CAMEL_SOURCE_URL_CONF,
DIRECT_URI);
+
+ CamelSourceTask sourceTask = new CamelSourceTask();
+ sourceTask.start(props);
+ try (JsonConverter converter = new JsonConverter()) {
+
converter.configure(Collections.singletonMap(JsonConverterConfig.SCHEMAS_ENABLE_CONFIG,
false), false);
+ final ProducerTemplate template =
sourceTask.getCms().getProducerTemplate();
+
+ // a JSON object with values of different types, such as form
fields or an unmarshalled document
+ Map<String, Object> map = new LinkedHashMap<>();
+ map.put("token", "abc");
+ map.put("count", 3);
+ template.sendBody(DIRECT_URI, map);
+ template.sendBody(DIRECT_URI, Arrays.asList("camel", 1, true));
+ // values whose types can be inferred keep a complete schema
+ template.sendBody(DIRECT_URI, Collections.singletonMap("name",
"camel"));
+
+ List<SourceRecord> poll = sourceTask.poll();
+ assertEquals(3, poll.size());
+
+ assertNull(poll.get(0).valueSchema());
+ assertEquals("{\"token\":\"abc\",\"count\":3}", toJson(converter,
poll.get(0)));
+ assertNull(poll.get(1).valueSchema());
+ assertEquals("[\"camel\",1,true]", toJson(converter, poll.get(1)));
+ assertEquals(Schema.Type.MAP, poll.get(2).valueSchema().type());
+ assertEquals(Schema.Type.STRING,
poll.get(2).valueSchema().keySchema().type());
+ assertEquals(Schema.Type.STRING,
poll.get(2).valueSchema().valueSchema().type());
+ assertEquals("{\"name\":\"camel\"}", toJson(converter,
poll.get(2)));
+ } finally {
+ sourceTask.stop();
+ }
+ }
+
+ private static String toJson(JsonConverter converter, SourceRecord record)
{
+ return new String(converter.fromConnectData(record.topic(),
record.valueSchema(), record.value()), StandardCharsets.UTF_8);
+ }
+
@Test
public void testSourcePollingWithKey() {
Map<String, String> props = new HashMap<>();