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);