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

nastra 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 a5e238e5b9 Core: Tolerate explicit null config fields across REST 
parsers (#17250)
a5e238e5b9 is described below

commit a5e238e5b935e27c78cb95dca50f210f65cbe3b3
Author: Eunbin Son <[email protected]>
AuthorDate: Mon Aug 3 15:56:24 2026 +0900

    Core: Tolerate explicit null config fields across REST parsers (#17250)
    
    A REST server may send "config": null in a load view response. The reader
    used json.has(CONFIG), so a null value fell through to getStringMap and
    threw IllegalArgumentException. Use json.hasNonNull to match the sibling
    LoadTableResponseParser, which already skips a null config and yields an
    empty config map.
    
    Generated-by: Claude Code
    
    * Core: Tolerate explicit null in remaining REST parser optional fields
    
    The same has() + non-null getter pattern that broke LoadViewResponseParser
    on an explicit "config": null exists in other REST parsers. 
JsonUtil.getString,
    getBool, getStringMap and JsonUtil.get all reject a NullNode, while has() 
only
    checks that the key is present, so an explicit null throws instead of 
falling
    back to the absent-field behavior.
    
    Switch to hasNonNull() for the optional fields where treating an explicit 
null
    as absent matches the field's documented default:
    
      CreateViewRequestParser  properties
      RemoteSignRequestParser  properties, body, provider
      PlanTableScanRequestParser  case-sensitive, use-snapshot-schema
      TableScanResponseParser  delete-files, file-scan-tasks
      RESTFileScanTaskParser  delete-file-references
      OAuth2Util  expires_in, scope
    
    The filter fields (PlanTableScanRequestParser.filter and
    RESTFileScanTaskParser.residual-filter) are left alone: #14657 added
    testFilterFieldWithExplicitNullThrowsError to pin the throw when aligning 
the
    field with the OpenAPI spec, and silently reading a null filter as "no 
filter"
    would turn a malformed request into a full scan rather than an error.
    
    Each parser gets a test covering the explicit-null input.
    
    Generated-by: Claude Code
    
    * Core: Comment why filter fields reject an explicit null in REST parsers
    
    huaxingao asked to note in code why PlanTableScanRequestParser.filter and
    RESTFileScanTaskParser.residual-filter still reject an explicit null while 
the
    other optional fields now tolerate it. Add a comment on each: those fields 
are
    non-nullable in the OpenAPI spec (#14657 pins the throw with
    testFilterFieldWithExplicitNullThrowsError), so the throw is the aligned
    behavior rather than silently reading a null filter as "no filter".
    
    Generated-by: Claude Code
---
 .../iceberg/rest/RESTFileScanTaskParser.java       |  5 +++-
 .../iceberg/rest/TableScanResponseParser.java      |  4 +--
 .../org/apache/iceberg/rest/auth/OAuth2Util.java   |  4 +--
 .../rest/requests/CreateViewRequestParser.java     |  2 +-
 .../rest/requests/PlanTableScanRequestParser.java  |  7 +++--
 .../rest/requests/RemoteSignRequestParser.java     |  6 ++--
 .../rest/responses/LoadViewResponseParser.java     |  2 +-
 .../rest/requests/TestCreateViewRequestParser.java | 24 +++++++++++++++
 .../requests/TestPlanTableScanRequestParser.java   |  9 ++++++
 .../rest/requests/TestRemoteSignRequestParser.java | 17 +++++++++++
 .../rest/responses/TestLoadViewResponseParser.java | 34 ++++++++++++++++++++++
 .../rest/responses/TestOAuthTokenResponse.java     | 11 +++++++
 .../responses/TestPlanTableScanResponseParser.java | 31 ++++++++++++++++++++
 13 files changed, 144 insertions(+), 12 deletions(-)

diff --git 
a/core/src/main/java/org/apache/iceberg/rest/RESTFileScanTaskParser.java 
b/core/src/main/java/org/apache/iceberg/rest/RESTFileScanTaskParser.java
index ccd2872e04..53ef00155c 100644
--- a/core/src/main/java/org/apache/iceberg/rest/RESTFileScanTaskParser.java
+++ b/core/src/main/java/org/apache/iceberg/rest/RESTFileScanTaskParser.java
@@ -84,7 +84,7 @@ class RESTFileScanTaskParser {
     int specId = dataFile.specId();
 
     DeleteFile[] deleteFiles = null;
-    if (jsonNode.has(DELETE_FILE_REFERENCES)) {
+    if (jsonNode.hasNonNull(DELETE_FILE_REFERENCES)) {
       List<Integer> indices = JsonUtil.getIntegerList(DELETE_FILE_REFERENCES, 
jsonNode);
       Preconditions.checkArgument(
           indices.isEmpty() || Collections.max(indices) < 
allDeleteFiles.size(),
@@ -95,6 +95,9 @@ class RESTFileScanTaskParser {
     }
 
     Expression filter = null;
+    // An explicit null residual-filter is intentionally rejected to stay 
aligned with the OpenAPI
+    // spec, where residual-filter is a non-nullable expression: has() + 
fromJson throws rather than
+    // dropping the residual.
     if (jsonNode.has(RESIDUAL_FILTER)) {
       filter = ExpressionParser.fromJson(jsonNode.get(RESIDUAL_FILTER));
     }
diff --git 
a/core/src/main/java/org/apache/iceberg/rest/TableScanResponseParser.java 
b/core/src/main/java/org/apache/iceberg/rest/TableScanResponseParser.java
index 02d9890494..27a046e4ce 100644
--- a/core/src/main/java/org/apache/iceberg/rest/TableScanResponseParser.java
+++ b/core/src/main/java/org/apache/iceberg/rest/TableScanResponseParser.java
@@ -44,7 +44,7 @@ public class TableScanResponseParser {
 
   public static List<DeleteFile> parseDeleteFiles(
       JsonNode node, Map<Integer, PartitionSpec> specsById) {
-    if (node.has(DELETE_FILES)) {
+    if (node.hasNonNull(DELETE_FILES)) {
       JsonNode deleteFiles = JsonUtil.get(DELETE_FILES, node);
       Preconditions.checkArgument(
           deleteFiles.isArray(), "Cannot parse delete files from non-array: 
%s", deleteFiles);
@@ -65,7 +65,7 @@ public class TableScanResponseParser {
       List<DeleteFile> deleteFiles,
       Map<Integer, PartitionSpec> specsById,
       boolean caseSensitive) {
-    if (node.has(FILE_SCAN_TASKS)) {
+    if (node.hasNonNull(FILE_SCAN_TASKS)) {
       JsonNode scanTasks = JsonUtil.get(FILE_SCAN_TASKS, node);
       Preconditions.checkArgument(
           scanTasks.isArray(), "Cannot parse file scan tasks from non-array: 
%s", scanTasks);
diff --git a/core/src/main/java/org/apache/iceberg/rest/auth/OAuth2Util.java 
b/core/src/main/java/org/apache/iceberg/rest/auth/OAuth2Util.java
index a9504a7a4e..b8db459ede 100644
--- a/core/src/main/java/org/apache/iceberg/rest/auth/OAuth2Util.java
+++ b/core/src/main/java/org/apache/iceberg/rest/auth/OAuth2Util.java
@@ -369,11 +369,11 @@ public class OAuth2Util {
             .withTokenType(JsonUtil.getString(TOKEN_TYPE, json))
             .withIssuedTokenType(JsonUtil.getStringOrNull(ISSUED_TOKEN_TYPE, 
json));
 
-    if (json.has(EXPIRES_IN)) {
+    if (json.hasNonNull(EXPIRES_IN)) {
       builder.setExpirationInSeconds(JsonUtil.getInt(EXPIRES_IN, json));
     }
 
-    if (json.has(SCOPE)) {
+    if (json.hasNonNull(SCOPE)) {
       builder.addScopes(parseScope(JsonUtil.getString(SCOPE, json)));
     }
 
diff --git 
a/core/src/main/java/org/apache/iceberg/rest/requests/CreateViewRequestParser.java
 
b/core/src/main/java/org/apache/iceberg/rest/requests/CreateViewRequestParser.java
index 7a66bc4a1e..4d593af48f 100644
--- 
a/core/src/main/java/org/apache/iceberg/rest/requests/CreateViewRequestParser.java
+++ 
b/core/src/main/java/org/apache/iceberg/rest/requests/CreateViewRequestParser.java
@@ -90,7 +90,7 @@ public class CreateViewRequestParser {
             .viewVersion(viewVersion)
             .schema(schema);
 
-    if (json.has(PROPERTIES)) {
+    if (json.hasNonNull(PROPERTIES)) {
       builder.properties(JsonUtil.getStringMap(PROPERTIES, json));
     }
 
diff --git 
a/core/src/main/java/org/apache/iceberg/rest/requests/PlanTableScanRequestParser.java
 
b/core/src/main/java/org/apache/iceberg/rest/requests/PlanTableScanRequestParser.java
index cdfca722f8..586b9d1321 100644
--- 
a/core/src/main/java/org/apache/iceberg/rest/requests/PlanTableScanRequestParser.java
+++ 
b/core/src/main/java/org/apache/iceberg/rest/requests/PlanTableScanRequestParser.java
@@ -111,17 +111,20 @@ public class PlanTableScanRequestParser {
     List<String> select = JsonUtil.getStringListOrNull(SELECT, json);
 
     Expression filter = null;
+    // Unlike the optional fields above, an explicit null filter is 
intentionally rejected to stay
+    // aligned with the OpenAPI spec, where filter is a non-nullable 
expression: has() + fromJson
+    // throws rather than treating null as "no filter" and silently widening 
the scan.
     if (json.has(FILTER)) {
       filter = ExpressionParser.fromJson(json.get(FILTER));
     }
 
     boolean caseSensitive = true;
-    if (json.has(CASE_SENSITIVE)) {
+    if (json.hasNonNull(CASE_SENSITIVE)) {
       caseSensitive = JsonUtil.getBool(CASE_SENSITIVE, json);
     }
 
     boolean useSnapshotSchema = false;
-    if (json.has(USE_SNAPSHOT_SCHEMA)) {
+    if (json.hasNonNull(USE_SNAPSHOT_SCHEMA)) {
       useSnapshotSchema = JsonUtil.getBool(USE_SNAPSHOT_SCHEMA, json);
     }
 
diff --git 
a/core/src/main/java/org/apache/iceberg/rest/requests/RemoteSignRequestParser.java
 
b/core/src/main/java/org/apache/iceberg/rest/requests/RemoteSignRequestParser.java
index a29326a76b..1b56ec0983 100644
--- 
a/core/src/main/java/org/apache/iceberg/rest/requests/RemoteSignRequestParser.java
+++ 
b/core/src/main/java/org/apache/iceberg/rest/requests/RemoteSignRequestParser.java
@@ -95,15 +95,15 @@ public class RemoteSignRequestParser {
             .uri(uri)
             .headers(headers);
 
-    if (json.has(PROPERTIES)) {
+    if (json.hasNonNull(PROPERTIES)) {
       builder.properties(JsonUtil.getStringMap(PROPERTIES, json));
     }
 
-    if (json.has(BODY)) {
+    if (json.hasNonNull(BODY)) {
       builder.body(JsonUtil.getString(BODY, json));
     }
 
-    if (json.has(PROVIDER)) {
+    if (json.hasNonNull(PROVIDER)) {
       builder.provider(JsonUtil.getString(PROVIDER, json));
     }
 
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 a8aaf17e5d..7964a8455a 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
@@ -76,7 +76,7 @@ public class LoadViewResponseParser {
     ImmutableLoadViewResponse.Builder builder =
         
ImmutableLoadViewResponse.builder().metadataLocation(metadataLocation).metadata(metadata);
 
-    if (json.has(CONFIG)) {
+    if (json.hasNonNull(CONFIG)) {
       builder.config(JsonUtil.getStringMap(CONFIG, json));
     }
 
diff --git 
a/core/src/test/java/org/apache/iceberg/rest/requests/TestCreateViewRequestParser.java
 
b/core/src/test/java/org/apache/iceberg/rest/requests/TestCreateViewRequestParser.java
index a228c94a08..1f30ba6654 100644
--- 
a/core/src/test/java/org/apache/iceberg/rest/requests/TestCreateViewRequestParser.java
+++ 
b/core/src/test/java/org/apache/iceberg/rest/requests/TestCreateViewRequestParser.java
@@ -127,4 +127,28 @@ public class TestCreateViewRequestParser {
     
assertThat(CreateViewRequestParser.toJson(CreateViewRequestParser.fromJson(json),
 true))
         .isEqualTo(expectedJson);
   }
+
+  @Test
+  public void nullProperties() {
+    String viewVersion =
+        ViewVersionParser.toJson(
+            ImmutableViewVersion.builder()
+                .schemaId(0)
+                .versionId(1)
+                .timestampMillis(23L)
+                .defaultNamespace(Namespace.of("ns1"))
+                .build());
+
+    String json =
+        "{\"name\":\"view-name\","
+            + "\"location\":\"location\","
+            + "\"view-version\":"
+            + viewVersion
+            + ","
+            + "\"schema\":{\"type\":\"struct\",\"schema-id\":0,"
+            + 
"\"fields\":[{\"id\":1,\"name\":\"x\",\"required\":true,\"type\":\"long\"}]},"
+            + "\"properties\":null}";
+
+    assertThat(CreateViewRequestParser.fromJson(json).properties()).isEmpty();
+  }
 }
diff --git 
a/core/src/test/java/org/apache/iceberg/rest/requests/TestPlanTableScanRequestParser.java
 
b/core/src/test/java/org/apache/iceberg/rest/requests/TestPlanTableScanRequestParser.java
index 28087298b4..7fb049b8e2 100644
--- 
a/core/src/test/java/org/apache/iceberg/rest/requests/TestPlanTableScanRequestParser.java
+++ 
b/core/src/test/java/org/apache/iceberg/rest/requests/TestPlanTableScanRequestParser.java
@@ -283,4 +283,13 @@ public class TestPlanTableScanRequestParser {
         .isInstanceOf(IllegalArgumentException.class)
         .hasMessage("Cannot parse expression from non-object: null");
   }
+
+  @Test
+  public void nullCaseSensitiveAndUseSnapshotSchema() {
+    String json = 
"{\"snapshot-id\":123,\"case-sensitive\":null,\"use-snapshot-schema\":null}";
+
+    PlanTableScanRequest request = PlanTableScanRequestParser.fromJson(json);
+    assertThat(request.caseSensitive()).isTrue();
+    assertThat(request.useSnapshotSchema()).isFalse();
+  }
 }
diff --git 
a/core/src/test/java/org/apache/iceberg/rest/requests/TestRemoteSignRequestParser.java
 
b/core/src/test/java/org/apache/iceberg/rest/requests/TestRemoteSignRequestParser.java
index 3515588e44..bb3f6ef3d9 100644
--- 
a/core/src/test/java/org/apache/iceberg/rest/requests/TestRemoteSignRequestParser.java
+++ 
b/core/src/test/java/org/apache/iceberg/rest/requests/TestRemoteSignRequestParser.java
@@ -151,6 +151,23 @@ public class TestRemoteSignRequestParser {
                 + "}");
   }
 
+  @Test
+  public void nullPropertiesBodyAndProvider() {
+    String json =
+        "{\"region\":\"us-west-2\","
+            + "\"method\":\"PUT\","
+            + "\"uri\":\"http://localhost:49208/iceberg-signer-test\",";
+            + "\"headers\":{},"
+            + "\"properties\":null,"
+            + "\"body\":null,"
+            + "\"provider\":null}";
+
+    RemoteSignRequest request = RemoteSignRequestParser.fromJson(json);
+    assertThat(request.properties()).isEmpty();
+    assertThat(request.body()).isNull();
+    assertThat(request.provider()).isNull();
+  }
+
   @Test
   public void roundTripSerdeWithProperties() {
     RemoteSignRequest request =
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 f3de08cd29..b4ae60b174 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
@@ -245,4 +245,38 @@ public class TestLoadViewResponseParser {
     
assertThat(LoadViewResponseParser.toJson(LoadViewResponseParser.fromJson(json), 
true))
         .isEqualTo(expectedJson);
   }
+
+  @Test
+  public void nullConfig() {
+    ViewMetadata viewMetadata =
+        ViewMetadata.builder()
+            .assignUUID("386b9f01-002b-4d8c-b77f-42c3fd3b7c9b")
+            .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 jsonWithoutConfig = LoadViewResponseParser.toJson(response, true);
+    // a REST server may send an explicit "config": null, which must parse as 
an empty config
+    String jsonWithNullConfig =
+        jsonWithoutConfig.substring(0, jsonWithoutConfig.length() - 2)
+            + ",\n  \"config\" : null\n}";
+
+    LoadViewResponse parsed = 
LoadViewResponseParser.fromJson(jsonWithNullConfig);
+    assertThat(parsed.config()).isEmpty();
+    assertThat(LoadViewResponseParser.toJson(parsed, 
true)).isEqualTo(jsonWithoutConfig);
+  }
 }
diff --git 
a/core/src/test/java/org/apache/iceberg/rest/responses/TestOAuthTokenResponse.java
 
b/core/src/test/java/org/apache/iceberg/rest/responses/TestOAuthTokenResponse.java
index 6c11c0a4c9..6a9bb649ca 100644
--- 
a/core/src/test/java/org/apache/iceberg/rest/responses/TestOAuthTokenResponse.java
+++ 
b/core/src/test/java/org/apache/iceberg/rest/responses/TestOAuthTokenResponse.java
@@ -131,6 +131,17 @@ public class TestOAuthTokenResponse extends 
RequestResponseTestBase<OAuthTokenRe
         .hasMessageContaining("Cannot parse to a string value: token_type: 
34");
   }
 
+  @Test
+  public void nullExpiresInAndScope() throws Exception {
+    OAuthTokenResponse response =
+        deserialize(
+            "{\"access_token\":\"bearer-token\",\"token_type\":\"bearer\","
+                + "\"expires_in\":null,\"scope\":null}");
+
+    assertThat(response.expiresInSeconds()).isNull();
+    assertThat(response.scopes()).isEmpty();
+  }
+
   @Test
   void invalidScopeReportedInErrorMsg() {
     assertThatThrownBy(() -> OAuthTokenResponse.builder().addScope("bad 
scope"))
diff --git 
a/core/src/test/java/org/apache/iceberg/rest/responses/TestPlanTableScanResponseParser.java
 
b/core/src/test/java/org/apache/iceberg/rest/responses/TestPlanTableScanResponseParser.java
index 6354e7bf24..919efb5bbd 100644
--- 
a/core/src/test/java/org/apache/iceberg/rest/responses/TestPlanTableScanResponseParser.java
+++ 
b/core/src/test/java/org/apache/iceberg/rest/responses/TestPlanTableScanResponseParser.java
@@ -430,6 +430,37 @@ public class TestPlanTableScanResponseParser {
     
assertThat(PlanTableScanResponseParser.toJson(copyResponse)).isEqualTo(expectedJson);
   }
 
+  @Test
+  public void nullDeleteFilesAndFileScanTasks() {
+    PlanTableScanResponse response =
+        PlanTableScanResponseParser.fromJson(
+            
"{\"status\":\"completed\",\"delete-files\":null,\"file-scan-tasks\":null}",
+            PARTITION_SPECS_BY_ID,
+            false);
+
+    assertThat(response.planStatus()).isEqualTo(PlanStatus.COMPLETED);
+    assertThat(response.fileScanTasks()).isNull();
+  }
+
+  @Test
+  public void nullDeleteFileReferences() {
+    PlanTableScanResponse response =
+        PlanTableScanResponseParser.fromJson(
+            "{\"status\":\"completed\","
+                + "\"file-scan-tasks\":["
+                + "{\"data-file\":{\"spec-id\":0,\"content\":\"data\","
+                + "\"file-path\":\"/path/to/data-a.parquet\","
+                + "\"file-format\":\"parquet\",\"partition\":[0],"
+                + 
"\"file-size-in-bytes\":10,\"record-count\":1,\"sort-order-id\":0},"
+                + "\"delete-file-references\":null}]"
+                + "}",
+            PARTITION_SPECS_BY_ID,
+            false);
+
+    assertThat(response.fileScanTasks()).hasSize(1);
+    assertThat(response.fileScanTasks().get(0).deletes()).isEmpty();
+  }
+
   @Test
   public void emptyOrInvalidCredentials() {
     assertThat(

Reply via email to