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

hansva pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git


The following commit(s) were added to refs/heads/main by this push:
     new 39d377c3f5 Issue #6408 : Read and write Kafka record headers (#7770)
39d377c3f5 is described below

commit 39d377c3f5ef77fec3a75f702a23703c61f76be5
Author: vbhanuchander-lang <[email protected]>
AuthorDate: Thu Aug 6 02:27:37 2026 -0400

    Issue #6408 : Read and write Kafka record headers (#7770)
    
    * Issue #6408 : Read Kafka record headers into an output field
    
    The Kafka Consumer exposes key, message, topic, partition, offset and 
timestamp,
    but not the record headers, so anything carried in a header - trace context,
    schema hints, routing metadata - is unreachable once a message is consumed.
    
    Add an optional Headers output field. It ships with an empty output name, 
and
    getRowMeta already omits fields without one, so a pipeline saved before this
    change produces exactly the row it did before.
    
    Headers are rendered as a JSON array of {"name":..,"value":..} objects 
rather
    than a JSON object. A record carries an ordered list of header pairs and the
    same name may appear more than once, so an object would silently drop 
repeats
    and lose ordering; an array keeps both, which also lets the value round-trip
    back out through the producer unchanged. Values are bytes on the wire and 
are
    decoded as UTF-8, with a null value rendered as JSON null.
    
    Building the row also had to change. putFieldOnRowMeta leaves out any field
    whose output name is empty, so the output row is variable length, but
    processMessageAsRow wrote its six values at fixed indices regardless. 
Clearing
    the name of, say, the topic field was therefore enough to shift every later
    column by one. Values are now placed only for fields that contribute a 
column,
    in the order getRowMeta adds them, which fixes that and is what lets the new
    field be optional at all.
    
    * Issue #6408 : Write Kafka record headers from a field
    
    Completes header support: the consumer can now read headers into a field 
and the
    producer can attach headers read back from one.
    
    The exchange format lives in a new shared KafkaHeaders class rather than in
    either transform, because it is a contract between the two: what the 
consumer
    writes into a field can be handed straight to the producer and yields the 
same
    headers, including ordering, repeated names, and the difference between a 
null
    and an empty value. Keeping both directions in one place stops them drifting
    apart, and the round trip is covered by a test.
    
    The producer's headers field is optional; leaving it empty sends messages
    without headers, so existing pipelines are unaffected. A field that is 
named but
    absent from the input stream fails when the first row arrives, matching how 
the
    key, message and topic fields already behave. A value that is not a JSON 
array
    of name/value objects fails with the offending document in the message.
    
    Also documents both transforms and makes the consumer's output field table
    editable. That table was already built with an editable name column and a
    disabled-listener limiting type changes, but the table itself was flagged
    read-only and the write-back left as a TODO, so field names could not 
actually
    be changed from the dialog - which would have left the new Headers field
    impossible to switch on outside metadata injection.
---
 .../pages/pipeline/transforms/kafkaconsumer.adoc   |   8 +-
 .../pages/pipeline/transforms/kafkaproducer.adoc   |   6 +
 .../kafka/consumer/KafkaConsumerField.java         |   3 +-
 .../kafka/consumer/KafkaConsumerInput.java         |  29 +++-
 .../kafka/consumer/KafkaConsumerInputDialog.java   |  30 +++-
 .../kafka/consumer/KafkaConsumerInputMeta.java     |  46 +++++-
 .../kafka/producer/KafkaProducerOutput.java        |  48 +++++-
 .../kafka/producer/KafkaProducerOutputData.java    |   1 +
 .../kafka/producer/KafkaProducerOutputDialog.java  |  42 ++++++
 .../kafka/producer/KafkaProducerOutputMeta.java    |   7 +
 .../transforms/kafka/shared/KafkaHeaders.java      | 115 ++++++++++++++
 .../consumer/messages/messages_en_US.properties    |   1 +
 .../producer/messages/messages_en_US.properties    |   4 +
 .../shared/messages/messages_en_US.properties      |  20 +++
 .../transforms/kafka/shared/KafkaHeadersTest.java  | 167 +++++++++++++++++++++
 15 files changed, 512 insertions(+), 15 deletions(-)

diff --git 
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/kafkaconsumer.adoc
 
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/kafkaconsumer.adoc
index 904caff683..94808fa8fb 100644
--- 
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/kafkaconsumer.adoc
+++ 
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/kafkaconsumer.adoc
@@ -134,8 +134,14 @@ You cannot have more consumers than the number of 
partitions.
 * **offset**: A sequential ID number assigned by Kafka to each record.
 It uniquely identifies each record within the partition.
 * **timestamp**: The time the message is received on the server.
+* **headers**: The record headers, as a JSON array of `{"name":..,"value":..}` 
objects.
+An array is used rather than a JSON object because Kafka keeps headers ordered 
and allows the same name to appear more than once.
+This value can be fed straight into the _Headers field_ of the Kafka Producer 
transform to pass headers through unchanged.
 
-|Output name|The _Output name_ can be mapped to subscriber and member 
requirements.
+|Output name a|The _Output name_ can be mapped to subscriber and member 
requirements.
+
+Leave the output name empty to keep that field off the output row.
+**headers** ships with an empty output name, so pipelines created before 
header support produce the same rows as before; give it a name to start reading 
headers.
 |Type a|The Type field defines the data format for streaming the record.
 You must choose the same data type that produced the records.
 This field applies to the ‘key’ and ‘message’ input names.
