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

JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git


The following commit(s) were added to refs/heads/master by this push:
     new 7d7a672811 feat(core): Support schema management over REST in 
RESTCatalog (#9673)
7d7a672811 is described below

commit 7d7a672811b98b543c44d42b7d949059c5cad48b
Author: baiyangtx <[email protected]>
AuthorDate: Tue Sep 8 22:09:20 2026 +0800

    feat(core): Support schema management over REST in RESTCatalog (#9673)
---
 .../main/java/org/apache/paimon/rest/RESTApi.java  |  31 +++++
 .../java/org/apache/paimon/rest/ResourcePaths.java |  16 +++
 .../paimon/rest/responses/ErrorResponse.java       |   2 +
 .../paimon/rest/responses/GetSchemaResponse.java   |  47 +++++++
 .../paimon/rest/responses/ListSchemasResponse.java |  69 ++++++++++
 .../java/org/apache/paimon/catalog/Catalog.java    |  32 +++++
 .../org/apache/paimon/catalog/DelegateCatalog.java |  14 ++
 .../java/org/apache/paimon/rest/RESTCatalog.java   |  28 ++++
 .../apache/paimon/rest/MockRESTCatalogTest.java    |  39 ++++++
 .../org/apache/paimon/rest/RESTCatalogServer.java  |  79 +++++------
 .../rest/RESTCatalogServerMetadataHandler.java     | 148 +++++++++++++++++++++
 .../org/apache/paimon/rest/RESTCatalogTest.java    |  52 ++++++++
 12 files changed, 511 insertions(+), 46 deletions(-)

diff --git a/paimon-api/src/main/java/org/apache/paimon/rest/RESTApi.java 
b/paimon-api/src/main/java/org/apache/paimon/rest/RESTApi.java
index ad6e394a26..84cdecba48 100644
--- a/paimon-api/src/main/java/org/apache/paimon/rest/RESTApi.java
+++ b/paimon-api/src/main/java/org/apache/paimon/rest/RESTApi.java
@@ -77,6 +77,7 @@ import 
org.apache.paimon.rest.responses.DropPartitionsResponse;
 import org.apache.paimon.rest.responses.ErrorResponse;
 import org.apache.paimon.rest.responses.GetDatabaseResponse;
 import org.apache.paimon.rest.responses.GetFunctionResponse;
+import org.apache.paimon.rest.responses.GetSchemaResponse;
 import org.apache.paimon.rest.responses.GetTableResponse;
 import org.apache.paimon.rest.responses.GetTableSnapshotResponse;
 import org.apache.paimon.rest.responses.GetTableTokenResponse;
@@ -92,6 +93,7 @@ import org.apache.paimon.rest.responses.ListFunctionsResponse;
 import org.apache.paimon.rest.responses.ListPartitionsResponse;
 import org.apache.paimon.rest.responses.ListPermissionsResponse;
 import org.apache.paimon.rest.responses.ListPoliciesResponse;
+import org.apache.paimon.rest.responses.ListSchemasResponse;
 import org.apache.paimon.rest.responses.ListSnapshotsResponse;
 import org.apache.paimon.rest.responses.ListTableDetailsResponse;
 import org.apache.paimon.rest.responses.ListTablesGloballyResponse;
@@ -103,6 +105,7 @@ import org.apache.paimon.rest.responses.ListViewsResponse;
 import org.apache.paimon.rest.responses.PagedResponse;
 import org.apache.paimon.schema.Schema;
 import org.apache.paimon.schema.SchemaChange;
+import org.apache.paimon.schema.TableSchema;
 import org.apache.paimon.table.Instant;
 import org.apache.paimon.table.TableSnapshot;
 import org.apache.paimon.utils.JsonSerdeUtil;
@@ -762,6 +765,34 @@ public class RESTApi {
                 restAuthFunction);
     }
 
+    /** Load the schema of a table for the given version. */
+    public TableSchema loadSchema(Identifier identifier, String version) {
+        GetSchemaResponse response =
+                client.get(
+                        resourcePaths.schemas(
+                                identifier.getDatabaseName(), 
identifier.getObjectName(), version),
+                        GetSchemaResponse.class,
+                        restAuthFunction);
+        return response.getSchema();
+    }
+
+    /** Get a paged schema list of a table in descending schema ID order. */
+    public PagedList<TableSchema> listSchemasPaged(
+            Identifier identifier, @Nullable Integer maxResults, @Nullable 
String pageToken) {
+        ListSchemasResponse response =
+                client.get(
+                        resourcePaths.schemas(
+                                identifier.getDatabaseName(), 
identifier.getObjectName()),
+                        buildPagedQueryParams(maxResults, pageToken),
+                        ListSchemasResponse.class,
+                        restAuthFunction);
+        List<TableSchema> schemas = response.getSchemas();
+        if (schemas == null) {
+            return new PagedList<>(emptyList(), null);
+        }
+        return new PagedList<>(schemas, response.getNextPageToken());
+    }
+
     /**
      * Create table.
      *
diff --git a/paimon-api/src/main/java/org/apache/paimon/rest/ResourcePaths.java 
b/paimon-api/src/main/java/org/apache/paimon/rest/ResourcePaths.java
index 4cef311061..d51a3119e1 100644
--- a/paimon-api/src/main/java/org/apache/paimon/rest/ResourcePaths.java
+++ b/paimon-api/src/main/java/org/apache/paimon/rest/ResourcePaths.java
@@ -37,6 +37,7 @@ public class ResourcePaths {
     protected static final String TAGS = "tags";
     protected static final String SNAPSHOTS = "snapshots";
     protected static final String CONSUMERS = "consumers";
+    protected static final String SCHEMAS = "schemas";
     protected static final String VIEWS = "views";
     protected static final String TABLE_DETAILS = "table-details";
     protected static final String VIEW_DETAILS = "view-details";
@@ -223,6 +224,21 @@ public class ResourcePaths {
                 SNAPSHOTS);
     }
 
+    public String schemas(String databaseName, String objectName) {
+        return SLASH.join(
+                V1,
+                prefix,
+                DATABASES,
+                encodeString(databaseName),
+                TABLES,
+                encodeString(objectName),
+                SCHEMAS);
+    }
+
+    public String schemas(String databaseName, String objectName, String 
version) {
+        return SLASH.join(schemas(databaseName, objectName), 
encodeString(version));
+    }
+
     public String authTable(String databaseName, String objectName) {
         return SLASH.join(
                 V1,
diff --git 
a/paimon-api/src/main/java/org/apache/paimon/rest/responses/ErrorResponse.java 
b/paimon-api/src/main/java/org/apache/paimon/rest/responses/ErrorResponse.java
index 4fe52b1d0b..bfc9e3bf4e 100644
--- 
a/paimon-api/src/main/java/org/apache/paimon/rest/responses/ErrorResponse.java
+++ 
b/paimon-api/src/main/java/org/apache/paimon/rest/responses/ErrorResponse.java
@@ -41,6 +41,8 @@ public class ErrorResponse implements RESTResponse {
 
     public static final String RESOURCE_TYPE_SNAPSHOT = "SNAPSHOT";
 
+    public static final String RESOURCE_TYPE_SCHEMA = "SCHEMA";
+
     public static final String RESOURCE_TYPE_BRANCH = "BRANCH";
 
     public static final String RESOURCE_TYPE_TAG = "TAG";
diff --git 
a/paimon-api/src/main/java/org/apache/paimon/rest/responses/GetSchemaResponse.java
 
b/paimon-api/src/main/java/org/apache/paimon/rest/responses/GetSchemaResponse.java
new file mode 100644
index 0000000000..1ac383a8b7
--- /dev/null
+++ 
b/paimon-api/src/main/java/org/apache/paimon/rest/responses/GetSchemaResponse.java
@@ -0,0 +1,47 @@
+/*
+ * 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.paimon.rest.responses;
+
+import org.apache.paimon.rest.RESTResponse;
+import org.apache.paimon.schema.TableSchema;
+
+import 
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonCreator;
+import 
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonGetter;
+import 
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import 
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonProperty;
+
+/** Response for table schema by a version. */
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class GetSchemaResponse implements RESTResponse {
+
+    private static final String FIELD_SCHEMA = "schema";
+
+    @JsonProperty(FIELD_SCHEMA)
+    private final TableSchema schema;
+
+    @JsonCreator
+    public GetSchemaResponse(@JsonProperty(FIELD_SCHEMA) TableSchema schema) {
+        this.schema = schema;
+    }
+
+    @JsonGetter(FIELD_SCHEMA)
+    public TableSchema getSchema() {
+        return schema;
+    }
+}
diff --git 
a/paimon-api/src/main/java/org/apache/paimon/rest/responses/ListSchemasResponse.java
 
b/paimon-api/src/main/java/org/apache/paimon/rest/responses/ListSchemasResponse.java
new file mode 100644
index 0000000000..493386abde
--- /dev/null
+++ 
b/paimon-api/src/main/java/org/apache/paimon/rest/responses/ListSchemasResponse.java
@@ -0,0 +1,69 @@
+/*
+ * 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.paimon.rest.responses;
+
+import org.apache.paimon.schema.TableSchema;
+
+import 
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonCreator;
+import 
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonGetter;
+import 
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import 
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonProperty;
+
+import java.util.List;
+
+/** Response for list schemas. */
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class ListSchemasResponse implements PagedResponse<TableSchema> {
+
+    private static final String FIELD_SCHEMAS = "schemas";
+    private static final String FIELD_NEXT_PAGE_TOKEN = "nextPageToken";
+
+    @JsonProperty(FIELD_SCHEMAS)
+    private final List<TableSchema> schemas;
+
+    @JsonProperty(FIELD_NEXT_PAGE_TOKEN)
+    private final String nextPageToken;
+
+    public ListSchemasResponse(@JsonProperty(FIELD_SCHEMAS) List<TableSchema> 
schemas) {
+        this(schemas, null);
+    }
+
+    @JsonCreator
+    public ListSchemasResponse(
+            @JsonProperty(FIELD_SCHEMAS) List<TableSchema> schemas,
+            @JsonProperty(FIELD_NEXT_PAGE_TOKEN) String nextPageToken) {
+        this.schemas = schemas;
+        this.nextPageToken = nextPageToken;
+    }
+
+    @JsonGetter(FIELD_SCHEMAS)
+    public List<TableSchema> getSchemas() {
+        return schemas;
+    }
+
+    @Override
+    public List<TableSchema> data() {
+        return schemas;
+    }
+
+    @JsonGetter(FIELD_NEXT_PAGE_TOKEN)
+    public String getNextPageToken() {
+        return nextPageToken;
+    }
+}
diff --git a/paimon-core/src/main/java/org/apache/paimon/catalog/Catalog.java 
b/paimon-core/src/main/java/org/apache/paimon/catalog/Catalog.java
index 7d26e03290..329abcf67b 100644
--- a/paimon-core/src/main/java/org/apache/paimon/catalog/Catalog.java
+++ b/paimon-core/src/main/java/org/apache/paimon/catalog/Catalog.java
@@ -31,6 +31,7 @@ import org.apache.paimon.predicate.Predicate;
 import org.apache.paimon.rest.responses.GetTagResponse;
 import org.apache.paimon.schema.Schema;
 import org.apache.paimon.schema.SchemaChange;
+import org.apache.paimon.schema.TableSchema;
 import org.apache.paimon.table.CatalogEnvironment;
 import org.apache.paimon.table.Instant;
 import org.apache.paimon.table.Table;
@@ -880,6 +881,37 @@ public interface Catalog extends AutoCloseable {
         throw new UnsupportedOperationException();
     }
 
+    /**
+     * Return the schema of a table for the given version. The version can be 
{@code EARLIEST},
+     * {@code LATEST}, or a schema ID.
+     *
+     * @param identifier path of the table
+     * @param version version of the schema
+     * @return the requested schema
+     * @throws TableNotExistException if the table does not exist
+     * @throws UnsupportedOperationException if the catalog does not support 
loading schemas
+     */
+    default Optional<TableSchema> loadSchema(Identifier identifier, String 
version)
+            throws TableNotExistException {
+        throw new UnsupportedOperationException();
+    }
+
+    /**
+     * Get a paged schema list of a table in descending schema ID order.
+     *
+     * @param identifier path of the table
+     * @param maxResults maximum number of results, or {@code null} for the 
server default
+     * @param pageToken token from the previous response, or {@code null} for 
the first page
+     * @return schemas and the token for the next page
+     * @throws TableNotExistException if the table does not exist
+     * @throws UnsupportedOperationException if the catalog does not support 
listing schemas
+     */
+    default PagedList<TableSchema> listSchemasPaged(
+            Identifier identifier, @Nullable Integer maxResults, @Nullable 
String pageToken)
+            throws TableNotExistException {
+        throw new UnsupportedOperationException();
+    }
+
     /**
      * Create a new branch for this table. By default, an empty branch will be 
created using the
      * latest schema. If you provide {@code #fromTag}, a branch will be 
created from the tag and the
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/catalog/DelegateCatalog.java 
b/paimon-core/src/main/java/org/apache/paimon/catalog/DelegateCatalog.java
index 2f20d38fde..bd342e1013 100644
--- a/paimon-core/src/main/java/org/apache/paimon/catalog/DelegateCatalog.java
+++ b/paimon-core/src/main/java/org/apache/paimon/catalog/DelegateCatalog.java
@@ -28,6 +28,7 @@ import org.apache.paimon.predicate.Predicate;
 import org.apache.paimon.rest.responses.GetTagResponse;
 import org.apache.paimon.schema.Schema;
 import org.apache.paimon.schema.SchemaChange;
+import org.apache.paimon.schema.TableSchema;
 import org.apache.paimon.table.Instant;
 import org.apache.paimon.table.Table;
 import org.apache.paimon.table.TableSnapshot;
@@ -223,6 +224,19 @@ public abstract class DelegateCatalog implements Catalog {
         return wrapped.listSnapshotsPaged(identifier, maxResults, pageToken);
     }
 
+    @Override
+    public Optional<TableSchema> loadSchema(Identifier identifier, String 
version)
+            throws TableNotExistException {
+        return wrapped.loadSchema(identifier, version);
+    }
+
+    @Override
+    public PagedList<TableSchema> listSchemasPaged(
+            Identifier identifier, @Nullable Integer maxResults, @Nullable 
String pageToken)
+            throws TableNotExistException {
+        return wrapped.listSchemasPaged(identifier, maxResults, pageToken);
+    }
+
     @Override
     public void rollbackTo(Identifier identifier, Instant instant, @Nullable 
Long fromSnapshot)
             throws Catalog.TableNotExistException {
diff --git a/paimon-core/src/main/java/org/apache/paimon/rest/RESTCatalog.java 
b/paimon-core/src/main/java/org/apache/paimon/rest/RESTCatalog.java
index 4fa683d7d4..0fb356966d 100644
--- a/paimon-core/src/main/java/org/apache/paimon/rest/RESTCatalog.java
+++ b/paimon-core/src/main/java/org/apache/paimon/rest/RESTCatalog.java
@@ -478,6 +478,34 @@ public class RESTCatalog implements Catalog {
         return true;
     }
 
+    @Override
+    public Optional<TableSchema> loadSchema(Identifier identifier, String 
version)
+            throws TableNotExistException {
+        try {
+            return Optional.ofNullable(api.loadSchema(identifier, version));
+        } catch (NoSuchResourceException e) {
+            if (StringUtils.equals(e.resourceType(), 
ErrorResponse.RESOURCE_TYPE_SCHEMA)) {
+                return Optional.empty();
+            }
+            throw new TableNotExistException(identifier);
+        } catch (ForbiddenException e) {
+            throw new TableNoPermissionException(identifier, e);
+        }
+    }
+
+    @Override
+    public PagedList<TableSchema> listSchemasPaged(
+            Identifier identifier, @Nullable Integer maxResults, @Nullable 
String pageToken)
+            throws TableNotExistException {
+        try {
+            return api.listSchemasPaged(identifier, maxResults, pageToken);
+        } catch (NoSuchResourceException e) {
+            throw new TableNotExistException(identifier);
+        } catch (ForbiddenException e) {
+            throw new TableNoPermissionException(identifier, e);
+        }
+    }
+
     @Override
     public boolean commitSnapshot(
             Identifier identifier,
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/rest/MockRESTCatalogTest.java 
b/paimon-core/src/test/java/org/apache/paimon/rest/MockRESTCatalogTest.java
index aec8fb775e..210db841eb 100644
--- a/paimon-core/src/test/java/org/apache/paimon/rest/MockRESTCatalogTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/rest/MockRESTCatalogTest.java
@@ -57,6 +57,7 @@ import 
org.apache.paimon.rest.exceptions.NotAuthorizedException;
 import org.apache.paimon.rest.exceptions.NotImplementedException;
 import org.apache.paimon.rest.requests.CreatePartitionsRequest;
 import org.apache.paimon.rest.responses.ConfigResponse;
+import org.apache.paimon.schema.FileSystemSchemaManager;
 import org.apache.paimon.schema.Schema;
 import org.apache.paimon.schema.SchemaChange;
 import org.apache.paimon.table.BlobDescriptorReaderFactory;
@@ -142,6 +143,44 @@ class MockRESTCatalogTest extends RESTCatalogTest {
         }
     }
 
+    @Test
+    void testCompatibilityWithServerWithoutSchemaEndpoints() throws Exception {
+        restCatalogServer.setSchemaEndpointsSupported(false);
+        Identifier identifier = Identifier.create("schema_compatibility", 
"table");
+        createTable(identifier, Collections.emptyMap(), 
Collections.singletonList("col1"));
+        restCatalogServer.clearReceivedHeaders();
+
+        FileStoreTable table = (FileStoreTable) catalog.getTable(identifier);
+        
assertThat(table.schemaManager()).isInstanceOf(FileSystemSchemaManager.class);
+        long firstSchemaId = table.schemaManager().latest().get().id();
+        catalog.alterTable(identifier, SchemaChange.setOption("key", "value"), 
false);
+        assertThat(((FileStoreTable) 
catalog.getTable(identifier)).schemaManager().latest().get())
+                .extracting(schema -> schema.options().get("key"))
+                .isEqualTo("value");
+        catalog.rollbackSchema(identifier, firstSchemaId);
+        assertThat(
+                        ((FileStoreTable) catalog.getTable(identifier))
+                                .schemaManager()
+                                .latest()
+                                .get()
+                                .id())
+                .isEqualTo(firstSchemaId);
+
+        ResourcePaths paths = new ResourcePaths("paimon");
+        assertThat(
+                        restCatalogServer.getReceivedHeaders(
+                                paths.schemas(
+                                        identifier.getDatabaseName(), 
identifier.getObjectName())))
+                .isEmpty();
+        assertThat(
+                        restCatalogServer.getReceivedHeaders(
+                                paths.schemas(
+                                        identifier.getDatabaseName(),
+                                        identifier.getObjectName(),
+                                        "LATEST")))
+                .isEmpty();
+    }
+
     @Test
     void testAuthFail() {
         Options options = new Options();
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogServer.java 
b/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogServer.java
index 59e9b870bc..90a948395b 100644
--- a/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogServer.java
+++ b/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogServer.java
@@ -96,7 +96,6 @@ import org.apache.paimon.rest.responses.GetTableResponse;
 import org.apache.paimon.rest.responses.GetTableSnapshotResponse;
 import org.apache.paimon.rest.responses.GetTableTokenResponse;
 import org.apache.paimon.rest.responses.GetTagResponse;
-import org.apache.paimon.rest.responses.GetVersionSnapshotResponse;
 import org.apache.paimon.rest.responses.GetViewResponse;
 import org.apache.paimon.rest.responses.ListBranchesResponse;
 import org.apache.paimon.rest.responses.ListConsumersResponse;
@@ -107,7 +106,6 @@ import 
org.apache.paimon.rest.responses.ListFunctionsResponse;
 import org.apache.paimon.rest.responses.ListPartitionsResponse;
 import org.apache.paimon.rest.responses.ListPermissionsResponse;
 import org.apache.paimon.rest.responses.ListPoliciesResponse;
-import org.apache.paimon.rest.responses.ListSnapshotsResponse;
 import org.apache.paimon.rest.responses.ListTableDetailsResponse;
 import org.apache.paimon.rest.responses.ListTablesGloballyResponse;
 import org.apache.paimon.rest.responses.ListTablesResponse;
@@ -167,7 +165,6 @@ import java.util.Collections;
 import java.util.Comparator;
 import java.util.HashMap;
 import java.util.HashSet;
-import java.util.Iterator;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
@@ -276,6 +273,7 @@ public class RESTCatalogServer {
 
     private volatile boolean partitionListingSupported = true;
     private volatile boolean partitionOptionsCreateSupported = true;
+    private volatile boolean schemaEndpointsSupported = true;
 
     public RESTCatalogServer(
             String dataPath, AuthProvider authProvider, ConfigResponse config, 
String warehouse) {
@@ -348,6 +346,10 @@ public class RESTCatalogServer {
         this.partitionOptionsCreateSupported = partitionOptionsCreateSupported;
     }
 
+    public void setSchemaEndpointsSupported(boolean schemaEndpointsSupported) {
+        this.schemaEndpointsSupported = schemaEndpointsSupported;
+    }
+
     public void clearReceivedListPartitionsByFilterRequests() {
         receivedListPartitionsByFilterRequests.clear();
     }
@@ -488,9 +490,7 @@ public class RESTCatalogServer {
                         return functionsHandle(parameters);
                     } else if (request.getPath().startsWith(databaseUri)) {
                         String[] resources =
-                                request.getPath()
-                                        .substring((databaseUri + 
"/").length())
-                                        .split("/");
+                                resourcePath.substring((databaseUri + 
"/").length()).split("/");
                         String databaseName = 
RESTUtil.decodeString(resources[0]);
                         if (noPermissionDatabases.contains(databaseName)) {
                             throw new 
Catalog.DatabaseNoPermissionException(databaseName);
@@ -533,6 +533,14 @@ public class RESTCatalogServer {
                                 resources.length == 4
                                         && 
ResourcePaths.TABLES.equals(resources[1])
                                         && 
ResourcePaths.SNAPSHOTS.equals(resources[3]);
+                        boolean isListSchemas =
+                                resources.length == 4
+                                        && 
ResourcePaths.TABLES.equals(resources[1])
+                                        && 
ResourcePaths.SCHEMAS.equals(resources[3]);
+                        boolean isLoadSchema =
+                                resources.length == 5
+                                        && 
ResourcePaths.TABLES.equals(resources[1])
+                                        && 
ResourcePaths.SCHEMAS.equals(resources[3]);
                         boolean isListConsumers =
                                 resources.length == 4
                                         && 
ResourcePaths.TABLES.equals(resources[1])
@@ -692,6 +700,12 @@ public class RESTCatalogServer {
                             return snapshotHandle(identifier);
                         } else if (isListSnapshots) {
                             return listSnapshots(identifier);
+                        } else if ((isListSchemas || isLoadSchema) && 
!schemaEndpointsSupported) {
+                            return new MockResponse().setResponseCode(404);
+                        } else if (isListSchemas) {
+                            return listSchemas(identifier, parameters);
+                        } else if (isLoadSchema) {
+                            return loadSchema(identifier, resources[4]);
                         } else if (isListConsumers) {
                             return listConsumers(identifier);
                         } else if (isResetConsumer) {
@@ -992,13 +1006,18 @@ public class RESTCatalogServer {
 
     private MockResponse listSnapshots(Identifier identifier) throws Exception 
{
         FileStoreTable table = (FileStoreTable) catalog.getTable(identifier);
-        Iterator<Snapshot> snapshots = table.snapshotManager().snapshots();
-        List<Snapshot> snapshotList = new ArrayList<>();
-        while (snapshots.hasNext()) {
-            snapshotList.add(snapshots.next());
-        }
-        ListSnapshotsResponse response = new 
ListSnapshotsResponse(snapshotList, null);
-        return new 
MockResponse().setResponseCode(200).setBody(RESTApi.toJson(response));
+        return RESTCatalogServerMetadataHandler.listSnapshots(table);
+    }
+
+    private MockResponse listSchemas(Identifier identifier, Map<String, 
String> parameters)
+            throws Exception {
+        FileStoreTable table = getFileTable(identifier);
+        return RESTCatalogServerMetadataHandler.listSchemas(
+                table, getMaxResults(parameters), parameters.get(PAGE_TOKEN));
+    }
+
+    private MockResponse loadSchema(Identifier identifier, String version) 
throws Exception {
+        return 
RESTCatalogServerMetadataHandler.loadSchema(getFileTable(identifier), version);
     }
 
     private MockResponse listConsumers(Identifier identifier) throws Exception 
{
@@ -1031,40 +1050,8 @@ public class RESTCatalogServer {
     }
 
     private MockResponse loadSnapshot(Identifier identifier, String version) 
throws Exception {
-
         FileStoreTable table = (FileStoreTable) catalog.getTable(identifier);
-        SnapshotManager snapshotManager = table.snapshotManager();
-        Snapshot snapshot = null;
-        try {
-            if (version.equals("EARLIEST")) {
-                snapshot = snapshotManager.earliestSnapshot();
-            } else if (version.equals("LATEST")) {
-                snapshot = snapshotManager.latestSnapshot();
-            } else {
-                try {
-                    long snapshotId = Long.parseLong(version);
-                    snapshot = snapshotManager.tryGetSnapshot(snapshotId);
-                } catch (NumberFormatException e) {
-                    Optional<Tag> tag = table.tagManager().get(version);
-                    if (tag.isPresent()) {
-                        snapshot = tag.get().trimToSnapshot();
-                    }
-                }
-            }
-        } catch (Exception ignored) {
-        }
-
-        if (snapshot == null) {
-            RESTResponse response =
-                    new ErrorResponse(
-                            ErrorResponse.RESOURCE_TYPE_SNAPSHOT,
-                            identifier.getDatabaseName(),
-                            "No Snapshot",
-                            404);
-            return mockResponse(response, 404);
-        }
-        GetVersionSnapshotResponse response = new 
GetVersionSnapshotResponse(snapshot);
-        return new 
MockResponse().setResponseCode(200).setBody(RESTApi.toJson(response));
+        return RESTCatalogServerMetadataHandler.loadSnapshot(table, version);
     }
 
     private Optional<MockResponse> checkTablePartitioned(Identifier 
identifier) {
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogServerMetadataHandler.java
 
b/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogServerMetadataHandler.java
new file mode 100644
index 0000000000..3fb47f146a
--- /dev/null
+++ 
b/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogServerMetadataHandler.java
@@ -0,0 +1,148 @@
+/*
+ * 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.paimon.rest;
+
+import org.apache.paimon.Snapshot;
+import org.apache.paimon.rest.responses.ErrorResponse;
+import org.apache.paimon.rest.responses.GetSchemaResponse;
+import org.apache.paimon.rest.responses.GetVersionSnapshotResponse;
+import org.apache.paimon.rest.responses.ListSchemasResponse;
+import org.apache.paimon.rest.responses.ListSnapshotsResponse;
+import org.apache.paimon.schema.FileSystemSchemaManager;
+import org.apache.paimon.schema.SchemaManager;
+import org.apache.paimon.schema.TableSchema;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.tag.Tag;
+import org.apache.paimon.utils.SnapshotManager;
+
+import okhttp3.mockwebserver.MockResponse;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+/** Metadata response handlers used by {@link RESTCatalogServer}. */
+final class RESTCatalogServerMetadataHandler {
+
+    private RESTCatalogServerMetadataHandler() {}
+
+    static MockResponse listSnapshots(FileStoreTable table) throws Exception {
+        Iterator<Snapshot> snapshots = table.snapshotManager().snapshots();
+        List<Snapshot> snapshotList = new ArrayList<>();
+        while (snapshots.hasNext()) {
+            snapshotList.add(snapshots.next());
+        }
+        ListSnapshotsResponse response = new 
ListSnapshotsResponse(snapshotList, null);
+        return response(response);
+    }
+
+    static MockResponse loadSnapshot(FileStoreTable table, String version) 
throws Exception {
+        SnapshotManager snapshotManager = table.snapshotManager();
+        Snapshot snapshot = null;
+        try {
+            if (version.equals("EARLIEST")) {
+                snapshot = snapshotManager.earliestSnapshot();
+            } else if (version.equals("LATEST")) {
+                snapshot = snapshotManager.latestSnapshot();
+            } else {
+                try {
+                    snapshot = 
snapshotManager.tryGetSnapshot(Long.parseLong(version));
+                } catch (NumberFormatException e) {
+                    Optional<Tag> tag = table.tagManager().get(version);
+                    if (tag.isPresent()) {
+                        snapshot = tag.get().trimToSnapshot();
+                    }
+                }
+            }
+        } catch (Exception ignored) {
+        }
+
+        if (snapshot == null) {
+            return notFound(ErrorResponse.RESOURCE_TYPE_SNAPSHOT, "No 
Snapshot");
+        }
+        return response(new GetVersionSnapshotResponse(snapshot));
+    }
+
+    static MockResponse loadSchema(FileStoreTable table, String version) 
throws Exception {
+        SchemaManager schemaManager = schemaManager(table);
+        TableSchema schema = null;
+        if ("LATEST".equals(version)) {
+            schema = schemaManager.latest().orElse(null);
+        } else {
+            List<TableSchema> schemas = schemaManager.listAll();
+            if ("EARLIEST".equals(version)) {
+                schema =
+                        schemas.stream()
+                                .min(Comparator.comparingLong(TableSchema::id))
+                                .orElse(null);
+            } else {
+                try {
+                    long schemaId = Long.parseLong(version);
+                    if (schemaManager.schemaExists(schemaId)) {
+                        schema = schemaManager.schema(schemaId);
+                    }
+                } catch (NumberFormatException ignored) {
+                }
+            }
+        }
+
+        if (schema == null) {
+            return notFound(ErrorResponse.RESOURCE_TYPE_SCHEMA, "No Schema");
+        }
+        return response(new GetSchemaResponse(schema));
+    }
+
+    static MockResponse listSchemas(FileStoreTable table, int maxResults, 
String pageToken)
+            throws Exception {
+        List<TableSchema> schemas = schemaManager(table).listAll();
+        schemas.sort(Comparator.comparingLong(TableSchema::id).reversed());
+        if (pageToken != null) {
+            long previousSchemaId = Long.parseLong(pageToken);
+            schemas =
+                    schemas.stream()
+                            .filter(schema -> schema.id() < previousSchemaId)
+                            .collect(Collectors.toList());
+        }
+
+        int resultSize = Math.min(maxResults, schemas.size());
+        List<TableSchema> result = new ArrayList<>(schemas.subList(0, 
resultSize));
+        String nextPageToken =
+                resultSize < schemas.size()
+                        ? Long.toString(result.get(result.size() - 1).id())
+                        : null;
+        return response(new ListSchemasResponse(result, nextPageToken));
+    }
+
+    private static SchemaManager schemaManager(FileStoreTable table) {
+        return new FileSystemSchemaManager(table.fileIO(), table.location());
+    }
+
+    private static MockResponse notFound(String resourceType, String message) 
throws Exception {
+        return new MockResponse()
+                .setResponseCode(404)
+                .setBody(RESTApi.toJson(new ErrorResponse(resourceType, null, 
message, 404)));
+    }
+
+    private static MockResponse response(RESTResponse response) throws 
Exception {
+        return new 
MockResponse().setResponseCode(200).setBody(RESTApi.toJson(response));
+    }
+}
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogTest.java 
b/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogTest.java
index 61958cb7bd..1cf891dbd3 100644
--- a/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogTest.java
@@ -81,6 +81,7 @@ import org.apache.paimon.schema.FileSystemSchemaManager;
 import org.apache.paimon.schema.Schema;
 import org.apache.paimon.schema.SchemaChange;
 import org.apache.paimon.schema.SchemaManager;
+import org.apache.paimon.schema.TableSchema;
 import org.apache.paimon.table.FileStoreTable;
 import org.apache.paimon.table.FormatTable;
 import org.apache.paimon.table.Instant;
@@ -2470,6 +2471,57 @@ public abstract class RESTCatalogTest extends 
CatalogTestBase {
                                 + " is still referenced by 
snapshots/tags/changelogs");
     }
 
+    @Test
+    public void testLoadSchema() throws Exception {
+        Identifier identifier = Identifier.create("test_list_schemas", 
"table_load");
+        createTable(identifier, Maps.newHashMap(), Lists.newArrayList("col1"));
+
+        FileStoreTable table = (FileStoreTable) catalog.getTable(identifier);
+        SchemaManager local = new FileSystemSchemaManager(table.fileIO(), 
table.location());
+        TableSchema first = local.latest().get();
+
+        catalog.alterTable(identifier, SchemaChange.setOption("aa", "bb"), 
false);
+        TableSchema latest = local.latest().get();
+
+        assertThat(catalog.loadSchema(identifier, "EARLIEST")).contains(first);
+        assertThat(catalog.loadSchema(identifier, "LATEST")).contains(latest);
+        assertThat(catalog.loadSchema(identifier, 
Long.toString(first.id()))).contains(first);
+        assertThat(catalog.loadSchema(identifier, "9999")).isEmpty();
+        assertThat(catalog.loadSchema(identifier, 
"invalid-version")).isEmpty();
+    }
+
+    @Test
+    public void testListSchemasPaged() throws Exception {
+        Identifier identifier = Identifier.create("test_list_schemas", 
"table_paged");
+        createTable(identifier, Maps.newHashMap(), Lists.newArrayList("col1"));
+
+        FileStoreTable table = (FileStoreTable) catalog.getTable(identifier);
+        SchemaManager local = new FileSystemSchemaManager(table.fileIO(), 
table.location());
+        catalog.alterTable(identifier, SchemaChange.setOption("aa", "bb"), 
false);
+        catalog.alterTable(identifier, SchemaChange.setOption("cc", "dd"), 
false);
+        catalog.alterTable(identifier, SchemaChange.setOption("ee", "ff"), 
false);
+
+        List<TableSchema> expected = local.listAll();
+        
expected.sort(java.util.Comparator.comparingLong(TableSchema::id).reversed());
+        PagedList<TableSchema> firstPage = 
catalog.listSchemasPaged(identifier, 2, null);
+        
assertThat(firstPage.getElements()).containsExactlyElementsOf(expected.subList(0,
 2));
+        assertThat(firstPage.getNextPageToken()).isNotNull();
+
+        PagedList<TableSchema> secondPage =
+                catalog.listSchemasPaged(identifier, 2, 
firstPage.getNextPageToken());
+        
assertThat(secondPage.getElements()).containsExactlyElementsOf(expected.subList(2,
 4));
+        assertThat(secondPage.getNextPageToken()).isNull();
+    }
+
+    @Test
+    public void testSchemaMethodsTableNotExist() {
+        Identifier missing = Identifier.create("test_list_schemas", 
"missing_table");
+        assertThatThrownBy(() -> catalog.loadSchema(missing, "LATEST"))
+                .isInstanceOf(Catalog.TableNotExistException.class);
+        assertThatThrownBy(() -> catalog.listSchemasPaged(missing, null, null))
+                .isInstanceOf(Catalog.TableNotExistException.class);
+    }
+
     @Test
     public void testDataTokenExpired() throws Exception {
         this.catalog = newRestCatalogWithDataToken();

Reply via email to