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 672509a605 [rest] Align the OpenAPI spec, RESTApi and openapi-v4 
actions, checked in CI (#10599)
672509a605 is described below

commit 672509a605c18629bed647dc205bdd5ff881fc60
Author: Dapeng Sun(孙大鹏) <[email protected]>
AuthorDate: Sat Oct 17 21:54:35 2026 +0800

    [rest] Align the OpenAPI spec, RESTApi and openapi-v4 actions, checked in 
CI (#10599)
---
 docs/static/rest-catalog-open-api.yaml             | 252 +++++++++++++++-----
 .../apache/paimon/rest/auth/DLFOpenApiActions.java |  29 +++
 .../java/org/apache/paimon/rest/OpenApiSpec.java   | 130 +++++++++++
 .../apache/paimon/rest/PagedResponseSpecTest.java  | 102 ++++++++
 .../apache/paimon/rest/RESTApiCoverageTest.java    | 258 +++++++++++++++++++++
 .../paimon/rest/auth/DLFOpenApiActionsTest.java    |  54 ++++-
 .../paimon/rest/auth/DLFOpenApiV4SignerTest.java   |  12 +-
 7 files changed, 767 insertions(+), 70 deletions(-)

diff --git a/docs/static/rest-catalog-open-api.yaml 
b/docs/static/rest-catalog-open-api.yaml
index ab100585ff..6d16745d30 100644
--- a/docs/static/rest-catalog-open-api.yaml
+++ b/docs/static/rest-catalog-open-api.yaml
@@ -815,6 +815,130 @@ paths:
           $ref: '#/components/responses/TableNotExistErrorResponse'
         "500":
           $ref: '#/components/responses/ServerErrorResponse'
+  /v1/{prefix}/databases/{database}/tables/{table}/replace:
+    post:
+      tags:
+        - table
+      summary: Replace table
+      operationId: replaceTable
+      parameters:
+        - name: prefix
+          in: path
+          required: true
+          schema:
+            type: string
+        - name: database
+          in: path
+          required: true
+          schema:
+            type: string
+        - name: table
+          in: path
+          required: true
+          schema:
+            type: string
+      requestBody:
+        content:
+          application/json:
+            schema:
+              $ref: '#/components/schemas/ReplaceTableRequest'
+      responses:
+        "200":
+          description: Success, no content
+        "401":
+          $ref: '#/components/responses/UnauthorizedErrorResponse'
+        "404":
+          $ref: '#/components/responses/TableNotExistErrorResponse'
+        "500":
+          $ref: '#/components/responses/ServerErrorResponse'
+  /v1/{prefix}/databases/{database}/tables/{table}/schemas:
+    get:
+      tags:
+        - table
+      summary: List schemas
+      operationId: listSchemas
+      parameters:
+        - name: prefix
+          in: path
+          required: true
+          schema:
+            type: string
+        - name: database
+          in: path
+          required: true
+          schema:
+            type: string
+        - name: table
+          in: path
+          required: true
+          schema:
+            type: string
+        - name: maxResults
+          in: query
+          schema:
+            type: integer
+            format: int32
+        - name: pageToken
+          in: query
+          schema:
+            type: string
+      responses:
+        "200":
+          description: OK
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ListSchemasResponse'
+        "401":
+          $ref: '#/components/responses/UnauthorizedErrorResponse'
+        "404":
+          $ref: '#/components/responses/TableNotExistErrorResponse'
+        "500":
+          $ref: '#/components/responses/ServerErrorResponse'
+  /v1/{prefix}/databases/{database}/tables/{table}/schemas/{version}:
+    get:
+      tags:
+        - table
+      summary: Get schema
+      operationId: getSchema
+      parameters:
+        - name: prefix
+          in: path
+          required: true
+          schema:
+            type: string
+        - name: database
+          in: path
+          required: true
+          schema:
+            type: string
+        - name: table
+          in: path
+          required: true
+          schema:
+            type: string
+        - name: version
+          in: path
+          required: true
+          schema:
+            type: string
+      responses:
+        "200":
+          description: OK
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/GetSchemaResponse'
+        "401":
+          $ref: '#/components/responses/UnauthorizedErrorResponse'
+        "404":
+          description: Not Found - the table or the schema version does not 
exist
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ErrorResponse'
+        "500":
+          $ref: '#/components/responses/ServerErrorResponse'
   /v1/{prefix}/databases/{database}/tables/{table}/token:
     get:
       tags:
@@ -1433,49 +1557,6 @@ paths:
           $ref: '#/components/responses/BranchNotExistErrorResponse'
         "500":
           $ref: '#/components/responses/ServerErrorResponse'
-  /v1/{prefix}/databases/{database}/tables/{table}/branches/{branch}/rename:
-    post:
-      tags:
-        - branch
-      summary: rename branch
-      operationId: renameBranch
-      parameters:
-        - name: prefix
-          in: path
-          required: true
-          schema:
-            type: string
-        - name: database
-          in: path
-          required: true
-          schema:
-            type: string
-        - name: table
-          in: path
-          required: true
-          schema:
-            type: string
-        - name: branch
-          in: path
-          required: true
-          schema:
-            type: string
-      requestBody:
-        content:
-          application/json:
-            schema:
-              $ref: '#/components/schemas/RenameBranchRequest'
-      responses:
-        "200":
-          description: Success, no content
-        "401":
-          $ref: '#/components/responses/UnauthorizedErrorResponse'
-        "404":
-          $ref: '#/components/responses/BranchNotExistErrorResponse'
-        "409":
-          description: Branch already exists
-        "500":
-          $ref: '#/components/responses/ServerErrorResponse'
   /v1/{prefix}/databases/{database}/tables/{table}/branches/{branch}/forward:
     post:
       tags:
@@ -3686,6 +3767,58 @@ components:
         schemaId:
           type: integer
           format: int64
+    ReplaceTableRequest:
+      type: object
+      properties:
+        schema:
+          $ref: '#/components/schemas/Schema'
+    TableSchema:
+      type: object
+      properties:
+        version:
+          type: integer
+          format: int32
+        id:
+          type: integer
+          format: int64
+        fields:
+          type: array
+          items:
+            $ref: '#/components/schemas/DataField'
+        highestFieldId:
+          type: integer
+          format: int32
+        partitionKeys:
+          type: array
+          items:
+            type: string
+        primaryKeys:
+          type: array
+          items:
+            type: string
+        options:
+          type: object
+          additionalProperties:
+            type: string
+        comment:
+          type: string
+        timeMillis:
+          type: integer
+          format: int64
+    ListSchemasResponse:
+      type: object
+      properties:
+        schemas:
+          type: array
+          items:
+            $ref: '#/components/schemas/TableSchema'
+        nextPageToken:
+          type: [ string, "null" ]
+    GetSchemaResponse:
+      type: object
+      properties:
+        schema:
+          $ref: '#/components/schemas/TableSchema'
     Instant:
       anyOf:
         - $ref: '#/components/schemas/SnapshotInstant'
@@ -3822,7 +3955,7 @@ components:
           items:
             $ref: '#/components/schemas/Snapshot'
         nextPageToken:
-          type: string
+          type: [ string, "null" ]
     AuthTableQueryRequest:
       type: object
       properties:
@@ -3876,7 +4009,7 @@ components:
           items:
             type: string
         nextPageToken:
-          type: string
+          type: [ string, "null" ]
     GetDatabaseResponse:
       type: object
       properties:
@@ -3911,7 +4044,7 @@ components:
             type: string
             description: table name of the table.
         nextPageToken:
-          type: string
+          type: [ string, "null" ]
     ListTableDetailsResponse:
       type: object
       properties:
@@ -3920,7 +4053,7 @@ components:
           items:
             $ref: '#/components/schemas/GetTableResponse'
         nextPageToken:
-          type: string
+          type: [ string, "null" ]
     ListTablesGloballyResponse:
       type: object
       properties:
@@ -3929,7 +4062,7 @@ components:
           items:
             $ref: '#/components/schemas/Identifier'
         nextPageToken:
-          type: string
+          type: [ string, "null" ]
     ConfigResponse:
       type: object
       properties:
@@ -3949,7 +4082,7 @@ components:
           items:
             $ref: '#/components/schemas/Partition'
         nextPageToken:
-          type: string
+          type: [ string, "null" ]
     CreateBranchRequest:
       type: object
       properties:
@@ -3957,11 +4090,6 @@ components:
           type: string
         fromTag:
           type: [ string, "null" ]
-    RenameBranchRequest:
-      type: object
-      properties:
-        toBranch:
-          type: string
     ForwardBranchRequest:
       type: object
       properties:
@@ -4012,7 +4140,7 @@ components:
           items:
             type: string
         nextPageToken:
-          type: string
+          type: [ string, "null" ]
     TagInfo:
       type: object
       properties:
@@ -4056,7 +4184,7 @@ components:
             type: string
             description: view name of the view.
         nextPageToken:
-          type: string
+          type: [ string, "null" ]
     ListViewDetailsResponse:
       type: object
       properties:
@@ -4065,7 +4193,7 @@ components:
           items:
             $ref: '#/components/schemas/GetViewResponse'
         nextPageToken:
-          type: string
+          type: [ string, "null" ]
     ListFunctionsGloballyResponse:
       type: object
       properties:
@@ -4074,7 +4202,7 @@ components:
           items:
             $ref: '#/components/schemas/Identifier'
         nextPageToken:
-          type: string
+          type: [ string, "null" ]
     ListViewsGloballyResponse:
       type: object
       properties:
@@ -4083,7 +4211,7 @@ components:
           items:
             $ref: '#/components/schemas/Identifier'
         nextPageToken:
-          type: string
+          type: [ string, "null" ]
     ListFunctionsResponse:
       type: object
       properties:
@@ -4092,7 +4220,7 @@ components:
           items:
             type: string
         nextPageToken:
-          type: string
+          type: [ string, "null" ]
     ListFunctionDetailsResponse:
       type: object
       properties:
@@ -4101,7 +4229,7 @@ components:
           items:
             $ref: '#/components/schemas/GetFunctionResponse'
         nextPageToken:
-          type: string
+          type: [ string, "null" ]
     GetFunctionResponse:
       type: object
       properties:
@@ -4159,7 +4287,7 @@ components:
           items:
             $ref: '#/components/schemas/ConsumerInfo'
         nextPageToken:
-          type: string
+          type: [ string, "null" ]
           description: Token for the next page of results
     ResetConsumerRequest:
       type: object
diff --git 
a/paimon-api/src/main/java/org/apache/paimon/rest/auth/DLFOpenApiActions.java 
b/paimon-api/src/main/java/org/apache/paimon/rest/auth/DLFOpenApiActions.java
index cb5d581593..832a0595fa 100644
--- 
a/paimon-api/src/main/java/org/apache/paimon/rest/auth/DLFOpenApiActions.java
+++ 
b/paimon-api/src/main/java/org/apache/paimon/rest/auth/DLFOpenApiActions.java
@@ -44,14 +44,32 @@ final class DLFOpenApiActions {
         {"GET", "/v1/{prefix}/databases/{database}/tables", "ListTables"},
         {"POST", "/v1/{prefix}/databases/{database}/tables", "CreateTable"},
         {"GET", "/v1/{prefix}/databases/{database}/table-details", 
"ListTableDetails"},
+        {"GET", "/v1/{prefix}/tables", "ListTablesGlobally"},
         {"GET", "/v1/{prefix}/databases/{database}/tables/{table}", 
"GetTable"},
         {"POST", "/v1/{prefix}/databases/{database}/tables/{table}", 
"AlterTable"},
         {"DELETE", "/v1/{prefix}/databases/{database}/tables/{table}", 
"DropTable"},
+        {"GET", "/v1/{prefix}/tables/id/{tableId}", "GetTableById"},
         {"POST", "/v1/{prefix}/tables/rename", "RenameTable"},
         {"POST", "/v1/{prefix}/databases/{database}/tables/{table}/auth", 
"AuthTableQuery"},
         {"POST", "/v1/{prefix}/databases/{database}/tables/{table}/commit", 
"CommitTable"},
+        {
+            "POST",
+            "/v1/{prefix}/databases/{database}/tables/{table}/commit-lock",
+            "AcquireCommitLock"
+        },
+        {
+            "POST",
+            
"/v1/{prefix}/databases/{database}/tables/{table}/commit-lock/renew",
+            "RenewCommitLock"
+        },
+        {"POST", "/v1/{prefix}/databases/{database}/tables/{table}/replace", 
"ReplaceTable"},
         {"GET", "/v1/{prefix}/databases/{database}/tables/{table}/token", 
"GetTableToken"},
         {"POST", "/v1/{prefix}/databases/{database}/tables/{table}/rollback", 
"RollbackToSnapshot"},
+        {
+            "POST",
+            "/v1/{prefix}/databases/{database}/tables/{table}/rollback-schema",
+            "RollbackSchema"
+        },
         {"GET", "/v1/{prefix}/databases/{database}/tables/{table}/snapshot", 
"GetTableSnapshot"},
         {"GET", "/v1/{prefix}/databases/{database}/tables/{table}/snapshots", 
"ListSnapshots"},
         {
@@ -59,6 +77,8 @@ final class DLFOpenApiActions {
             
"/v1/{prefix}/databases/{database}/tables/{table}/snapshots/{version}",
             "GetVersionSnapshot"
         },
+        {"GET", "/v1/{prefix}/databases/{database}/tables/{table}/schemas", 
"ListSchemas"},
+        {"GET", 
"/v1/{prefix}/databases/{database}/tables/{table}/schemas/{version}", 
"GetSchema"},
         {"GET", "/v1/{prefix}/databases/{database}/tables/{table}/partitions", 
"ListPartitions"},
         {"POST", 
"/v1/{prefix}/databases/{database}/tables/{table}/partitions", 
"CreatePartitions"},
         {
@@ -97,12 +117,19 @@ final class DLFOpenApiActions {
         {"POST", "/v1/{prefix}/databases/{database}/tables/{table}/tags", 
"CreateTag"},
         {"GET", "/v1/{prefix}/databases/{database}/tables/{table}/tags/{tag}", 
"GetTag"},
         {"DELETE", 
"/v1/{prefix}/databases/{database}/tables/{table}/tags/{tag}", "DropTag"},
+        {"GET", "/v1/{prefix}/databases/{database}/tables/{table}/consumers", 
"ListConsumers"},
+        {
+            "POST",
+            "/v1/{prefix}/databases/{database}/tables/{table}/consumers/reset",
+            "ResetConsumer"
+        },
         {"GET", "/v1/{prefix}/databases/{database}/tables/{table}/policies", 
"ListPolicies"},
         {"POST", "/v1/{prefix}/databases/{database}/tables/{table}/policies", 
"CreatePolicy"},
         {"POST", 
"/v1/{prefix}/databases/{database}/tables/{table}/policies/drop", "DropPolicy"},
         {"GET", "/v1/{prefix}/databases/{database}/views", "ListViews"},
         {"POST", "/v1/{prefix}/databases/{database}/views", "CreateView"},
         {"GET", "/v1/{prefix}/databases/{database}/view-details", 
"ListViewDetails"},
+        {"GET", "/v1/{prefix}/views", "ListViewsGlobally"},
         {"GET", "/v1/{prefix}/databases/{database}/views/{view}", "GetView"},
         {"POST", "/v1/{prefix}/databases/{database}/views/{view}", 
"AlterView"},
         {"DELETE", "/v1/{prefix}/databases/{database}/views/{view}", 
"DropView"},
@@ -125,6 +152,8 @@ final class DLFOpenApiActions {
         },
         {"GET", "/v1/{prefix}/databases/{database}/functions", 
"ListFunctions"},
         {"POST", "/v1/{prefix}/databases/{database}/functions", 
"CreateFunction"},
+        {"GET", "/v1/{prefix}/databases/{database}/function-details", 
"ListFunctionDetails"},
+        {"GET", "/v1/{prefix}/functions", "ListFunctionsGlobally"},
         {"GET", "/v1/{prefix}/databases/{database}/functions/{function}", 
"GetFunction"},
         {"POST", "/v1/{prefix}/databases/{database}/functions/{function}", 
"AlterFunction"},
         {"DELETE", "/v1/{prefix}/databases/{database}/functions/{function}", 
"DropFunction"},
diff --git a/paimon-api/src/test/java/org/apache/paimon/rest/OpenApiSpec.java 
b/paimon-api/src/test/java/org/apache/paimon/rest/OpenApiSpec.java
new file mode 100644
index 0000000000..b5b416ff02
--- /dev/null
+++ b/paimon-api/src/test/java/org/apache/paimon/rest/OpenApiSpec.java
@@ -0,0 +1,130 @@
+/*
+ * 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.shade.jackson2.com.fasterxml.jackson.databind.JsonNode;
+import 
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.dataformat.yaml.YAMLMapper;
+
+import javax.annotation.Nullable;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.TreeSet;
+
+/** The operations of the REST OpenAPI specs in docs/static, each as "METHOD 
/path/{template}". */
+public final class OpenApiSpec {
+
+    private static final List<String> FILES =
+            Arrays.asList("rest-catalog-open-api.yaml", 
"rest-management-open-api.yaml");
+    private static final List<String> METHODS =
+            Arrays.asList("get", "put", "post", "delete", "patch", "head");
+
+    private OpenApiSpec() {}
+
+    public static Set<String> operations() throws IOException {
+        File directory = specDirectory();
+        Set<String> operations = new TreeSet<>();
+        for (String file : FILES) {
+            Iterator<Map.Entry<String, JsonNode>> paths =
+                    new YAMLMapper().readTree(new File(directory, 
file)).get("paths").fields();
+            while (paths.hasNext()) {
+                Map.Entry<String, JsonNode> path = paths.next();
+                Iterator<String> methods = path.getValue().fieldNames();
+                while (methods.hasNext()) {
+                    String method = methods.next();
+                    if (METHODS.contains(method)) {
+                        operations.add(method.toUpperCase(Locale.ROOT) + " " + 
path.getKey());
+                    }
+                }
+            }
+        }
+        return operations;
+    }
+
+    /** The component schemas of both specs, by name. */
+    public static Map<String, JsonNode> schemas() throws IOException {
+        File directory = specDirectory();
+        Map<String, JsonNode> schemas = new TreeMap<>();
+        for (String file : FILES) {
+            Iterator<Map.Entry<String, JsonNode>> entries =
+                    new YAMLMapper()
+                            .readTree(new File(directory, file))
+                            .path("components")
+                            .path("schemas")
+                            .fields();
+            while (entries.hasNext()) {
+                Map.Entry<String, JsonNode> entry = entries.next();
+                schemas.put(entry.getKey(), entry.getValue());
+            }
+        }
+        return schemas;
+    }
+
+    /** Returns the operation a request matches, preferring literal segments, 
or null. */
+    @Nullable
+    public static String match(Set<String> operations, String method, String 
path) {
+        String[] segments = path.split("/", -1);
+        String best = null;
+        int bestLiterals = -1;
+        for (String operation : operations) {
+            String[] parts = operation.split(" ", 2);
+            String[] template = parts[1].split("/", -1);
+            if (!parts[0].equals(method) || template.length != 
segments.length) {
+                continue;
+            }
+            int literals = 0;
+            boolean matches = true;
+            for (int i = 0; i < template.length && matches; i++) {
+                if (template[i].startsWith("{")) {
+                    matches = !segments[i].isEmpty();
+                } else {
+                    matches = template[i].equals(segments[i]);
+                    literals++;
+                }
+            }
+            if (matches && literals > bestLiterals) {
+                best = operation;
+                bestLiterals = literals;
+            }
+        }
+        return best;
+    }
+
+    /** Replaces each {name} with {} so templates compare whatever their 
parameters are called. */
+    public static String normalize(String operation) {
+        return operation.replaceAll("\\{[^}]*}", "{}");
+    }
+
+    private static File specDirectory() {
+        for (File dir = new File("").getAbsoluteFile(); dir != null; dir = 
dir.getParentFile()) {
+            File candidate = new File(dir, "docs/static");
+            if (new File(candidate, FILES.get(0)).isFile()) {
+                return candidate;
+            }
+        }
+        throw new IllegalStateException("docs/static/" + FILES.get(0) + " not 
found");
+    }
+}
diff --git 
a/paimon-api/src/test/java/org/apache/paimon/rest/PagedResponseSpecTest.java 
b/paimon-api/src/test/java/org/apache/paimon/rest/PagedResponseSpecTest.java
new file mode 100644
index 0000000000..3ff1bff819
--- /dev/null
+++ b/paimon-api/src/test/java/org/apache/paimon/rest/PagedResponseSpecTest.java
@@ -0,0 +1,102 @@
+/*
+ * 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.rest.responses.PagedResponse;
+
+import 
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.databind.JsonNode;
+import 
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.databind.ObjectMapper;
+import 
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.databind.node.MissingNode;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.File;
+import java.lang.reflect.Modifier;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** The last page of every {@link PagedResponse} must be valid against the 
OpenAPI spec. */
+class PagedResponseSpecTest {
+
+    private static final String NEXT_PAGE_TOKEN = "nextPageToken";
+
+    @Test
+    void testLastPagesMatchTheOpenApiSpec() throws Exception {
+        Map<String, JsonNode> schemas = OpenApiSpec.schemas();
+        List<Class<?>> responses = pagedResponses();
+        assertThat(responses).isNotEmpty();
+
+        Map<String, String> rejected = new TreeMap<>();
+        for (Class<?> response : responses) {
+            String name = response.getSimpleName();
+            JsonNode token =
+                    schemas.getOrDefault(name, MissingNode.getInstance())
+                            .path("properties")
+                            .path(NEXT_PAGE_TOKEN);
+            String lastPage = RESTApi.toJson(RESTApi.fromJson("{}", response));
+            if (token.isMissingNode()) {
+                rejected.put(name, "the spec has no " + name + "." + 
NEXT_PAGE_TOKEN);
+            } else if (new 
ObjectMapper().readTree(lastPage).path(NEXT_PAGE_TOKEN).isNull()
+                    && !allowsNull(token)) {
+                rejected.put(name, "sends a null " + NEXT_PAGE_TOKEN + " the 
spec does not allow");
+            }
+        }
+        assertThat(rejected).as("last pages the OpenAPI spec 
rejects").isEmpty();
+    }
+
+    private static boolean allowsNull(JsonNode schema) {
+        if (schema.path("nullable").asBoolean(false)) {
+            return true;
+        }
+        for (JsonNode type : schema.path("type")) {
+            if ("null".equals(type.asText())) {
+                return true;
+            }
+        }
+        return "null".equals(schema.path("type").asText());
+    }
+
+    /** Every concrete {@link PagedResponse} next to it on the class path. */
+    private static List<Class<?>> pagedResponses() throws Exception {
+        String suffix = ".class";
+        File directory =
+                new File(PagedResponse.class.getResource("PagedResponse" + 
suffix).toURI())
+                        .getParentFile();
+        List<Class<?>> responses = new ArrayList<>();
+        for (String file : directory.list()) {
+            if (!file.endsWith(suffix) || file.contains("$")) {
+                continue;
+            }
+            Class<?> type =
+                    Class.forName(
+                            PagedResponse.class.getPackage().getName()
+                                    + "."
+                                    + file.substring(0, file.length() - 
suffix.length()));
+            if (PagedResponse.class.isAssignableFrom(type)
+                    && !Modifier.isAbstract(type.getModifiers())) {
+                responses.add(type);
+            }
+        }
+        return responses;
+    }
+}
diff --git 
a/paimon-api/src/test/java/org/apache/paimon/rest/RESTApiCoverageTest.java 
b/paimon-api/src/test/java/org/apache/paimon/rest/RESTApiCoverageTest.java
new file mode 100644
index 0000000000..7ec9360195
--- /dev/null
+++ b/paimon-api/src/test/java/org/apache/paimon/rest/RESTApiCoverageTest.java
@@ -0,0 +1,258 @@
+/*
+ * 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.catalog.Identifier;
+import org.apache.paimon.function.Function;
+import org.apache.paimon.function.FunctionImpl;
+import org.apache.paimon.management.DataPolicy;
+import org.apache.paimon.management.ListPermissionsRequest;
+import org.apache.paimon.management.ListPoliciesRequest;
+import org.apache.paimon.management.PermissionAssignment;
+import org.apache.paimon.management.PermissionResource;
+import org.apache.paimon.management.ResourceType;
+import org.apache.paimon.management.RowFilter;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.view.SemanticViewDefinition;
+
+import com.sun.net.httpserver.HttpServer;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import javax.annotation.Nullable;
+
+import java.io.IOException;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.lang.reflect.Parameter;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.TreeSet;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+import static org.apache.paimon.options.CatalogOptions.WAREHOUSE;
+import static org.apache.paimon.rest.RESTCatalogInternalOptions.PREFIX;
+import static org.apache.paimon.rest.RESTCatalogOptions.DLF_ACCESS_KEY_ID;
+import static org.apache.paimon.rest.RESTCatalogOptions.DLF_ACCESS_KEY_SECRET;
+import static org.apache.paimon.rest.RESTCatalogOptions.DLF_REGION;
+import static org.apache.paimon.rest.RESTCatalogOptions.DLF_SIGNING_ALGORITHM;
+import static org.apache.paimon.rest.RESTCatalogOptions.TOKEN_PROVIDER;
+import static org.apache.paimon.rest.RESTCatalogOptions.URI;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Every request {@link RESTApi} sends must be in the OpenAPI spec and carry 
an openapi-v4 action.
+ */
+class RESTApiCoverageTest {
+
+    /** RESTApi methods with no entry in DLFOpenApiActions; they are signed 
without an action. */
+    private static final Set<String> WITHOUT_ACTION = Collections.emptySet();
+
+    /** A value per parameter type; "SELECT" is valid both as a name and as an 
access. */
+    private static final Map<Class<?>, Object> ARGUMENTS = arguments();
+
+    private final List<String[]> requests = new CopyOnWriteArrayList<>();
+    private HttpServer server;
+    private Options options;
+
+    @BeforeEach
+    void setUp() throws IOException {
+        server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+        server.createContext(
+                "/v1/",
+                exchange -> {
+                    requests.add(
+                            new String[] {
+                                exchange.getRequestMethod(),
+                                exchange.getRequestURI().getRawPath(),
+                                
exchange.getRequestHeaders().getFirst("x-acs-action")
+                            });
+                    byte[] data = "{}".getBytes(StandardCharsets.UTF_8);
+                    exchange.getResponseHeaders().set("Content-Type", 
"application/json");
+                    exchange.sendResponseHeaders(200, data.length);
+                    exchange.getResponseBody().write(data);
+                    exchange.close();
+                });
+        server.start();
+
+        options = new Options();
+        options.set(URI, "http://127.0.0.1:"; + server.getAddress().getPort());
+        options.set(PREFIX, "clg-paimon-1");
+        options.set(TOKEN_PROVIDER, "dlf");
+        options.set(DLF_ACCESS_KEY_ID, "ak");
+        options.set(DLF_ACCESS_KEY_SECRET, "sk");
+        options.set(DLF_REGION, "cn-hangzhou");
+        options.set(DLF_SIGNING_ALGORITHM, "openapi-v4");
+    }
+
+    @AfterEach
+    void tearDown() {
+        if (server != null) {
+            server.stop(0);
+        }
+    }
+
+    /** A new REST call fails here until it is added to DLFOpenApiActions or 
WITHOUT_ACTION. */
+    @Test
+    void testEveryRequestCarriesAnAction() throws Exception {
+        Map<String, List<String>> withoutAction = new TreeMap<>();
+        sendEveryRequest()
+                .forEach(
+                        (name, sent) -> {
+                            for (String[] request : sent) {
+                                if (request[2] == null) {
+                                    withoutAction
+                                            .computeIfAbsent(name, k -> new 
ArrayList<>())
+                                            .add(request[0] + " " + 
request[1]);
+                                }
+                            }
+                        });
+
+        Map<String, List<String>> missing = new TreeMap<>(withoutAction);
+        missing.keySet().removeAll(WITHOUT_ACTION);
+        assertThat(missing)
+                .as("requests without x-acs-action; map them in 
DLFOpenApiActions")
+                .isEmpty();
+        assertThat(withoutAction.keySet())
+                .as("WITHOUT_ACTION entries that now resolve; remove them")
+                .containsAll(WITHOUT_ACTION);
+    }
+
+    /** Adding or removing a REST call fails here until 
docs/static/rest-*-open-api.yaml follows. */
+    @Test
+    void testRequestsMatchTheOpenApiSpec() throws Exception {
+        Set<String> spec = OpenApiSpec.operations();
+        Set<String> unsent = new TreeSet<>(spec);
+        Map<String, List<String>> unspecified = new TreeMap<>();
+        sendEveryRequest()
+                .forEach(
+                        (name, sent) -> {
+                            for (String[] request : sent) {
+                                String operation = OpenApiSpec.match(spec, 
request[0], request[1]);
+                                if (operation == null) {
+                                    unspecified
+                                            .computeIfAbsent(name, k -> new 
ArrayList<>())
+                                            .add(request[0] + " " + 
request[1]);
+                                } else {
+                                    unsent.remove(operation);
+                                }
+                            }
+                        });
+
+        assertThat(unspecified).as("requests the OpenAPI spec lacks; add them 
to it").isEmpty();
+        assertThat(unsent)
+                .as("spec operations RESTApi never sends; drop them or add the 
call")
+                .isEmpty();
+    }
+
+    /** Requests sent by each public method, by name; "RESTApi" holds the 
constructor's. */
+    private Map<String, List<String[]>> sendEveryRequest() throws Exception {
+        Map<String, List<String[]>> sent = new TreeMap<>();
+        Options withConfig = new Options(options.toMap());
+        withConfig.set(WAREHOUSE, "x1");
+        try {
+            new RESTApi(withConfig, true);
+        } catch (RuntimeException ignored) {
+            // a reply of {} may not parse; only the request matters
+        }
+        sent.put("RESTApi", new ArrayList<>(requests));
+        List<String> silent = new ArrayList<>();
+        if (requests.isEmpty()) {
+            silent.add("RESTApi(Options, boolean)");
+        }
+
+        RESTApi api = new RESTApi(options, false);
+        for (Method method : RESTApi.class.getDeclaredMethods()) {
+            if (!Modifier.isPublic(method.getModifiers())
+                    || Modifier.isStatic(method.getModifiers())
+                    || method.getName().equals("options")) {
+                continue;
+            }
+            requests.clear();
+            try {
+                method.invoke(api, arguments(method));
+            } catch (InvocationTargetException ignored) {
+                // a reply of {} may not parse; only the request matters
+            }
+            if (requests.isEmpty()) {
+                silent.add(method.toGenericString());
+            }
+            sent.computeIfAbsent(method.getName(), k -> new 
ArrayList<>()).addAll(requests);
+        }
+        assertThat(silent).as("methods that sent no request; give them 
arguments").isEmpty();
+        return sent;
+    }
+
+    private static Object[] arguments(Method method) {
+        Parameter[] parameters = method.getParameters();
+        Object[] arguments = new Object[parameters.length];
+        for (int i = 0; i < parameters.length; i++) {
+            Class<?> type = parameters[i].getType();
+            if (parameters[i].isAnnotationPresent(Nullable.class)) {
+                arguments[i] = null;
+            } else if (type.isEnum()) {
+                arguments[i] = type.getEnumConstants()[0];
+            } else {
+                arguments[i] = ARGUMENTS.get(type);
+            }
+        }
+        return arguments;
+    }
+
+    private static Map<Class<?>, Object> arguments() {
+        PermissionResource table =
+                new PermissionResource(ResourceType.TABLE, "db", "t", null, 
null);
+        Map<Class<?>, Object> arguments = new HashMap<>();
+        arguments.put(String.class, "SELECT");
+        arguments.put(Integer.class, 1);
+        arguments.put(int.class, 1);
+        arguments.put(Long.class, 1L);
+        arguments.put(long.class, 1L);
+        arguments.put(boolean.class, false);
+        arguments.put(Instant.class, Instant.EPOCH);
+        arguments.put(List.class, Collections.emptyList());
+        arguments.put(Map.class, Collections.emptyMap());
+        arguments.put(Identifier.class, Identifier.create("db", "t"));
+        arguments.put(
+                Function.class, new FunctionImpl(Identifier.create("db", "f"), 
new HashMap<>()));
+        arguments.put(SemanticViewDefinition.class, new 
SemanticViewDefinition("x1", "x1"));
+        arguments.put(PermissionResource.class, table);
+        arguments.put(
+                PermissionAssignment.class, new PermissionAssignment(table, 
"SELECT", "x1", null));
+        arguments.put(
+                ListPermissionsRequest.class,
+                new ListPermissionsRequest(
+                        ResourceType.TABLE, "db", "t", null, null, null, null, 
null, null));
+        arguments.put(
+                ListPoliciesRequest.class,
+                new ListPoliciesRequest(table, null, null, null, null, null));
+        arguments.put(DataPolicy.class, DataPolicy.rowFilter(table, new 
RowFilter("x1"), "x1"));
+        return arguments;
+    }
+}
diff --git 
a/paimon-api/src/test/java/org/apache/paimon/rest/auth/DLFOpenApiActionsTest.java
 
b/paimon-api/src/test/java/org/apache/paimon/rest/auth/DLFOpenApiActionsTest.java
index 4d21036e90..52671482c3 100644
--- 
a/paimon-api/src/test/java/org/apache/paimon/rest/auth/DLFOpenApiActionsTest.java
+++ 
b/paimon-api/src/test/java/org/apache/paimon/rest/auth/DLFOpenApiActionsTest.java
@@ -20,15 +20,20 @@ package org.apache.paimon.rest.auth;
 
 import org.apache.paimon.management.PermissionResource;
 import org.apache.paimon.management.ResourceType;
+import org.apache.paimon.rest.OpenApiSpec;
 import org.apache.paimon.rest.ResourcePaths;
 
 import org.junit.jupiter.api.Test;
 
+import java.io.IOException;
 import java.util.HashSet;
 import java.util.Set;
+import java.util.TreeSet;
 
+import static org.assertj.core.api.Assertions.assertThat;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 
 /** Test for {@link DLFOpenApiActions}. */
 public class DLFOpenApiActionsTest {
@@ -44,7 +49,35 @@ public class DLFOpenApiActionsTest {
             assertEquals(operation[2], DLFOpenApiActions.resolve(operation[0], 
path), path);
             actions.add(operation[2]);
         }
-        assertEquals(61, actions.size());
+        assertEquals(74, actions.size());
+    }
+
+    /** The table and docs/static/rest-*-open-api.yaml must list the same 
operations. */
+    @Test
+    public void testOperationsMatchTheOpenApiSpec() throws IOException {
+        Set<String> spec = new TreeSet<>();
+        for (String operation : OpenApiSpec.operations()) {
+            spec.add(OpenApiSpec.normalize(operation));
+        }
+        Set<String> registered = new TreeSet<>();
+        for (String[] operation : DLFOpenApiActions.operations()) {
+            registered.add(OpenApiSpec.normalize(operation[0] + " " + 
operation[1]));
+        }
+        assertThat(registered)
+                .as("DLFOpenApiActions must register exactly the spec 
operations")
+                .containsExactlyInAnyOrderElementsOf(spec);
+    }
+
+    @Test
+    public void testActionNamesAreUnique() {
+        Set<String> actions = new HashSet<>();
+        Set<String> duplicates = new TreeSet<>();
+        for (String[] operation : DLFOpenApiActions.operations()) {
+            if (!actions.add(operation[2])) {
+                duplicates.add(operation[2]);
+            }
+        }
+        assertTrue(duplicates.isEmpty(), "duplicate actions: " + duplicates);
     }
 
     @Test
@@ -59,17 +92,25 @@ public class DLFOpenApiActionsTest {
         assertAction("ListTables", "GET", paths.tables("db"));
         assertAction("CreateTable", "POST", paths.tables("db"));
         assertAction("ListTableDetails", "GET", paths.tableDetails("db"));
+        assertAction("ListTablesGlobally", "GET", paths.tables());
         assertAction("GetTable", "GET", paths.table("db", "t"));
         assertAction("AlterTable", "POST", paths.table("db", "t"));
         assertAction("DropTable", "DELETE", paths.table("db", "t"));
+        assertAction("GetTableById", "GET", paths.table("tbl-1"));
         assertAction("RenameTable", "POST", paths.renameTable());
         assertAction("AuthTableQuery", "POST", paths.authTable("db", "t"));
         assertAction("CommitTable", "POST", paths.commitTable("db", "t"));
+        assertAction("AcquireCommitLock", "POST", paths.commitLock("db", "t"));
+        assertAction("RenewCommitLock", "POST", paths.commitLock("db", "t") + 
"/renew");
+        assertAction("ReplaceTable", "POST", paths.replaceTable("db", "t"));
         assertAction("GetTableToken", "GET", paths.tableToken("db", "t"));
         assertAction("RollbackToSnapshot", "POST", paths.rollbackTable("db", 
"t"));
+        assertAction("RollbackSchema", "POST", paths.rollbackSchemaTable("db", 
"t"));
         assertAction("GetTableSnapshot", "GET", paths.tableSnapshot("db", 
"t"));
         assertAction("ListSnapshots", "GET", paths.snapshots("db", "t"));
         assertAction("GetVersionSnapshot", "GET", paths.tableSnapshot("db", 
"t", "3"));
+        assertAction("ListSchemas", "GET", paths.schemas("db", "t"));
+        assertAction("GetSchema", "GET", paths.schemas("db", "t", "3"));
         assertAction("ListPartitions", "GET", paths.partitions("db", "t"));
         assertAction("CreatePartitions", "POST", paths.partitions("db", "t"));
         assertAction("DropPartitions", "POST", paths.dropPartitions("db", 
"t"));
@@ -84,6 +125,8 @@ public class DLFOpenApiActionsTest {
         assertAction("CreateTag", "POST", paths.tags("db", "t"));
         assertAction("GetTag", "GET", paths.tag("db", "t", "tag"));
         assertAction("DropTag", "DELETE", paths.tag("db", "t", "tag"));
+        assertAction("ListConsumers", "GET", paths.consumers("db", "t"));
+        assertAction("ResetConsumer", "POST", paths.resetConsumer("db", "t"));
         PermissionResource table =
                 new PermissionResource(ResourceType.TABLE, "db", "t", null, 
null);
         assertAction("ListPolicies", "GET", paths.policies(table));
@@ -92,6 +135,7 @@ public class DLFOpenApiActionsTest {
         assertAction("ListViews", "GET", paths.views("db"));
         assertAction("CreateView", "POST", paths.views("db"));
         assertAction("ListViewDetails", "GET", paths.viewDetails("db"));
+        assertAction("ListViewsGlobally", "GET", paths.views());
         assertAction("GetView", "GET", paths.view("db", "v"));
         assertAction("AlterView", "POST", paths.view("db", "v"));
         assertAction("DropView", "DELETE", paths.view("db", "v"));
@@ -102,6 +146,8 @@ public class DLFOpenApiActionsTest {
         assertAction("DeleteSemanticView", "DELETE", paths.semanticView("db", 
"sv"));
         assertAction("ListFunctions", "GET", paths.functions("db"));
         assertAction("CreateFunction", "POST", paths.functions("db"));
+        assertAction("ListFunctionDetails", "GET", 
paths.functionDetails("db"));
+        assertAction("ListFunctionsGlobally", "GET", paths.functions());
         assertAction("GetFunction", "GET", paths.function("db", "f"));
         assertAction("AlterFunction", "POST", paths.function("db", "f"));
         assertAction("DropFunction", "DELETE", paths.function("db", "f"));
@@ -130,9 +176,9 @@ public class DLFOpenApiActionsTest {
 
     @Test
     public void testUnregisteredRequestsHaveNoAction() {
-        assertNull(DLFOpenApiActions.resolve("GET", paths.tables()));
-        assertNull(DLFOpenApiActions.resolve("GET", paths.table("tbl-1")));
-        assertNull(DLFOpenApiActions.resolve("GET", paths.schemas("db", "t")));
+        assertNull(DLFOpenApiActions.resolve("GET", paths.renameTable()));
+        assertNull(DLFOpenApiActions.resolve("DELETE", paths.table("tbl-1")));
+        assertNull(DLFOpenApiActions.resolve("GET", paths.commitTable("db", 
"t")));
         assertNull(DLFOpenApiActions.resolve("PUT", paths.table("db", "t")));
         assertNull(DLFOpenApiActions.resolve("GET", ResourcePaths.config() + 
"/"));
         assertNull(DLFOpenApiActions.resolve("GET", "/v1//databases"));
diff --git 
a/paimon-api/src/test/java/org/apache/paimon/rest/auth/DLFOpenApiV4SignerTest.java
 
b/paimon-api/src/test/java/org/apache/paimon/rest/auth/DLFOpenApiV4SignerTest.java
index 67d40499de..6d676b3b69 100644
--- 
a/paimon-api/src/test/java/org/apache/paimon/rest/auth/DLFOpenApiV4SignerTest.java
+++ 
b/paimon-api/src/test/java/org/apache/paimon/rest/auth/DLFOpenApiV4SignerTest.java
@@ -243,9 +243,9 @@ public class DLFOpenApiV4SignerTest {
 
         assertEquals(
                 "ACS4-HMAC-SHA256 
Credential=TestAKId/20250416/cn-hangzhou/DlfNext/aliyun_v4_request,"
-                        + 
"SignedHeaders=host;x-acs-content-sha256;x-acs-date;x-acs-signature-nonce;"
-                        + "x-acs-version,"
-                        + 
"Signature=9812bacd92dd1152e7ea32aacfad0545835d6a18543852364f1a1ea0cba6219a",
+                        + 
"SignedHeaders=host;x-acs-action;x-acs-content-sha256;x-acs-date;"
+                        + "x-acs-signature-nonce;x-acs-version,"
+                        + 
"Signature=2d5fdb5201901a5cdceee97318366666a88504eeae0b1ce534dbf86889cc00fb",
                 authorization);
     }
 
@@ -279,7 +279,11 @@ public class DLFOpenApiV4SignerTest {
         DLFOpenApiV4Signer signer = new DLFOpenApiV4Signer(REGION);
         DLFToken token = new DLFToken("TestAKId", "TestAKSecret", null, null);
         RESTAuthParameter restAuthParameter =
-                new RESTAuthParameter("/v1/clg-paimon-1/tables", new 
HashMap<>(), "GET", null);
+                new RESTAuthParameter(
+                        "/v1/clg-paimon-1/databases/db/tables/t/unknown",
+                        new HashMap<>(),
+                        "GET",
+                        null);
         Map<String, String> signHeaders = signer.signHeaders(null, NOW, null, 
HOST);
 
         String authorization = signer.authorization(restAuthParameter, token, 
HOST, signHeaders);

Reply via email to