diff --git 
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/kafkaproducer.adoc
 
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/kafkaproducer.adoc
index fdb99fa38a..a5bcdcfb4e 100644
--- 
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/kafkaproducer.adoc
+++ 
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/kafkaproducer.adoc
@@ -69,6 +69,12 @@ This lets a single transform route rows across topics, 
rather than needing one K
 |The field holding the topic name for each row, used when *Get topic from 
field* is enabled.
 Rows where this field is empty cause the transform to fail.
 
+|Headers field
+|Optional.
+A field holding the record headers to attach to each message, as a JSON array 
of `{"name":..,"value":..}` objects — the same shape the Kafka Consumer 
transform produces, so headers survive a consume-then-produce round trip.
+An array is used rather than a JSON object because Kafka keeps headers ordered 
and allows a name to repeat.
+Leave empty to send messages without headers.
+
 |Key Field
 |In Kafka, all messages can be keyed, allowing for messages to be distributed 
to partitions based on their keys in a default routing scheme.
 If no key is present, messages are randomly distributed to partitions.
diff --git 
a/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/consumer/KafkaConsumerField.java
 
b/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/consumer/KafkaConsumerField.java
index 627249b145..bb48f64ce2 100644
--- 
a/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/consumer/KafkaConsumerField.java
+++ 
b/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/consumer/KafkaConsumerField.java
@@ -135,7 +135,8 @@ public class KafkaConsumerField {
     TOPIC("topic"),
     PARTITION("partition"),
     OFFSET("offset"),
-    TIMESTAMP("timestamp");
+    TIMESTAMP("timestamp"),
+    HEADERS("headers");
 
     private final String code;
 
diff --git 
a/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/consumer/KafkaConsumerInput.java
 
b/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/consumer/KafkaConsumerInput.java
index 375e7dc521..7288f916c5 100644
--- 
a/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/consumer/KafkaConsumerInput.java
+++ 
b/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/consumer/KafkaConsumerInput.java
@@ -43,6 +43,7 @@ import org.apache.hop.pipeline.transform.ITransformMeta;
 import org.apache.hop.pipeline.transform.RowAdapter;
 import org.apache.hop.pipeline.transform.TransformMeta;
 import org.apache.hop.pipeline.transforms.injector.InjectorMeta;
+import org.apache.hop.pipeline.transforms.kafka.shared.KafkaHeaders;
 import org.apache.hop.pipeline.transforms.kafka.shared.KafkaOption;
 import org.apache.kafka.clients.consumer.Consumer;
 import org.apache.kafka.clients.consumer.ConsumerConfig;
@@ -410,14 +411,30 @@ public class KafkaConsumerInput
 
     Object[] rowData = RowDataUtil.allocateRowData(data.outputRowMeta.size());
 
+    // Only fields carrying an output name are on the row, in the order 
KafkaConsumerInputMeta
+    // adds them, so each value is placed conditionally rather than at a fixed 
index.
     int index = 0;
-    rowData[index++] = record.key();
-    rowData[index++] = record.value();
-    rowData[index++] = record.topic();
-    rowData[index++] = (long) record.partition();
-    rowData[index++] = record.offset();
-    rowData[index] = record.timestamp();
+    index = putIfNamed(rowData, index, meta.getKeyField(), record.key());
+    index = putIfNamed(rowData, index, meta.getMessageField(), record.value());
+    index = putIfNamed(rowData, index, meta.getTopicField(), record.topic());
+    index = putIfNamed(rowData, index, meta.getPartitionField(), (long) 
record.partition());
+    index = putIfNamed(rowData, index, meta.getOffsetField(), record.offset());
+    index = putIfNamed(rowData, index, meta.getTimestampField(), 
record.timestamp());
+    putIfNamed(rowData, index, meta.getHeadersField(), 
KafkaHeaders.toJson(record.headers()));
 
     return rowData;
   }
+
+  /**
+   * Writes a value at the given index only when the field contributes a 
column, and reports the
+   * next free index. A field with an empty output name is skipped by {@code
+   * KafkaConsumerInputMeta.getRowMeta}, so writing it here would shift every 
later column.
+   */
+  private int putIfNamed(Object[] rowData, int index, KafkaConsumerField 
field, Object value) {
+    if (field == null || StringUtils.isEmpty(field.getOutputName()) || index 
>= rowData.length) {
+      return index;
+    }
+    rowData[index] = value;
+    return index + 1;
+  }
 }
diff --git 
a/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/consumer/KafkaConsumerInputDialog.java
 
