You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@iceberg.apache.org by at...@apache.org on 2026/09/17 13:01:46 UTC

(iceberg) branch main updated: Core: add labels to the load table/view read path (#18045)

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

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


The following commit(s) were added to refs/heads/main by this push:
     new f1c6206d8e Core: add labels to the load table/view read path (#18045)
f1c6206d8e is described below

commit f1c6206d8ef4ad92a2a8bab511c5138e1124423e
Author: Andrei Tserakhau <la...@gmail.com>
AuthorDate: Thu Sep 17 15:01:28 2026 +0200

    Core: add labels to the load table/view read path (#18045)
    
    Implement the client read path for catalog labels (added to the REST
    spec in apache/iceberg#15750): parse the labels a catalog returns on
    load table/view responses so they are visible to the Java client. Until
    now RESTObjectMapper (FAIL_ON_UNKNOWN_PROPERTIES = false) silently
    dropped the field, so any labels a catalog returned were lost.
    
    Add Labels and FieldLabel value types with JSON parsers, and wire an
    optional labels field into LoadTableResponse and LoadViewResponse.
    Labels default to absent and are omitted from the wire when empty, so
    the change is additive and backward compatible.
---
 .../LoadViewResponse.java => FieldLabel.java}      |  18 +--
 .../java/org/apache/iceberg/FieldLabelParser.java  |  64 ++++++++++
 .../LoadViewResponse.java => Labels.java}          |  23 ++--
 .../main/java/org/apache/iceberg/LabelsParser.java |  81 ++++++++++++
 .../iceberg/rest/responses/LoadTableResponse.java  |  19 ++-
 .../rest/responses/LoadTableResponseParser.java    |  11 ++
 .../iceberg/rest/responses/LoadViewResponse.java   |   6 +
 .../rest/responses/LoadViewResponseParser.java     |  11 ++
 .../org/apache/iceberg/TestFieldLabelParser.java   |  85 ++++++++++++
 .../java/org/apache/iceberg/TestLabelsParser.java  | 142 +++++++++++++++++++++
 .../rest/responses/TestLoadTableResponse.java      |  46 +++++++
 .../responses/TestLoadTableResponseParser.java     | 117 +++++++++++++++++
 .../rest/responses/TestLoadViewResponseParser.java | 116 +++++++++++++++++
 13 files changed, 714 insertions(+), 25 deletions(-)

diff --git a/core/src/main/java/org/apache/iceberg/rest/responses/LoadViewResponse.java b/core/src/main/java/org/apache/iceberg/FieldLabel.java
similarity index 68%
copy from core/src/main/java/org/apache/iceberg/rest/responses/LoadViewResponse.java
copy to core/src/main/java/org/apache/iceberg/FieldLabel.java
index d07ba872fd..b75c9b9945 100644
--- a/core/src/main/java/org/apache/iceberg/rest/responses/LoadViewResponse.java
+++ b/core/src/main/java/org/apache/iceberg/FieldLabel.java
@@ -16,23 +16,15 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package org.apache.iceberg.rest.responses;
+package org.apache.iceberg;
 
 import java.util.Map;
-import org.apache.iceberg.rest.RESTResponse;
-import org.apache.iceberg.view.ViewMetadata;
 import org.immutables.value.Value;
 
+/** Labels attached to a single field, identified by its field id. See {@link Labels}. */
 @Value.Immutable
-public interface LoadViewResponse extends RESTResponse {
-  String metadataLocation();
+public interface FieldLabel {
+  int fieldId();
 
-  ViewMetadata metadata();
-
-  Map<String, String> config();
-
-  @Override
-  default void validate() {
-    // nothing to validate as it's not possible to create an invalid instance
-  }
+  Map<String, String> labels();
 }
diff --git a/core/src/main/java/org/apache/iceberg/FieldLabelParser.java b/core/src/main/java/org/apache/iceberg/FieldLabelParser.java
new file mode 100644
index 0000000000..e15c4b0093
--- /dev/null
+++ b/core/src/main/java/org/apache/iceberg/FieldLabelParser.java
@@ -0,0 +1,64 @@
+/*
+ * 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.iceberg;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.databind.JsonNode;
+import java.io.IOException;
+import org.apache.iceberg.relocated.com.google.common.base.Preconditions;
+import org.apache.iceberg.util.JsonUtil;
+
+public class FieldLabelParser {
+  private static final String FIELD_ID = "field-id";
+  private static final String LABELS = "labels";
+
+  private FieldLabelParser() {}
+
+  public static String toJson(FieldLabel fieldLabel) {
+    return toJson(fieldLabel, false);
+  }
+
+  public static String toJson(FieldLabel fieldLabel, boolean pretty) {
+    return JsonUtil.generate(gen -> toJson(fieldLabel, gen), pretty);
+  }
+
+  public static void toJson(FieldLabel fieldLabel, JsonGenerator gen) throws IOException {
+    Preconditions.checkArgument(null != fieldLabel, "Invalid field labels: null");
+
+    gen.writeStartObject();
+
+    gen.writeNumberField(FIELD_ID, fieldLabel.fieldId());
+    JsonUtil.writeStringMap(LABELS, fieldLabel.labels(), gen);
+
+    gen.writeEndObject();
+  }
+
+  public static FieldLabel fromJson(String json) {
+    return JsonUtil.parse(json, FieldLabelParser::fromJson);
+  }
+
+  public static FieldLabel fromJson(JsonNode json) {
+    Preconditions.checkArgument(null != json, "Cannot parse field labels from null object");
+
+    return ImmutableFieldLabel.builder()
+        .fieldId(JsonUtil.getInt(FIELD_ID, json))
+        .labels(JsonUtil.getStringMap(LABELS, json))
+        .build();
+  }
+}
diff --git a/core/src/main/java/org/apache/iceberg/rest/responses/LoadViewResponse.java b/core/src/main/java/org/apache/iceberg/Labels.java
similarity index 63%
copy from core/src/main/java/org/apache/iceberg/rest/responses/LoadViewResponse.java
copy to core/src/main/java/org/apache/iceberg/Labels.java
index d07ba872fd..20f41f4ea0 100644
--- a/core/src/main/java/org/apache/iceberg/rest/responses/LoadViewResponse.java
+++ b/core/src/main/java/org/apache/iceberg/Labels.java
@@ -16,23 +16,26 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package org.apache.iceberg.rest.responses;
+package org.apache.iceberg;
 
+import java.util.List;
 import java.util.Map;
-import org.apache.iceberg.rest.RESTResponse;
-import org.apache.iceberg.view.ViewMetadata;
 import org.immutables.value.Value;
 
+/** Optional catalog-provided labels returned on a load response. */
 @Value.Immutable
-public interface LoadViewResponse extends RESTResponse {
-  String metadataLocation();
+public interface Labels {
+  Labels EMPTY = ImmutableLabels.builder().build();
 
-  ViewMetadata metadata();
+  /** Object-level labels. */
+  Map<String, String> objectLabels();
 
-  Map<String, String> config();
+  /** Field-level labels */
+  List<FieldLabel> fields();
 
-  @Override
-  default void validate() {
-    // nothing to validate as it's not possible to create an invalid instance
+  /** Returns true when there are neither object-level nor field-level labels. */
+  @Value.Derived
+  default boolean isEmpty() {
+    return objectLabels().isEmpty() && fields().isEmpty();
   }
 }
diff --git a/core/src/main/java/org/apache/iceberg/LabelsParser.java b/core/src/main/java/org/apache/iceberg/LabelsParser.java
new file mode 100644
index 0000000000..32ce968e13
--- /dev/null
+++ b/core/src/main/java/org/apache/iceberg/LabelsParser.java
@@ -0,0 +1,81 @@
+/*
+ * 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.iceberg;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.databind.JsonNode;
+import java.io.IOException;
+import org.apache.iceberg.relocated.com.google.common.base.Preconditions;
+import org.apache.iceberg.util.JsonUtil;
+
+public class LabelsParser {
+  private static final String OBJECT_LABELS = "object-labels";
+  private static final String FIELDS = "fields";
+
+  private LabelsParser() {}
+
+  public static String toJson(Labels labels) {
+    return toJson(labels, false);
+  }
+
+  public static String toJson(Labels labels, boolean pretty) {
+    return JsonUtil.generate(gen -> toJson(labels, gen), pretty);
+  }
+
+  public static void toJson(Labels labels, JsonGenerator gen) throws IOException {
+    Preconditions.checkArgument(null != labels, "Invalid labels: null");
+
+    gen.writeStartObject();
+
+    if (!labels.objectLabels().isEmpty()) {
+      JsonUtil.writeStringMap(OBJECT_LABELS, labels.objectLabels(), gen);
+    }
+
+    if (!labels.fields().isEmpty()) {
+      gen.writeArrayFieldStart(FIELDS);
+      for (FieldLabel fieldLabel : labels.fields()) {
+        FieldLabelParser.toJson(fieldLabel, gen);
+      }
+
+      gen.writeEndArray();
+    }
+
+    gen.writeEndObject();
+  }
+
+  public static Labels fromJson(String json) {
+    return JsonUtil.parse(json, LabelsParser::fromJson);
+  }
+
+  public static Labels fromJson(JsonNode json) {
+    Preconditions.checkArgument(null != json, "Cannot parse labels from null object");
+
+    ImmutableLabels.Builder builder = ImmutableLabels.builder();
+
+    if (json.hasNonNull(OBJECT_LABELS)) {
+      builder.objectLabels(JsonUtil.getStringMap(OBJECT_LABELS, json));
+    }
+
+    if (json.hasNonNull(FIELDS)) {
+      builder.fields(JsonUtil.getObjectList(FIELDS, json, FieldLabelParser::fromJson));
+    }
+
+    return builder.build();
+  }
+}
diff --git a/core/src/main/java/org/apache/iceberg/rest/responses/LoadTableResponse.java b/core/src/main/java/org/apache/iceberg/rest/responses/LoadTableResponse.java
index a60310236d..24b3ad65fc 100644
--- a/core/src/main/java/org/apache/iceberg/rest/responses/LoadTableResponse.java
+++ b/core/src/main/java/org/apache/iceberg/rest/responses/LoadTableResponse.java
@@ -20,6 +20,7 @@ package org.apache.iceberg.rest.responses;
 
 import java.util.List;
 import java.util.Map;
+import org.apache.iceberg.Labels;
 import org.apache.iceberg.TableMetadata;
 import org.apache.iceberg.relocated.com.google.common.base.MoreObjects;
 import org.apache.iceberg.relocated.com.google.common.base.Preconditions;
@@ -47,6 +48,7 @@ public class LoadTableResponse implements RESTResponse {
   private TableMetadata metadataWithLocation;
   private List<Credential> credentials;
   private RemoteSigningConfig remoteSigningConfig;
+  private Labels labels;
 
   public LoadTableResponse() {
     // Required for Jackson deserialization
@@ -57,12 +59,14 @@ public class LoadTableResponse implements RESTResponse {
       TableMetadata metadata,
       Map<String, String> config,
       List<Credential> credentials,
-      RemoteSigningConfig remoteSigningConfig) {
+      RemoteSigningConfig remoteSigningConfig,
+      Labels labels) {
     this.metadataLocation = metadataLocation;
     this.metadata = metadata;
     this.config = config;
     this.credentials = credentials;
     this.remoteSigningConfig = remoteSigningConfig;
+    this.labels = labels;
   }
 
   @Override
@@ -95,12 +99,17 @@ public class LoadTableResponse implements RESTResponse {
     return remoteSigningConfig != null ? remoteSigningConfig : RemoteSigningConfig.EMPTY;
   }
 
+  public Labels labels() {
+    return labels != null ? labels : Labels.EMPTY;
+  }
+
   @Override
   public String toString() {
     return MoreObjects.toStringHelper(this)
         .add("metadataLocation", metadataLocation)
         .add("metadata", metadata)
         .add("config", config)
+        .add("labels", labels)
         .toString();
   }
 
@@ -114,6 +123,7 @@ public class LoadTableResponse implements RESTResponse {
     private final Map<String, String> config = Maps.newHashMap();
     private final List<Credential> credentials = Lists.newArrayList();
     private RemoteSigningConfig remoteSigningConfig = RemoteSigningConfig.EMPTY;
+    private Labels labels = Labels.EMPTY;
 
     private Builder() {}
 
@@ -148,10 +158,15 @@ public class LoadTableResponse implements RESTResponse {
       return this;
     }
 
+    public Builder withLabels(Labels tableLabels) {
+      this.labels = tableLabels;
+      return this;
+    }
+
     public LoadTableResponse build() {
       Preconditions.checkNotNull(metadata, "Invalid metadata: null");
       return new LoadTableResponse(
-          metadataLocation, metadata, config, credentials, remoteSigningConfig);
+          metadataLocation, metadata, config, credentials, remoteSigningConfig, labels);
     }
   }
 }
diff --git a/core/src/main/java/org/apache/iceberg/rest/responses/LoadTableResponseParser.java b/core/src/main/java/org/apache/iceberg/rest/responses/LoadTableResponseParser.java
index d53d879bee..404928bc2a 100644
--- a/core/src/main/java/org/apache/iceberg/rest/responses/LoadTableResponseParser.java
+++ b/core/src/main/java/org/apache/iceberg/rest/responses/LoadTableResponseParser.java
@@ -21,6 +21,7 @@ package org.apache.iceberg.rest.responses;
 import com.fasterxml.jackson.core.JsonGenerator;
 import com.fasterxml.jackson.databind.JsonNode;
 import java.io.IOException;
+import org.apache.iceberg.LabelsParser;
 import org.apache.iceberg.TableMetadata;
 import org.apache.iceberg.TableMetadataParser;
 import org.apache.iceberg.relocated.com.google.common.base.Preconditions;
@@ -36,6 +37,7 @@ public class LoadTableResponseParser {
   private static final String CONFIG = "config";
   private static final String STORAGE_CREDENTIALS = "storage-credentials";
   private static final String REMOTE_SIGNING_CONFIG = "remote-signing-config";
+  private static final String LABELS = "labels";
 
   private LoadTableResponseParser() {}
 
@@ -77,6 +79,11 @@ public class LoadTableResponseParser {
       RemoteSigningConfigParser.toJson(response.remoteSigningConfig(), gen);
     }
 
+    if (!response.labels().isEmpty()) {
+      gen.writeFieldName(LABELS);
+      LabelsParser.toJson(response.labels(), gen);
+    }
+
     gen.writeEndObject();
   }
 
@@ -113,6 +120,10 @@ public class LoadTableResponseParser {
           RemoteSigningConfigParser.fromJson(JsonUtil.get(REMOTE_SIGNING_CONFIG, json)));
     }
 
+    if (json.hasNonNull(LABELS)) {
+      builder.withLabels(LabelsParser.fromJson(JsonUtil.get(LABELS, json)));
+    }
+
     return builder.build();
   }
 }
diff --git a/core/src/main/java/org/apache/iceberg/rest/responses/LoadViewResponse.java b/core/src/main/java/org/apache/iceberg/rest/responses/LoadViewResponse.java
index d07ba872fd..5d47fbbff9 100644
--- a/core/src/main/java/org/apache/iceberg/rest/responses/LoadViewResponse.java
+++ b/core/src/main/java/org/apache/iceberg/rest/responses/LoadViewResponse.java
@@ -19,6 +19,7 @@
 package org.apache.iceberg.rest.responses;
 
 import java.util.Map;
+import org.apache.iceberg.Labels;
 import org.apache.iceberg.rest.RESTResponse;
 import org.apache.iceberg.view.ViewMetadata;
 import org.immutables.value.Value;
@@ -31,6 +32,11 @@ public interface LoadViewResponse extends RESTResponse {
 
   Map<String, String> config();
 
+  @Value.Default
+  default Labels labels() {
+    return Labels.EMPTY;
+  }
+
   @Override
   default void validate() {
     // nothing to validate as it's not possible to create an invalid instance
diff --git a/core/src/main/java/org/apache/iceberg/rest/responses/LoadViewResponseParser.java b/core/src/main/java/org/apache/iceberg/rest/responses/LoadViewResponseParser.java
index 7964a8455a..fabcfc3435 100644
--- a/core/src/main/java/org/apache/iceberg/rest/responses/LoadViewResponseParser.java
+++ b/core/src/main/java/org/apache/iceberg/rest/responses/LoadViewResponseParser.java
@@ -21,6 +21,7 @@ package org.apache.iceberg.rest.responses;
 import com.fasterxml.jackson.core.JsonGenerator;
 import com.fasterxml.jackson.databind.JsonNode;
 import java.io.IOException;
+import org.apache.iceberg.LabelsParser;
 import org.apache.iceberg.relocated.com.google.common.base.Preconditions;
 import org.apache.iceberg.util.JsonUtil;
 import org.apache.iceberg.view.ViewMetadata;
@@ -31,6 +32,7 @@ public class LoadViewResponseParser {
   private static final String METADATA_LOCATION = "metadata-location";
   private static final String METADATA = "metadata";
   private static final String CONFIG = "config";
+  private static final String LABELS = "labels";
 
   private LoadViewResponseParser() {}
 
@@ -56,6 +58,11 @@ public class LoadViewResponseParser {
       JsonUtil.writeStringMap(CONFIG, response.config(), gen);
     }
 
+    if (!response.labels().isEmpty()) {
+      gen.writeFieldName(LABELS);
+      LabelsParser.toJson(response.labels(), gen);
+    }
+
     gen.writeEndObject();
   }
 
@@ -80,6 +87,10 @@ public class LoadViewResponseParser {
       builder.config(JsonUtil.getStringMap(CONFIG, json));
     }
 
+    if (json.hasNonNull(LABELS)) {
+      builder.labels(LabelsParser.fromJson(JsonUtil.get(LABELS, json)));
+    }
+
     return builder.build();
   }
 }
diff --git a/core/src/test/java/org/apache/iceberg/TestFieldLabelParser.java b/core/src/test/java/org/apache/iceberg/TestFieldLabelParser.java
new file mode 100644
index 0000000000..72c1a49a03
--- /dev/null
+++ b/core/src/test/java/org/apache/iceberg/TestFieldLabelParser.java
@@ -0,0 +1,85 @@
+/*
+ * 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.iceberg;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap;
+import org.junit.jupiter.api.Test;
+
+class TestFieldLabelParser {
+
+  @Test
+  void nullCheck() {
+    assertThatThrownBy(() -> FieldLabelParser.toJson(null))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessage("Invalid field labels: null");
+
+    assertThatThrownBy(() -> FieldLabelParser.fromJson((JsonNode) null))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessage("Cannot parse field labels from null object");
+  }
+
+  @Test
+  void roundTrip() {
+    FieldLabel fieldLabel =
+        ImmutableFieldLabel.builder().fieldId(3).labels(ImmutableMap.of("pii", "true")).build();
+
+    String expectedJson =
+        """
+        {
+          "field-id" : 3,
+          "labels" : {
+            "pii" : "true"
+          }
+        }""";
+
+    assertThat(FieldLabelParser.toJson(fieldLabel, true)).isEqualTo(expectedJson);
+    assertThat(FieldLabelParser.fromJson(expectedJson)).isEqualTo(fieldLabel);
+  }
+
+  @Test
+  void emptyLabels() {
+    assertThat(ImmutableFieldLabel.builder().fieldId(1).build().labels()).isEmpty();
+  }
+
+  @Test
+  void emptyLabelsFromJson() {
+    FieldLabel fieldLabel = FieldLabelParser.fromJson("{\"field-id\": 1, \"labels\": {}}");
+
+    assertThat(fieldLabel.fieldId()).isEqualTo(1);
+    assertThat(fieldLabel.labels()).isEmpty();
+  }
+
+  @Test
+  void missingLabelsFromJson() {
+    assertThatThrownBy(() -> FieldLabelParser.fromJson("{\"field-id\": 1}"))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessage("Cannot parse missing map: labels");
+  }
+
+  @Test
+  void emptyJson() {
+    assertThatThrownBy(() -> FieldLabelParser.fromJson("{}"))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessage("Cannot parse missing int: field-id");
+  }
+}
diff --git a/core/src/test/java/org/apache/iceberg/TestLabelsParser.java b/core/src/test/java/org/apache/iceberg/TestLabelsParser.java
new file mode 100644
index 0000000000..bb331fcf44
--- /dev/null
+++ b/core/src/test/java/org/apache/iceberg/TestLabelsParser.java
@@ -0,0 +1,142 @@
+/*
+ * 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.iceberg;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap;
+import org.junit.jupiter.api.Test;
+
+class TestLabelsParser {
+
+  @Test
+  void nullCheck() {
+    assertThatThrownBy(() -> LabelsParser.toJson(null))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessage("Invalid labels: null");
+
+    assertThatThrownBy(() -> LabelsParser.fromJson((JsonNode) null))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessage("Cannot parse labels from null object");
+  }
+
+  @Test
+  void emptyLabels() {
+    Labels labels = ImmutableLabels.builder().build();
+    assertThat(labels.isEmpty()).isTrue();
+
+    String expectedJson = "{ }";
+    assertThat(LabelsParser.toJson(labels, true)).isEqualTo(expectedJson);
+
+    Labels roundTrip = LabelsParser.fromJson(expectedJson);
+    assertThat(roundTrip.objectLabels()).isEmpty();
+    assertThat(roundTrip.fields()).isEmpty();
+
+    Labels nonEmpty = ImmutableLabels.builder().objectLabels(ImmutableMap.of("k", "v")).build();
+    assertThat(nonEmpty.isEmpty()).isFalse();
+  }
+
+  @Test
+  void objectLabelsOnly() {
+    Labels labels =
+        ImmutableLabels.builder()
+            .objectLabels(ImmutableMap.of("owner", "team-a", "sensitivity", "high"))
+            .build();
+
+    String expectedJson =
+        """
+        {
+          "object-labels" : {
+            "owner" : "team-a",
+            "sensitivity" : "high"
+          }
+        }""";
+
+    assertThat(LabelsParser.toJson(labels, true)).isEqualTo(expectedJson);
+    assertThat(LabelsParser.fromJson(expectedJson)).isEqualTo(labels);
+  }
+
+  @Test
+  void fieldLabelsOnly() {
+    Labels labels =
+        ImmutableLabels.builder()
+            .addFields(
+                ImmutableFieldLabel.builder()
+                    .fieldId(3)
+                    .labels(ImmutableMap.of("pii", "true"))
+                    .build())
+            .build();
+
+    String expectedJson =
+        """
+        {
+          "fields" : [ {
+            "field-id" : 3,
+            "labels" : {
+              "pii" : "true"
+            }
+          } ]
+        }""";
+
+    assertThat(LabelsParser.toJson(labels, true)).isEqualTo(expectedJson);
+    assertThat(LabelsParser.fromJson(expectedJson)).isEqualTo(labels);
+  }
+
+  @Test
+  void objectAndFieldLabel() {
+    Labels labels =
+        ImmutableLabels.builder()
+            .objectLabels(ImmutableMap.of("owner", "team-a"))
+            .addFields(
+                ImmutableFieldLabel.builder()
+                    .fieldId(1)
+                    .labels(ImmutableMap.of("classification", "pii"))
+                    .build())
+            .addFields(
+                ImmutableFieldLabel.builder()
+                    .fieldId(2)
+                    .labels(ImmutableMap.of("classification", "public"))
+                    .build())
+            .build();
+
+    String expectedJson =
+        """
+        {
+          "object-labels" : {
+            "owner" : "team-a"
+          },
+          "fields" : [ {
+            "field-id" : 1,
+            "labels" : {
+              "classification" : "pii"
+            }
+          }, {
+            "field-id" : 2,
+            "labels" : {
+              "classification" : "public"
+            }
+          } ]
+        }""";
+
+    assertThat(LabelsParser.toJson(labels, true)).isEqualTo(expectedJson);
+    assertThat(LabelsParser.fromJson(expectedJson)).isEqualTo(labels);
+  }
+}
diff --git a/core/src/test/java/org/apache/iceberg/rest/responses/TestLoadTableResponse.java b/core/src/test/java/org/apache/iceberg/rest/responses/TestLoadTableResponse.java
index 96854fe64c..14f740dc30 100644
--- a/core/src/test/java/org/apache/iceberg/rest/responses/TestLoadTableResponse.java
+++ b/core/src/test/java/org/apache/iceberg/rest/responses/TestLoadTableResponse.java
@@ -26,6 +26,9 @@ import com.fasterxml.jackson.core.JsonProcessingException;
 import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.util.Map;
+import org.apache.iceberg.ImmutableFieldLabel;
+import org.apache.iceberg.ImmutableLabels;
+import org.apache.iceberg.Labels;
 import org.apache.iceberg.NullOrder;
 import org.apache.iceberg.PartitionSpec;
 import org.apache.iceberg.Schema;
@@ -162,6 +165,48 @@ public class TestLoadTableResponse extends RequestResponseTestBase<LoadTableResp
     assertRoundTripSerializesEquallyFrom(json, resp);
   }
 
+  @Test
+  public void testRoundTripSerdeWithLabels() throws Exception {
+    String tableMetadataJson = readTableMetadataInputFile("TableMetadataV2Valid.json");
+    TableMetadata metadata =
+        TableMetadataParser.fromJson(TEST_METADATA_LOCATION, tableMetadataJson);
+    Labels labels =
+        ImmutableLabels.builder()
+            .objectLabels(ImmutableMap.of("owner", "team-a"))
+            .addFields(
+                ImmutableFieldLabel.builder()
+                    .fieldId(1)
+                    .labels(ImmutableMap.of("classification", "pii"))
+                    .build())
+            .build();
+    String json =
+        String.format(
+            """
+            {
+              "metadata-location":"%s",
+              "metadata":%s,
+              "config":{"foo":"bar"},
+              "labels":{
+                "object-labels":{"owner":"team-a"},
+                "fields":[{"field-id":1,"labels":{"classification":"pii"}}]
+              }
+            }""",
+            TEST_METADATA_LOCATION, TableMetadataParser.toJson(metadata));
+
+    // exercise the full RESTObjectMapper (Jackson) path, not just the parser
+    LoadTableResponse actual = deserialize(json);
+    assertThat(actual.labels()).isEqualTo(labels);
+
+    LoadTableResponse resp =
+        LoadTableResponse.builder()
+            .withTableMetadata(metadata)
+            .addAllConfig(CONFIG)
+            .withLabels(labels)
+            .build();
+    // compare as JSON trees so the readable (pretty) input above is whitespace-insensitive
+    assertThat(mapper().readTree(serialize(resp))).isEqualTo(mapper().readTree(json));
+  }
+
   @Test
   public void testCanDeserializeWithoutDefaultValues() throws Exception {
     String metadataJson = readTableMetadataInputFile("TableMetadataV1Valid.json");
@@ -187,6 +232,7 @@ public class TestLoadTableResponse extends RequestResponseTestBase<LoadTableResp
     assertThat(actual.metadataLocation())
         .as("Should have the same metadata location")
         .isEqualTo(expected.metadataLocation());
+    assertThat(actual.labels()).as("Should have the same labels").isEqualTo(expected.labels());
   }
 
   private void assertEqualTableMetadata(TableMetadata actual, TableMetadata expected) {
diff --git a/core/src/test/java/org/apache/iceberg/rest/responses/TestLoadTableResponseParser.java b/core/src/test/java/org/apache/iceberg/rest/responses/TestLoadTableResponseParser.java
index 3c03f24cf7..7d6f028b79 100644
--- a/core/src/test/java/org/apache/iceberg/rest/responses/TestLoadTableResponseParser.java
+++ b/core/src/test/java/org/apache/iceberg/rest/responses/TestLoadTableResponseParser.java
@@ -25,6 +25,8 @@ import static org.assertj.core.api.Assumptions.assumeThat;
 import com.fasterxml.jackson.databind.JsonNode;
 import java.util.List;
 import java.util.Map;
+import org.apache.iceberg.ImmutableFieldLabel;
+import org.apache.iceberg.ImmutableLabels;
 import org.apache.iceberg.PartitionSpec;
 import org.apache.iceberg.Schema;
 import org.apache.iceberg.SortOrder;
@@ -557,4 +559,119 @@ public class TestLoadTableResponseParser {
     assertThat(LoadTableResponseParser.toJson(LoadTableResponseParser.fromJson(json), true))
         .isEqualTo(expectedJson);
   }
+
+  @Test
+  public void roundTripSerdeWithLabels() {
+    String uuid = "386b9f01-002b-4d8c-b77f-42c3fd3b7c9b";
+    TableMetadata metadata =
+        TableMetadata.buildFromEmpty()
+            .assignUUID(uuid)
+            .setLocation("location")
+            .setCurrentSchema(
+                new Schema(Types.NestedField.required(1, "x", Types.LongType.get())), 1)
+            .addPartitionSpec(PartitionSpec.unpartitioned())
+            .addSortOrder(SortOrder.unsorted())
+            .discardChanges()
+            .withMetadataLocation("metadata-location")
+            .build();
+
+    LoadTableResponse response =
+        LoadTableResponse.builder()
+            .withTableMetadata(metadata)
+            .withLabels(
+                ImmutableLabels.builder()
+                    .objectLabels(ImmutableMap.of("owner", "team-a"))
+                    .addFields(
+                        ImmutableFieldLabel.builder()
+                            .fieldId(1)
+                            .labels(ImmutableMap.of("classification", "pii"))
+                            .build())
+                    .build())
+            .build();
+
+    String expectedJson =
+        String.format(
+            """
+            {
+              "metadata-location" : "metadata-location",
+              "metadata" : {
+                "format-version" : 2,
+                "table-uuid" : "386b9f01-002b-4d8c-b77f-42c3fd3b7c9b",
+                "location" : "location",
+                "last-sequence-number" : 0,
+                "last-updated-ms" : %d,
+                "last-column-id" : 1,
+                "current-schema-id" : 0,
+                "schemas" : [ {
+                  "type" : "struct",
+                  "schema-id" : 0,
+                  "fields" : [ {
+                    "id" : 1,
+                    "name" : "x",
+                    "required" : true,
+                    "type" : "long"
+                  } ]
+                } ],
+                "default-spec-id" : 0,
+                "partition-specs" : [ {
+                  "spec-id" : 0,
+                  "fields" : [ ]
+                } ],
+                "last-partition-id" : 999,
+                "default-sort-order-id" : 0,
+                "sort-orders" : [ {
+                  "order-id" : 0,
+                  "fields" : [ ]
+                } ],
+                "properties" : { },
+                "current-snapshot-id" : -1,
+                "refs" : { },
+                "snapshots" : [ ],
+                "statistics" : [ ],
+                "partition-statistics" : [ ],
+                "snapshot-log" : [ ],
+                "metadata-log" : [ ]
+              },
+              "labels" : {
+                "object-labels" : {
+                  "owner" : "team-a"
+                },
+                "fields" : [ {
+                  "field-id" : 1,
+                  "labels" : {
+                    "classification" : "pii"
+                  }
+                } ]
+              }
+            }""",
+            metadata.lastUpdatedMillis());
+
+    String json = LoadTableResponseParser.toJson(response, true);
+    assertThat(json).isEqualTo(expectedJson);
+    // can't do an equality comparison because Schema doesn't implement equals/hashCode
+    assertThat(LoadTableResponseParser.toJson(LoadTableResponseParser.fromJson(json), true))
+        .isEqualTo(expectedJson);
+  }
+
+  @Test
+  public void labelsAreOptional() {
+    String uuid = "386b9f01-002b-4d8c-b77f-42c3fd3b7c9b";
+    TableMetadata metadata =
+        TableMetadata.buildFromEmpty()
+            .assignUUID(uuid)
+            .setLocation("location")
+            .setCurrentSchema(
+                new Schema(Types.NestedField.required(1, "x", Types.LongType.get())), 1)
+            .addPartitionSpec(PartitionSpec.unpartitioned())
+            .addSortOrder(SortOrder.unsorted())
+            .discardChanges()
+            .withMetadataLocation("metadata-location")
+            .build();
+
+    LoadTableResponse response = LoadTableResponse.builder().withTableMetadata(metadata).build();
+
+    String json = LoadTableResponseParser.toJson(response, true);
+    assertThat(json).doesNotContain("labels");
+    assertThat(LoadTableResponseParser.fromJson(json).labels().isEmpty()).isTrue();
+  }
 }
diff --git a/core/src/test/java/org/apache/iceberg/rest/responses/TestLoadViewResponseParser.java b/core/src/test/java/org/apache/iceberg/rest/responses/TestLoadViewResponseParser.java
index b4ae60b174..bd39882290 100644
--- a/core/src/test/java/org/apache/iceberg/rest/responses/TestLoadViewResponseParser.java
+++ b/core/src/test/java/org/apache/iceberg/rest/responses/TestLoadViewResponseParser.java
@@ -22,6 +22,8 @@ import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
 import com.fasterxml.jackson.databind.JsonNode;
+import org.apache.iceberg.ImmutableFieldLabel;
+import org.apache.iceberg.ImmutableLabels;
 import org.apache.iceberg.Schema;
 import org.apache.iceberg.catalog.Namespace;
 import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap;
@@ -279,4 +281,118 @@ public class TestLoadViewResponseParser {
     assertThat(parsed.config()).isEmpty();
     assertThat(LoadViewResponseParser.toJson(parsed, true)).isEqualTo(jsonWithoutConfig);
   }
+
+  @Test
+  public void roundTripSerdeWithLabels() {
+    String uuid = "386b9f01-002b-4d8c-b77f-42c3fd3b7c9b";
+    ViewMetadata viewMetadata =
+        ViewMetadata.builder()
+            .assignUUID(uuid)
+            .setLocation("location")
+            .addSchema(new Schema(Types.NestedField.required(1, "x", Types.LongType.get())))
+            .addVersion(
+                ImmutableViewVersion.builder()
+                    .schemaId(0)
+                    .versionId(1)
+                    .timestampMillis(23L)
+                    .defaultNamespace(Namespace.of("ns1"))
+                    .build())
+            .setCurrentVersionId(1)
+            .build();
+
+    LoadViewResponse response =
+        ImmutableLoadViewResponse.builder()
+            .metadata(viewMetadata)
+            .metadataLocation("custom-location")
+            .labels(
+                ImmutableLabels.builder()
+                    .objectLabels(ImmutableMap.of("owner", "team-a"))
+                    .addFields(
+                        ImmutableFieldLabel.builder()
+                            .fieldId(1)
+                            .labels(ImmutableMap.of("classification", "pii"))
+                            .build())
+                    .build())
+            .build();
+
+    String expectedJson =
+        """
+        {
+          "metadata-location" : "custom-location",
+          "metadata" : {
+            "view-uuid" : "386b9f01-002b-4d8c-b77f-42c3fd3b7c9b",
+            "format-version" : 1,
+            "location" : "location",
+            "schemas" : [ {
+              "type" : "struct",
+              "schema-id" : 0,
+              "fields" : [ {
+                "id" : 1,
+                "name" : "x",
+                "required" : true,
+                "type" : "long"
+              } ]
+            } ],
+            "current-version-id" : 1,
+            "versions" : [ {
+              "version-id" : 1,
+              "timestamp-ms" : 23,
+              "schema-id" : 0,
+              "summary" : { },
+              "default-namespace" : [ "ns1" ],
+              "representations" : [ ]
+            } ],
+            "version-log" : [ {
+              "timestamp-ms" : 23,
+              "version-id" : 1
+            } ]
+          },
+          "labels" : {
+            "object-labels" : {
+              "owner" : "team-a"
+            },
+            "fields" : [ {
+              "field-id" : 1,
+              "labels" : {
+                "classification" : "pii"
+              }
+            } ]
+          }
+        }""";
+
+    String json = LoadViewResponseParser.toJson(response, true);
+    assertThat(json).isEqualTo(expectedJson);
+    // can't do an equality comparison because Schema doesn't implement equals/hashCode
+    assertThat(LoadViewResponseParser.toJson(LoadViewResponseParser.fromJson(json), true))
+        .isEqualTo(expectedJson);
+  }
+
+  @Test
+  public void labelsAreOptional() {
+    String uuid = "386b9f01-002b-4d8c-b77f-42c3fd3b7c9b";
+    ViewMetadata viewMetadata =
+        ViewMetadata.builder()
+            .assignUUID(uuid)
+            .setLocation("location")
+            .addSchema(new Schema(Types.NestedField.required(1, "x", Types.LongType.get())))
+            .addVersion(
+                ImmutableViewVersion.builder()
+                    .schemaId(0)
+                    .versionId(1)
+                    .timestampMillis(23L)
+                    .defaultNamespace(Namespace.of("ns1"))
+                    .build())
+            .setCurrentVersionId(1)
+            .build();
+
+    LoadViewResponse response =
+        ImmutableLoadViewResponse.builder()
+            .metadata(viewMetadata)
+            .metadataLocation("custom-location")
+            .build();
+
+    String json = LoadViewResponseParser.toJson(response, true);
+    assertThat(json).doesNotContain("labels");
+    assertThat(LoadViewResponseParser.fromJson(json).labels().isEmpty()).isTrue();
+  }
 }