b/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/consumer/KafkaConsumerInputDialog.java
index fb3f48a5d4..b662f6d2e8 100644
--- 
a/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/consumer/KafkaConsumerInputDialog.java
+++ 
b/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/consumer/KafkaConsumerInputDialog.java
@@ -501,7 +501,9 @@ public class KafkaConsumerInputDialog extends 
BaseTransformDialog {
 
     // don't let any rows get deleted or added (this does not affect the 
read-only state of the
     // cells)
-    fieldsTable.setReadonly(true);
+    // The output name column is editable so fields can be renamed, and so the 
Headers field - which
+    // ships unnamed to keep existing pipelines unchanged - can be switched on 
from the dialog.
+    fieldsTable.setReadonly(false);
   }
 
   private void buildOptionsTable(Composite parentWidget) {
@@ -888,9 +890,13 @@ public class KafkaConsumerInputDialog extends 
BaseTransformDialog {
       String outputType = row.getText(3);
       try {
         KafkaConsumerField.Name ref = 
KafkaConsumerField.Name.valueOf(kafkaName.toUpperCase());
-        KafkaConsumerField field =
-            new KafkaConsumerField(ref, outputName, 
KafkaConsumerField.Type.valueOf(outputType));
-        // meta.setField(field); TODO FIXME
+        KafkaConsumerField field = fieldFor(ref);
+        if (field != null) {
+          field.setOutputName(outputName);
+          if (StringUtils.isNotEmpty(outputType)) {
+            field.setOutputType(KafkaConsumerField.Type.valueOf(outputType));
+          }
+        }
       } catch (IllegalArgumentException e) {
         if (isDebug()) {
           logDebug(e.getMessage(), e);
@@ -899,6 +905,22 @@ public class KafkaConsumerInputDialog extends 
BaseTransformDialog {
     }
   }
 
+  /**
+   * The metadata keeps one typed instance per Kafka field rather than a list, 
so an edited table
+   * row has to be written back onto the matching instance.
+   */
+  private KafkaConsumerField fieldFor(KafkaConsumerField.Name name) {
+    return switch (name) {
+      case KEY -> meta.getKeyField();
+      case MESSAGE -> meta.getMessageField();
+      case TOPIC -> meta.getTopicField();
+      case PARTITION -> meta.getPartitionField();
+      case OFFSET -> meta.getOffsetField();
+      case TIMESTAMP -> meta.getTimestampField();
+      case HEADERS -> meta.getHeadersField();
+    };
+  }
+
   private void setTopicsFromTable() {
     int itemCount = topicsTable.getItemCount();
     ArrayList<String> tableTopics = new ArrayList<>();
diff --git 
a/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/consumer/KafkaConsumerInputMeta.java
 
b/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/consumer/KafkaConsumerInputMeta.java
index 303fc18671..bc84cbd7c2 100644
--- 
a/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/consumer/KafkaConsumerInputMeta.java
+++ 
b/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/consumer/KafkaConsumerInputMeta.java
@@ -105,6 +105,8 @@ public class KafkaConsumerInputMeta
 
   @HopMetadataProperty private TimestampConsumerField timestampField;
 
+  @HopMetadataProperty private HeadersConsumerField headersField;
+
   @HopMetadataProperty(
       key = "pipelinePath",
       injectionKey = "pipelinePath",
@@ -236,6 +238,10 @@ public class KafkaConsumerInputMeta
     timestampField =
         new TimestampConsumerField(
             BaseMessages.getString(PKG, 
"KafkaConsumerInputDialog.TimestampField"));
+
+    // Left unnamed on purpose: an empty output name keeps the field off the 
output row, so
+    // pipelines saved before headers existed produce exactly the same row as 
before.
+    headersField = new HeadersConsumerField("");
   }
 
   public KafkaConsumerInputMeta(KafkaConsumerInputMeta m) {
@@ -271,6 +277,7 @@ public class KafkaConsumerInputMeta
     putFieldOnRowMeta(getPartitionField(), rowMeta, origin, variables);
     putFieldOnRowMeta(getOffsetField(), rowMeta, origin, variables);
     putFieldOnRowMeta(getTimestampField(), rowMeta, origin, variables);
+    putFieldOnRowMeta(getHeadersField(), rowMeta, origin, variables);
     return rowMeta;
   }
 
@@ -301,7 +308,8 @@ public class KafkaConsumerInputMeta
             getTopicField(),
             getPartitionField(),
             getOffsetField(),
-            getTimestampField()));
+            getTimestampField(),
+            getHeadersField()));
   }
 
   @Override
@@ -512,6 +520,7 @@ public class KafkaConsumerInputMeta
             case PARTITION -> partitionField;
             case OFFSET -> offsetField;
             case TIMESTAMP -> timestampField;
+            case HEADERS -> headersField;
           };
       field.setKafkaName(name);
       field.setOutputType(type);
@@ -606,6 +615,41 @@ public class KafkaConsumerInputMeta
     }
   }
 
+  /**
+   * The record headers, rendered as a JSON array of {@code 
{"name":..,"value":..}} objects.
+   *
+   * <p>A Kafka record carries an ordered list of header pairs and the same 
name may appear more
+   * than once, which a flat row column cannot represent. An array of objects 
keeps both the order
+   * and any repeats, so the value round-trips through the Kafka Producer 
transform unchanged. Leave
+   * the output name empty to keep headers off the row entirely.
+   */
+  @Getter
+  @Setter
+  public static class HeadersConsumerField extends KafkaConsumerField {
+    @HopMetadataProperty(
+        key = "outputName",
+        injectionKey = "HEADERS.OUTPUT_NAME",
+        injectionKeyDescription = 
"KafkaConsumerInputMeta.Injection.HEADERS.OUTPUT_NAME")
+    protected String outputName;
+
+    public HeadersConsumerField() {
+      super();
+      this.outputName = "";
+      super.outputType = Type.String;
+      super.kafkaName = Name.HEADERS;
+    }
+
+    public HeadersConsumerField(HeadersConsumerField f) {
+      super(f.kafkaName, f.outputName, f.outputType);
+      this.outputName = f.outputName;
+    }
+
+    public HeadersConsumerField(String outputName) {
+      this();
+      this.outputName = outputName;
+    }
+  }
+
   @Getter
   @Setter
   public static class OffsetConsumerField extends KafkaConsumerField {
diff --git 
a/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/producer/KafkaProducerOutput.java
 
b/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/producer/KafkaProducerOutput.java
index b76e2a9301..dfb7870456 100644
--- 
a/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/producer/KafkaProducerOutput.java
+++ 
b/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/producer/KafkaProducerOutput.java
@@ -26,7 +26,9 @@ import org.apache.hop.pipeline.transform.BaseTransform;
 import org.apache.hop.pipeline.transform.TransformMeta;
 import org.apache.hop.pipeline.transforms.kafka.consumer.KafkaConsumerField;
 import org.apache.hop.pipeline.transforms.kafka.shared.KafkaFactory;
+import org.apache.hop.pipeline.transforms.kafka.shared.KafkaHeaders;
 import org.apache.kafka.clients.producer.ProducerRecord;
+import org.apache.kafka.common.header.Header;
 
 public class KafkaProducerOutput
     extends BaseTransform<KafkaProducerOutputMeta, KafkaProducerOutputData> {
@@ -84,6 +86,17 @@ public class KafkaProducerOutput
         data.topic = resolve(meta.getTopic());
       }
 
+      data.headersFieldIndex = -1;
+      String headersField = resolve(meta.getHeadersField());
+      if (StringUtils.isNotEmpty(headersField)) {
+        data.headersFieldIndex = getInputRowMeta().indexOfValue(headersField);
+        if (data.headersFieldIndex < 0) {
+          throw new HopException(
+              BaseMessages.getString(
+                  PKG, "KafkaProducerOutput.Error.HeadersFieldNotFound", 
headersField));
+        }
+      }
+
       data.kafkaProducer =
           kafkaFactory.producer(
               meta,
@@ -100,13 +113,18 @@ public class KafkaProducerOutput
       return false;
     }
     String topic = resolveTopic(r);
+    Iterable<Header> headers = resolveHeaders(r);
 
     ProducerRecord<Object, Object> producerRecord;
     // allow for null keys
     if (data.keyFieldIndex < 0
         || getInputRowMeta().isNull(r, data.keyFieldIndex)
         || StringUtils.isEmpty(r[data.keyFieldIndex].toString())) {
-      producerRecord = new ProducerRecord<>(topic, r[data.messageFieldIndex]);
+      // Explicit casts pick the (topic, partition, timestamp, key, value, 
headers) constructor;
+      // bare nulls leave it ambiguous against the shorter overloads.
+      producerRecord =
+          new ProducerRecord<>(
+              topic, (Integer) null, (Long) null, null, 
r[data.messageFieldIndex], headers);
     } else {
 
       Object nativeObject =
@@ -116,7 +134,12 @@ public class KafkaProducerOutput
 
       producerRecord =
           new ProducerRecord<>(
-              topic, getInputRowMeta().getString(r, data.keyFieldIndex), 
nativeObject);
+              topic,
+              (Integer) null,
+              (Long) null,
+              getInputRowMeta().getString(r, data.keyFieldIndex),
+              nativeObject,
+              headers);
     }
 
     data.kafkaProducer.send(producerRecord);
@@ -157,6 +180,27 @@ public class KafkaProducerOutput
     return topic;
   }
 
+  /**
+   * Reads the record headers for a row from the configured field, which holds 
the same JSON array
+   * of {@code {"name":..,"value":..}} objects the Kafka Consumer produces, so 
headers survive a
+   * consume-then-produce round trip.
+   *
+   * @param r the current input row
+   * @return the headers to attach, or null when no headers field is 
configured or the value is
+   *     empty
+   * @throws HopException if the field does not hold a usable headers document
+   */
+  private Iterable<Header> resolveHeaders(Object[] r) throws HopException {
+    if (data.headersFieldIndex < 0) {
+      return null;
+    }
+    String json = getInputRowMeta().getString(r, data.headersFieldIndex);
+    if (StringUtils.isBlank(json)) {
+      return null;
+    }
+    return KafkaHeaders.fromJson(json);
+  }
+
   @Override
   public void stopRunning() {
     if (data.kafkaProducer != null && data.isOpen) {
diff --git 
a/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/producer/KafkaProducerOutputData.java
 
b/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/producer/KafkaProducerOutputData.java
index 20bc68c198..56af40aa12 100644
--- 
a/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/producer/KafkaProducerOutputData.java
+++ 
b/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/producer/KafkaProducerOutputData.java
@@ -30,6 +30,7 @@ public class KafkaProducerOutputData extends 
BaseTransformData implements ITrans
   public int keyFieldIndex;
   public int messageFieldIndex;
   public int topicFieldIndex;
+  public int headersFieldIndex;
   public String topic;
   public boolean isOpen;
 
diff --git 
a/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/producer/KafkaProducerOutputDialog.java
 
b/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/producer/KafkaProducerOutputDialog.java
index c00a064b2f..dafc099a95 100644
--- 
a/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/producer/KafkaProducerOutputDialog.java
+++ 
b/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/producer/KafkaProducerOutputDialog.java
@@ -77,6 +77,7 @@ public class KafkaProducerOutputDialog extends 
BaseTransformDialog {
   private ComboVar wTopicField;
   private ComboVar wKeyField;
   private ComboVar wMessageField;
+  private ComboVar wHeadersField;
   private TableView optionsTable;
   private CTabFolder wTabFolder;
 
@@ -300,6 +301,31 @@ public class KafkaProducerOutputDialog extends 
BaseTransformDialog {
                 variables, pipelineMeta, wMessageField, transformName);
     wMessageField.getCComboWidget().addListener(SWT.FocusIn, lsMessageFocus);
 
+    Label wlHeadersField = new Label(wSetupComp, SWT.RIGHT);
+    PropsUi.setLook(wlHeadersField);
+    wlHeadersField.setText(BaseMessages.getString(PKG, 
"KafkaProducerOutputDialog.HeadersField"));
+    FormData fdlHeadersField = new FormData();
+    fdlHeadersField.left = new FormAttachment(0, 0);
+    fdlHeadersField.top = new FormAttachment(wMessageField, margin);
+    fdlHeadersField.right = new FormAttachment(middle, -margin);
+    wlHeadersField.setLayoutData(fdlHeadersField);
+
+    wHeadersField = new ComboVar(variables, wSetupComp, SWT.SINGLE | SWT.LEFT 
| SWT.BORDER);
+    PropsUi.setLook(wHeadersField);
+    wHeadersField.addModifyListener(lsMod);
+    FormData fdHeadersField = new FormData();
+    fdHeadersField.left = new FormAttachment(wlHeadersField, margin);
+    fdHeadersField.top = new FormAttachment(wlHeadersField, 0, SWT.CENTER);
+    fdHeadersField.right = new FormAttachment(100, 0);
+    wHeadersField.setLayoutData(fdHeadersField);
+    wHeadersField
+        .getCComboWidget()
+        .addListener(
+            SWT.FocusIn,
+            e ->
+                KafkaDialogHelper.populateFieldsList(
+                    variables, pipelineMeta, wHeadersField, transformName));
+
     FormData fdSetupComp = new FormData();
     fdSetupComp.left = new FormAttachment(0, 0);
     fdSetupComp.top = new FormAttachment(0, 0);
@@ -418,6 +444,7 @@ public class KafkaProducerOutputDialog extends 
BaseTransformDialog {
     wTopicField.setText(Const.NVL(meta.getTopicField(), ""));
     wKeyField.setText(Const.NVL(meta.getKeyField(), ""));
     wMessageField.setText(Const.NVL(meta.getMessageField(), ""));
+    wHeadersField.setText(Const.NVL(meta.getHeadersField(), ""));
 
     populateOptionsData();
     enableFields();
@@ -540,6 +567,21 @@ public class KafkaProducerOutputDialog extends 
BaseTransformDialog {
     }
 
     meta.setMessageField(wMessageField.getText());
+
+    if (!Utils.isEmpty(wHeadersField.getText()) && 
checkMissingField(wHeadersField.getText())) {
+      MessageBox mb = new MessageBox(shell, SWT.OK | SWT.ICON_ERROR);
+      mb.setMessage(
+          BaseMessages.getString(
+              PKG,
+              "KafkaProducerOutputDialog.HeadersFieldNotExists.Message",
+              wHeadersField.getText()));
+      mb.setText(
+          BaseMessages.getString(PKG, 
CONST_KAFKA_PRODUCER_OUTPUT_DIALOG_FIELD_NOT_EXISTS_TITLE));
+      mb.open();
+      return;
+    }
+
+    meta.setHeadersField(wHeadersField.getText());
     meta.setOptions(KafkaDialogHelper.getConfig(optionsTable));
 
     transformName = wTransformName.getText();
diff --git 
a/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/producer/KafkaProducerOutputMeta.java
 
b/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/producer/KafkaProducerOutputMeta.java
index 625329f7af..bbf12bb110 100644
--- 
a/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/producer/KafkaProducerOutputMeta.java
+++ 
b/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/producer/KafkaProducerOutputMeta.java
@@ -87,6 +87,12 @@ public class KafkaProducerOutputMeta
       injectionKeyDescription = "KafkaProducerOutputMeta.Injection.KEY_FIELD")
   private String keyField;
 
+  @HopMetadataProperty(
+      key = "headersField",
+      injectionKey = "HEADERS_FIELD",
+      injectionKeyDescription = 
"KafkaProducerOutputMeta.Injection.HEADERS_FIELD")
+  private String headersField;
+
   @HopMetadataProperty(
       key = "messageField",
       injectionKey = "MESSAGE_FIELD",
@@ -112,6 +118,7 @@ public class KafkaProducerOutputMeta
     this.topic = m.topic;
     this.topicInField = m.topicInField;
     this.topicField = m.topicField;
+    this.headersField = m.headersField;
     this.keyField = m.keyField;
     this.messageField = m.messageField;
     m.options.forEach(option -> this.options.add(new KafkaOption(option)));
diff --git 
a/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/shared/KafkaHeaders.java
 
b/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/shared/KafkaHeaders.java
new file mode 100644
index 0000000000..63fd920188
--- /dev/null
+++ 
b/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/shared/KafkaHeaders.java
@@ -0,0 +1,115 @@
+/*
+ * 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.hop.pipeline.transforms.kafka.shared;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.JsonNodeFactory;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.kafka.common.header.Header;
+import org.apache.kafka.common.header.Headers;
+import org.apache.kafka.common.header.internals.RecordHeader;
+
+/**
+ * Converts Kafka record headers to and from the textual form carried in a 
pipeline field.
+ *
+ * <p>A record carries an ordered list of name/value pairs and the same name 
may appear more than
+ * once, which a flat row column cannot represent. The exchange format is 
therefore a JSON array of
+ * {@code {"name":..,"value":..}} objects rather than a JSON object: an object 
would silently drop
+ * repeats and lose ordering. Keeping both sides of the conversion here means 
the Kafka Consumer and
+ * Kafka Producer cannot drift apart, so headers survive a 
consume-then-produce round trip.
+ *
+ * <p>Header values are bytes on the wire and are treated as UTF-8 text. A 
null value is preserved
+ * as JSON null, which is distinct from an empty value.
+ */
+public final class KafkaHeaders {
+
+  private static final Class<?> PKG = KafkaHeaders.class;
+
+  private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+  private static final String NAME = "name";
+  private static final String VALUE = "value";
+
+  private KafkaHeaders() {
+    // Utility class
+  }
+
+  /**
+   * Renders record headers as a JSON array, preserving order and repeated 
names.
+   *
+   * @param headers the headers to render, may be null
+   * @return a JSON array document, empty when there are no headers
+   */
+  public static String toJson(Headers headers) {
+    ArrayNode array = JsonNodeFactory.instance.arrayNode();
+    if (headers != null) {
+      for (Header header : headers) {
+        ObjectNode node = array.addObject();
+        node.put(NAME, header.key());
+        if (header.value() == null) {
+          node.putNull(VALUE);
+        } else {
+          node.put(VALUE, new String(header.value(), StandardCharsets.UTF_8));
+        }
+      }
+    }
+    return array.toString();
+  }
+
+  /**
+   * Parses the document produced by {@link #toJson(Headers)} back into record 
headers.
+   *
+   * @param json the headers document
+   * @return the parsed headers, in document order
+   * @throws HopException if the document is not a JSON array of name/value 
objects
+   */
+  public static List<Header> fromJson(String json) throws HopException {
+    JsonNode root;
+    try {
+      root = OBJECT_MAPPER.readTree(json);
+    } catch (JsonProcessingException e) {
+      throw new HopException(BaseMessages.getString(PKG, 
"KafkaHeaders.Error.NotJson", json), e);
+    }
+    if (root == null || !root.isArray()) {
+      throw new HopException(BaseMessages.getString(PKG, 
"KafkaHeaders.Error.NotArray", json));
+    }
+
+    List<Header> headers = new ArrayList<>();
+    for (JsonNode element : root) {
+      JsonNode name = element.get(NAME);
+      if (name == null || !name.isTextual()) {
+        throw new HopException(BaseMessages.getString(PKG, 
"KafkaHeaders.Error.MissingName", json));
+      }
+      JsonNode value = element.get(VALUE);
+      byte[] bytes =
+          (value == null || value.isNull())
+              ? null
+              : value.asText().getBytes(StandardCharsets.UTF_8);
+      headers.add(new RecordHeader(name.asText(), bytes));
+    }
+    return headers;
+  }
+}
diff --git 
a/plugins/transforms/kafka/src/main/resources/org/apache/hop/pipeline/transforms/kafka/consumer/messages/messages_en_US.properties
 
b/plugins/transforms/kafka/src/main/resources/org/apache/hop/pipeline/transforms/kafka/consumer/messages/messages_en_US.properties
index b642302d33..5703e114e7 100644
--- 
a/plugins/transforms/kafka/src/main/resources/org/apache/hop/pipeline/transforms/kafka/consumer/messages/messages_en_US.properties
+++ 
b/plugins/transforms/kafka/src/main/resources/org/apache/hop/pipeline/transforms/kafka/consumer/messages/messages_en_US.properties
@@ -80,6 +80,7 @@ KafkaConsumerInputMeta.Injection.TOPICS_LIST=The topics
 KafkaConsumerInputMeta.Injection.TRANSFORMATION_PATH=The path to the 
sub-pipeline.
 KafkaConsumerInputMeta.Injection.VALUES=The value(s) of the Kafka consumer 
configuration properties.
 KafkaConsumerInputMeta.Injection.TOPIC.OUTPUT_NAME=Topic output name
+KafkaConsumerInputMeta.Injection.HEADERS.OUTPUT_NAME=Headers output name; 
leave empty to keep the record headers off the output row
 KafkaConsumerInputMeta.Injection.OFFSET.OUTPUT_NAME=Offset output name
 KafkaConsumerInputMeta.Injection.PARTITION.OUTPUT_NAME=Partition output name
 KafkaConsumerInputMeta.Injection.TIMESTAMP.OUTPUT_NAME=Timestamp output name
diff --git 
a/plugins/transforms/kafka/src/main/resources/org/apache/hop/pipeline/transforms/kafka/producer/messages/messages_en_US.properties
 
b/plugins/transforms/kafka/src/main/resources/org/apache/hop/pipeline/transforms/kafka/producer/messages/messages_en_US.properties
index 7b62ef7716..7f41648629 100644
--- 
a/plugins/transforms/kafka/src/main/resources/org/apache/hop/pipeline/transforms/kafka/producer/messages/messages_en_US.properties
+++ 
b/plugins/transforms/kafka/src/main/resources/org/apache/hop/pipeline/transforms/kafka/producer/messages/messages_en_US.properties
@@ -19,6 +19,7 @@ KafkaConsumerOutput.Log.LineNumber=Linenr {0}
 KafkaProducer.TypeLongDesc=Kafka producer
 KafkaProducer.TypeTooltipDesc=Sends messages to a Kafka producer for a certain 
topic
 KafkaProducerOutput.Error.EmptyTopicInField=Field ''{0}'' contains no topic 
name for the row at line {1}
+KafkaProducerOutput.Error.HeadersFieldNotFound=Field ''{0}'' containing the 
record headers is not present in the input stream
 KafkaProducerOutput.Error.NoTopicFieldSpecified=No field containing the topic 
name has been specified
 KafkaProducerOutput.Error.TopicFieldNotFound=Field ''{0}'' containing the 
topic name is not present in the input stream
 KafkaProducerOutputDialog.BootstrapServerMandatory.Message=''Bootstrap 
servers'' field''s value is mandatory!
@@ -27,6 +28,8 @@ KafkaProducerOutputDialog.ClientId=Client ID
 KafkaProducerOutputDialog.FailedToGetFields.DialogMessage=Unable to get fields 
from previous transforms because of an error
 KafkaProducerOutputDialog.FailedToGetFields.DialogTitle=Field not present
 KafkaProducerOutputDialog.FieldNotExists.Title=Error
+KafkaProducerOutputDialog.HeadersField=Headers field
+KafkaProducerOutputDialog.HeadersFieldNotExists.Message=Field ''{0}'' 
containing the record headers is not present in input stream
 KafkaProducerOutputDialog.KeyField=Key field
 KafkaProducerOutputDialog.KeyFieldNotExists.Message=Field ''{0}'' containing 
key value is not present in input stream
 KafkaProducerOutputDialog.MessageField=Message field
@@ -48,6 +51,7 @@ KafkaProducerOutputMeta.Injection.CLUSTER_NAME=The cluster 
containing the Kafka
 KafkaProducerOutputMeta.Injection.CONFIGURATION_PROPERTIES=The Kafka producer 
configuration properties.
 KafkaProducerOutputMeta.Injection.CONNECTION_TYPE=Specify the connection type: 
DIRECT or CLUSTER.
 KafkaProducerOutputMeta.Injection.DIRECT_BOOTSTRAP_SERVERS=Specify the 
Bootstrap Servers when Connection Type is DIRECT.
+KafkaProducerOutputMeta.Injection.HEADERS_FIELD=The name of the stream field 
holding the record headers, as a JSON array of name/value objects.
 KafkaProducerOutputMeta.Injection.KEY_FIELD=The name of the stream field used 
as the Kafka key.
 KafkaProducerOutputMeta.Injection.MESSAGE_FIELD=The name of the stream field 
used as the Kafka message.
 KafkaProducerOutputMeta.Injection.NAMES=The name(s) of the Kafka producer 
configuration properties.
diff --git 
a/plugins/transforms/kafka/src/main/resources/org/apache/hop/pipeline/transforms/kafka/shared/messages/messages_en_US.properties
 
b/plugins/transforms/kafka/src/main/resources/org/apache/hop/pipeline/transforms/kafka/shared/messages/messages_en_US.properties
new file mode 100644
index 0000000000..45b1264d7e
--- /dev/null
+++ 
b/plugins/transforms/kafka/src/main/resources/org/apache/hop/pipeline/transforms/kafka/shared/messages/messages_en_US.properties
@@ -0,0 +1,20 @@
+#
+# 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.
+#
+
+KafkaHeaders.Error.MissingName=A record header is missing its ''name'': {0}
+KafkaHeaders.Error.NotArray=The record headers must be a JSON array of 
name/value objects: {0}
+KafkaHeaders.Error.NotJson=The record headers could not be read as JSON: {0}
diff --git 
a/plugins/transforms/kafka/src/test/java/org/apache/hop/pipeline/transforms/kafka/shared/KafkaHeadersTest.java
 
b/plugins/transforms/kafka/src/test/java/org/apache/hop/pipeline/transforms/kafka/shared/KafkaHeadersTest.java
new file mode 100644
index 0000000000..4d3358cae4
--- /dev/null
+++ 
b/plugins/transforms/kafka/src/test/java/org/apache/hop/pipeline/transforms/kafka/shared/KafkaHeadersTest.java
@@ -0,0 +1,167 @@
+/*
+ * 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.hop.pipeline.transforms.kafka.shared;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import org.apache.hop.core.exception.HopException;
+import org.apache.kafka.common.header.Header;
+import org.apache.kafka.common.header.Headers;
+import org.apache.kafka.common.header.internals.RecordHeaders;
+import org.junit.jupiter.api.Test;
+
+class KafkaHeadersTest {
+
+  private static Headers headers(String... namesAndValues) {
+    RecordHeaders recordHeaders = new RecordHeaders();
+    for (int i = 0; i < namesAndValues.length; i += 2) {
+      String value = namesAndValues[i + 1];
+      recordHeaders.add(
+          namesAndValues[i], value == null ? null : 
value.getBytes(StandardCharsets.UTF_8));
+    }
+    return recordHeaders;
+  }
+
+  private static String valueOf(Header header) {
+    return header.value() == null ? null : new String(header.value(), 
StandardCharsets.UTF_8);
+  }
+
+  // --- rendering 
-------------------------------------------------------------------------------
+
+  @Test
+  void testNoHeadersRenderEmptyArray() {
+    assertEquals("[]", KafkaHeaders.toJson(new RecordHeaders()));
+  }
+
+  @Test
+  void testNullHeadersRenderEmptyArray() {
+    assertEquals("[]", KafkaHeaders.toJson(null));
+  }
+
+  @Test
+  void testSingleHeaderIsRendered() {
+    assertEquals(
+        "[{\"name\":\"traceparent\",\"value\":\"00-abc\"}]",
+        KafkaHeaders.toJson(headers("traceparent", "00-abc")));
+  }
+
+  @Test
+  void testRenderingPreservesOrder() {
+    assertEquals(
+        "[{\"name\":\"b\",\"value\":\"1\"},{\"name\":\"a\",\"value\":\"2\"}]",
+        KafkaHeaders.toJson(headers("b", "1", "a", "2")));
+  }
+
+  @Test
+  void testRenderingKeepsRepeatedNames() {
+    // The reason the format is an array rather than an object: Kafka allows 
the same header name
+    // more than once and an object would silently drop one.
+    assertEquals(
+        
"[{\"name\":\"tag\",\"value\":\"one\"},{\"name\":\"tag\",\"value\":\"two\"}]",
+        KafkaHeaders.toJson(headers("tag", "one", "tag", "two")));
+  }
+
+  @Test
+  void testNullHeaderValueRendersJsonNull() {
+    assertEquals(
+        "[{\"name\":\"tombstone\",\"value\":null}]",
+        KafkaHeaders.toJson(headers("tombstone", null)));
+  }
+
+  @Test
+  void testRenderingEscapesJson() {
+    assertEquals(
+        "[{\"name\":\"quoted\",\"value\":\"he said \\\"hi\\\"\"}]",
+        KafkaHeaders.toJson(headers("quoted", "he said \"hi\"")));
+  }
+
+  // --- parsing 
---------------------------------------------------------------------------------
+
+  @Test
+  void testEmptyArrayParsesToNoHeaders() throws HopException {
+    assertTrue(KafkaHeaders.fromJson("[]").isEmpty());
+  }
+
+  @Test
+  void testParsingKeepsOrderAndRepeatedNames() throws HopException {
+    List<Header> parsed =
+        KafkaHeaders.fromJson(
+            
"[{\"name\":\"tag\",\"value\":\"one\"},{\"name\":\"a\",\"value\":\"x\"},"
+                + "{\"name\":\"tag\",\"value\":\"two\"}]");
+
+    assertEquals(3, parsed.size());
+    assertEquals("tag", parsed.get(0).key());
+    assertEquals("one", valueOf(parsed.get(0)));
+    assertEquals("a", parsed.get(1).key());
+    assertEquals("tag", parsed.get(2).key());
+    assertEquals("two", valueOf(parsed.get(2)));
+  }
+
+  @Test
+  void testJsonNullParsesToNullValue() throws HopException {
+    // A null header value is distinct from an empty one on the wire.
+    
assertNull(KafkaHeaders.fromJson("[{\"name\":\"tombstone\",\"value\":null}]").get(0).value());
+  }
+
+  @Test
+  void testMissingValueParsesToNullValue() throws HopException {
+    assertNull(KafkaHeaders.fromJson("[{\"name\":\"bare\"}]").get(0).value());
+  }
+
+  @Test
+  void testNonJsonIsRejected() {
+    assertThrows(HopException.class, () -> KafkaHeaders.fromJson("not json at 
all"));
+  }
+
+  @Test
+  void testJsonObjectIsRejected() {
+    // An object cannot express ordering or repeats, so it is rejected even 
though it is the more
+    // obvious shape to hand-write.
+    assertThrows(HopException.class, () -> 
KafkaHeaders.fromJson("{\"traceparent\":\"00-abc\"}"));
+  }
+
+  @Test
+  void testEntryWithoutNameIsRejected() {
+    assertThrows(HopException.class, () -> 
KafkaHeaders.fromJson("[{\"value\":\"orphan\"}]"));
+  }
+
+  // --- round trip 
------------------------------------------------------------------------------
+
+  @Test
+  void testRoundTripPreservesEverything() throws HopException {
+    // The point of keeping both directions together: what the Kafka Consumer 
writes into a field
+    // can be handed straight back to the Kafka Producer and yield the same 
headers.
+    Headers original =
+        headers("tag", "one", "tag", "two", "city", "München", "tombstone", 
null, "empty", "");
+
+    List<Header> parsed = KafkaHeaders.fromJson(KafkaHeaders.toJson(original));
+
+    assertEquals(5, parsed.size());
+    assertEquals("tag", parsed.get(0).key());
+    assertEquals("one", valueOf(parsed.get(0)));
+    assertEquals("two", valueOf(parsed.get(1)));
+    assertEquals("München", valueOf(parsed.get(2)));
+    assertNull(parsed.get(3).value(), "a null value must not become an empty 
one");
+    assertEquals("", valueOf(parsed.get(4)), "an empty value must not become 
null");
+  }
+}

Reply via email to