diff --git a/paimon-core/src/main/java/org/apache/paimon/catalog/AbstractCatalog.java b/paimon-core/src/main/java/org/apache/paimon/catalog/AbstractCatalog.java index aa93b1ba3256..72d09b785f1a 100644 --- a/paimon-core/src/main/java/org/apache/paimon/catalog/AbstractCatalog.java +++ b/paimon-core/src/main/java/org/apache/paimon/catalog/AbstractCatalog.java @@ -369,7 +369,8 @@ public Table getTable(Identifier identifier) throws TableNotExistException { SnapshotCommit.Factory commitFactory = new RenamingSnapshotCommit.Factory( lockFactory().orElse(null), lockContext().orElse(null)); - return CatalogUtils.loadTable(this, identifier, this::loadTableMetadata, commitFactory); + return CatalogUtils.loadTable( + this, identifier, fileIO(), this::loadTableMetadata, commitFactory); } /** diff --git a/paimon-core/src/main/java/org/apache/paimon/catalog/CatalogUtils.java b/paimon-core/src/main/java/org/apache/paimon/catalog/CatalogUtils.java index fbd510692c30..7282b308e744 100644 --- a/paimon-core/src/main/java/org/apache/paimon/catalog/CatalogUtils.java +++ b/paimon-core/src/main/java/org/apache/paimon/catalog/CatalogUtils.java @@ -171,6 +171,7 @@ public static List listPartitionsFromFileSystem(Table table) { public static Table loadTable( Catalog catalog, Identifier identifier, + FileIO fileIO, TableMetadata.Loader metadataLoader, SnapshotCommit.Factory commitFactory) throws Catalog.TableNotExistException { @@ -189,8 +190,7 @@ public static Table loadTable( new CatalogEnvironment( identifier, metadata.uuid(), catalog.catalogLoader(), commitFactory); Path path = new Path(schema.options().get(PATH.key())); - FileStoreTable table = - FileStoreTableFactory.create(catalog.fileIO(), path, schema, catalogEnv); + FileStoreTable table = FileStoreTableFactory.create(fileIO, path, schema, catalogEnv); if (options.type() == TableType.OBJECT_TABLE) { table = toObjectTable(catalog, table); 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 87b8e8cb9612..e06ef012b87a 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 @@ -109,6 +109,7 @@ public class RESTCatalog implements Catalog { private final ResourcePaths resourcePaths; private final AuthSession catalogAuth; private final Options options; + private final boolean fileIORefreshCredentialEnable; private final FileIO fileIO; private volatile ScheduledExecutorService refreshExecutor = null; @@ -130,13 +131,19 @@ public RESTCatalog(CatalogContext context) { .merge(context.options().toMap())); this.resourcePaths = ResourcePaths.forCatalogProperties(options); + this.fileIORefreshCredentialEnable = + options.get(RESTCatalogOptions.FILE_IO_REFRESH_CREDENTIAL_ENABLE); try { - String warehouseStr = options.get(CatalogOptions.WAREHOUSE); - this.fileIO = - FileIO.get( - new Path(warehouseStr), - CatalogContext.create( - options, context.preferIO(), context.fallbackIO())); + if (fileIORefreshCredentialEnable) { + this.fileIO = null; + } else { + String warehouseStr = options.get(CatalogOptions.WAREHOUSE); + this.fileIO = + FileIO.get( + new Path(warehouseStr), + CatalogContext.create( + options, context.preferIO(), context.fallbackIO())); + } } catch (IOException e) { LOG.warn("Can not get FileIO from options."); throw new RuntimeException(e); @@ -149,6 +156,8 @@ protected RESTCatalog(Options options, FileIO fileIO) { this.options = options; this.resourcePaths = ResourcePaths.forCatalogProperties(options); this.fileIO = fileIO; + this.fileIORefreshCredentialEnable = + options.get(RESTCatalogOptions.FILE_IO_REFRESH_CREDENTIAL_ENABLE); } @Override @@ -168,12 +177,20 @@ public RESTCatalogLoader catalogLoader() { @Override public FileIO fileIO() { + if (fileIORefreshCredentialEnable) { + throw new UnsupportedOperationException(); + } return fileIO; } @Override public FileIO fileIO(Path path) { - return fileIO; + try { + return FileIO.get(path, CatalogContext.create(options)); + } catch (IOException e) { + LOG.warn("Can not get FileIO from options."); + throw new RuntimeException(e); + } } @Override @@ -289,6 +306,7 @@ public Table getTable(Identifier identifier) throws TableNotExistException { return CatalogUtils.loadTable( this, identifier, + this.fileIO(identifier), this::loadTableMetadata, new RESTSnapshotCommitFactory(catalogLoader())); } @@ -645,4 +663,12 @@ private ScheduledExecutorService tokenRefreshExecutor() { return refreshExecutor; } + + private FileIO fileIO(Identifier identifier) { + if (fileIORefreshCredentialEnable) { + return new RefreshCredentialFileIO( + resourcePaths, catalogAuth, options, client, identifier); + } + return fileIO; + } } diff --git a/paimon-core/src/main/java/org/apache/paimon/rest/RESTCatalogOptions.java b/paimon-core/src/main/java/org/apache/paimon/rest/RESTCatalogOptions.java index 843228fa0707..61aed5f7038f 100644 --- a/paimon-core/src/main/java/org/apache/paimon/rest/RESTCatalogOptions.java +++ b/paimon-core/src/main/java/org/apache/paimon/rest/RESTCatalogOptions.java @@ -77,4 +77,10 @@ public class RESTCatalogOptions { .stringType() .noDefaultValue() .withDescription("REST Catalog auth token provider path."); + + public static final ConfigOption FILE_IO_REFRESH_CREDENTIAL_ENABLE = + ConfigOptions.key("file-io-refresh-credential.enabled") + .booleanType() + .defaultValue(false) + .withDescription("Whether to support file io refresh credential."); } diff --git a/paimon-core/src/main/java/org/apache/paimon/rest/RefreshCredentialFileIO.java b/paimon-core/src/main/java/org/apache/paimon/rest/RefreshCredentialFileIO.java new file mode 100644 index 000000000000..b55486887d55 --- /dev/null +++ b/paimon-core/src/main/java/org/apache/paimon/rest/RefreshCredentialFileIO.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.catalog.CatalogContext; +import org.apache.paimon.catalog.Identifier; +import org.apache.paimon.fs.FileIO; +import org.apache.paimon.fs.FileStatus; +import org.apache.paimon.fs.Path; +import org.apache.paimon.fs.PositionOutputStream; +import org.apache.paimon.fs.SeekableInputStream; +import org.apache.paimon.options.CatalogOptions; +import org.apache.paimon.options.Options; +import org.apache.paimon.rest.auth.AuthSession; +import org.apache.paimon.rest.responses.GetTableCredentialsResponse; + +import java.io.IOException; +import java.util.Map; + +/** A {@link FileIO} to support refresh credential. */ +public class RefreshCredentialFileIO implements FileIO { + + private static final long serialVersionUID = 1L; + + private final ResourcePaths resourcePaths; + private final AuthSession catalogAuth; + protected Options options; + private final Identifier identifier; + private Long expireAtMillis; + private Map credential; + private final transient RESTClient client; + private transient volatile FileIO lazyFileIO; + + public RefreshCredentialFileIO( + ResourcePaths resourcePaths, + AuthSession catalogAuth, + Options options, + RESTClient client, + Identifier identifier) { + this.resourcePaths = resourcePaths; + this.catalogAuth = catalogAuth; + this.options = options; + this.identifier = identifier; + this.client = client; + } + + @Override + public void configure(CatalogContext context) { + this.options = context.options(); + } + + @Override + public SeekableInputStream newInputStream(Path path) throws IOException { + return fileIO().newInputStream(path); + } + + @Override + public PositionOutputStream newOutputStream(Path path, boolean overwrite) throws IOException { + return fileIO().newOutputStream(path, overwrite); + } + + @Override + public FileStatus getFileStatus(Path path) throws IOException { + return fileIO().getFileStatus(path); + } + + @Override + public FileStatus[] listStatus(Path path) throws IOException { + return fileIO().listStatus(path); + } + + @Override + public boolean exists(Path path) throws IOException { + return fileIO().exists(path); + } + + @Override + public boolean delete(Path path, boolean recursive) throws IOException { + return fileIO().delete(path, recursive); + } + + @Override + public boolean mkdirs(Path path) throws IOException { + return fileIO().mkdirs(path); + } + + @Override + public boolean rename(Path src, Path dst) throws IOException { + return fileIO().rename(src, dst); + } + + @Override + public boolean isObjectStore() { + try { + return fileIO().isObjectStore(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private FileIO fileIO() throws IOException { + if (lazyFileIO == null || shouldRefresh()) { + synchronized (this) { + if (lazyFileIO == null || shouldRefresh()) { + GetTableCredentialsResponse response = getCredential(); + expireAtMillis = response.getExpiresAtMillis(); + credential = response.getCredential(); + Map conf = RESTUtil.merge(options.toMap(), credential); + Options updateCredentialOption = new Options(conf); + lazyFileIO = + FileIO.get( + new Path(updateCredentialOption.get(CatalogOptions.WAREHOUSE)), + CatalogContext.create(updateCredentialOption)); + } + } + } + return lazyFileIO; + } + + // todo: handle exception + private GetTableCredentialsResponse getCredential() { + return client.get( + resourcePaths.tableCredentials( + identifier.getDatabaseName(), identifier.getObjectName()), + GetTableCredentialsResponse.class, + catalogAuth.getHeaders()); + } + + private boolean shouldRefresh() { + return expireAtMillis != null && expireAtMillis > System.currentTimeMillis(); + } +} diff --git a/paimon-core/src/main/java/org/apache/paimon/rest/ResourcePaths.java b/paimon-core/src/main/java/org/apache/paimon/rest/ResourcePaths.java index d77475fe40dc..1e843f99cb0e 100644 --- a/paimon-core/src/main/java/org/apache/paimon/rest/ResourcePaths.java +++ b/paimon-core/src/main/java/org/apache/paimon/rest/ResourcePaths.java @@ -70,6 +70,10 @@ public String commitTable(String databaseName) { return SLASH.join(V1, prefix, DATABASES, databaseName, TABLES, "commit"); } + public String tableCredentials(String databaseName, String tableName) { + return SLASH.join(V1, prefix, DATABASES, databaseName, TABLES, tableName, "credentials"); + } + public String partitions(String databaseName, String tableName) { return SLASH.join(V1, prefix, DATABASES, databaseName, TABLES, tableName, "partitions"); } diff --git a/paimon-core/src/main/java/org/apache/paimon/rest/responses/GetTableCredentialsResponse.java b/paimon-core/src/main/java/org/apache/paimon/rest/responses/GetTableCredentialsResponse.java new file mode 100644 index 000000000000..2792940ff6b1 --- /dev/null +++ b/paimon-core/src/main/java/org/apache/paimon/rest/responses/GetTableCredentialsResponse.java @@ -0,0 +1,60 @@ +/* + * 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.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.Map; + +/** Response for table credentials. */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class GetTableCredentialsResponse implements RESTResponse { + + private static final String FIELD_CREDENTIAL = "credential"; + private static final String FIELD_EXPIREAT_MILLIS = "expiresAtMillis"; + + @JsonProperty(FIELD_CREDENTIAL) + private final Map credential; + + @JsonProperty(FIELD_EXPIREAT_MILLIS) + private long expiresAtMillis; + + @JsonCreator + public GetTableCredentialsResponse( + @JsonProperty(FIELD_EXPIREAT_MILLIS) long expiresAtMillis, + @JsonProperty(FIELD_CREDENTIAL) Map credential) { + this.expiresAtMillis = expiresAtMillis; + this.credential = credential; + } + + @JsonGetter(FIELD_CREDENTIAL) + public Map getCredential() { + return credential; + } + + @JsonGetter(FIELD_EXPIREAT_MILLIS) + public long getExpiresAtMillis() { + return expiresAtMillis; + } +} diff --git a/paimon-core/src/test/java/org/apache/paimon/rest/MockRESTMessage.java b/paimon-core/src/test/java/org/apache/paimon/rest/MockRESTMessage.java index 766cb09b0bdd..a8a2d6189632 100644 --- a/paimon-core/src/test/java/org/apache/paimon/rest/MockRESTMessage.java +++ b/paimon-core/src/test/java/org/apache/paimon/rest/MockRESTMessage.java @@ -32,6 +32,7 @@ import org.apache.paimon.rest.responses.AlterDatabaseResponse; import org.apache.paimon.rest.responses.CreateDatabaseResponse; import org.apache.paimon.rest.responses.GetDatabaseResponse; +import org.apache.paimon.rest.responses.GetTableCredentialsResponse; import org.apache.paimon.rest.responses.GetTableResponse; import org.apache.paimon.rest.responses.GetViewResponse; import org.apache.paimon.rest.responses.ListDatabasesResponse; @@ -48,6 +49,7 @@ import org.apache.paimon.view.ViewSchema; import org.apache.paimon.shade.guava30.com.google.common.collect.ImmutableList; +import org.apache.paimon.shade.guava30.com.google.common.collect.ImmutableMap; import org.apache.paimon.shade.guava30.com.google.common.collect.Lists; import java.util.ArrayList; @@ -248,6 +250,11 @@ public static ListViewsResponse listViewsResponse() { return new ListViewsResponse(ImmutableList.of("view")); } + public static GetTableCredentialsResponse getTableCredentialsResponse() { + return new GetTableCredentialsResponse( + System.currentTimeMillis(), ImmutableMap.of("key", "value")); + } + private static ViewSchema viewSchema() { List fields = Arrays.asList( 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 91024867b7ea..16de35969dc6 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 @@ -42,6 +42,7 @@ import org.apache.paimon.rest.responses.ErrorResponse; import org.apache.paimon.rest.responses.ErrorResponseResourceType; import org.apache.paimon.rest.responses.GetDatabaseResponse; +import org.apache.paimon.rest.responses.GetTableCredentialsResponse; import org.apache.paimon.rest.responses.GetTableResponse; import org.apache.paimon.rest.responses.GetViewResponse; import org.apache.paimon.rest.responses.ListDatabasesResponse; @@ -63,6 +64,7 @@ import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; import okhttp3.mockwebserver.RecordedRequest; +import org.testcontainers.shaded.com.google.common.collect.ImmutableMap; import java.io.IOException; import java.util.List; @@ -149,6 +151,10 @@ public MockResponse dispatch(RecordedRequest request) { resources.length == 3 && "tables".equals(resources[1]) && "commit".equals(resources[2]); + boolean isTableCredentials = + resources.length == 4 + && "tables".equals(resources[1]) + && "credentials".equals(resources[3]); boolean isPartitions = resources.length == 4 && "tables".equals(resources[1]) @@ -202,6 +208,16 @@ public MockResponse dispatch(RecordedRequest request) { } else if (isPartitions) { String tableName = resources[2]; return partitionsApiHandler(catalog, request, databaseName, tableName); + } else if (isTableCredentials) { + GetTableCredentialsResponse getTableCredentialsResponse = + new GetTableCredentialsResponse( + System.currentTimeMillis(), + ImmutableMap.of("key", "value")); + return new MockResponse() + .setResponseCode(200) + .setBody( + OBJECT_MAPPER.writeValueAsString( + getTableCredentialsResponse)); } else if (isTableRename) { return renameTableApiHandler(catalog, request); } else if (isTableCommit) { 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 d1ce64b6c543..752f492078b7 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 @@ -26,6 +26,7 @@ import org.apache.paimon.partition.Partition; import org.apache.paimon.rest.exceptions.NotAuthorizedException; import org.apache.paimon.schema.Schema; +import org.apache.paimon.table.FileStoreTable; import org.apache.paimon.types.DataField; import org.apache.paimon.types.DataTypes; @@ -49,12 +50,12 @@ class RESTCatalogTest extends CatalogTestBase { private RESTCatalogServer restCatalogServer; + private String initToken = "init_token"; @BeforeEach @Override public void setUp() throws Exception { super.setUp(); - String initToken = "init_token"; restCatalogServer = new RESTCatalogServer(warehouse, initToken); restCatalogServer.start(); Options options = new Options(); @@ -107,6 +108,26 @@ void testListPartitionsFromFile() throws Exception { assertEquals(0, result.size()); } + @Test + void testRefreshFileIO() throws Exception { + Options options = new Options(); + options.set(RESTCatalogOptions.URI, restCatalogServer.getUrl()); + options.set(RESTCatalogOptions.TOKEN, initToken); + options.set(RESTCatalogOptions.THREAD_POOL_SIZE, 1); + options.set(RESTCatalogOptions.FILE_IO_REFRESH_CREDENTIAL_ENABLE, true); + this.catalog = new RESTCatalog(CatalogContext.create(options)); + List identifiers = + Lists.newArrayList( + Identifier.create("test_db_a", "test_table_a"), + Identifier.create("test_db_b", "test_table_b"), + Identifier.create("test_db_c", "test_table_c")); + for (Identifier identifier : identifiers) { + createTable(identifier, Maps.newHashMap(), Lists.newArrayList("col1")); + FileStoreTable fileStoreTable = (FileStoreTable) catalog.getTable(identifier); + assertEquals(true, fileStoreTable.fileIO().exists(fileStoreTable.location())); + } + } + @Override protected boolean supportsFormatTable() { return true; diff --git a/paimon-core/src/test/java/org/apache/paimon/rest/RESTObjectMapperTest.java b/paimon-core/src/test/java/org/apache/paimon/rest/RESTObjectMapperTest.java index fa56f8111828..4c3b622a8c7f 100644 --- a/paimon-core/src/test/java/org/apache/paimon/rest/RESTObjectMapperTest.java +++ b/paimon-core/src/test/java/org/apache/paimon/rest/RESTObjectMapperTest.java @@ -32,6 +32,7 @@ import org.apache.paimon.rest.responses.CreateDatabaseResponse; import org.apache.paimon.rest.responses.ErrorResponse; import org.apache.paimon.rest.responses.GetDatabaseResponse; +import org.apache.paimon.rest.responses.GetTableCredentialsResponse; import org.apache.paimon.rest.responses.GetTableResponse; import org.apache.paimon.rest.responses.GetViewResponse; import org.apache.paimon.rest.responses.ListDatabasesResponse; @@ -273,4 +274,14 @@ public void listViewsResponseParseTest() throws Exception { ListViewsResponse parseData = OBJECT_MAPPER.readValue(responseStr, ListViewsResponse.class); assertEquals(response.getViews(), parseData.getViews()); } + + @Test + public void getTableCredentialsResponseParseTest() throws Exception { + GetTableCredentialsResponse response = MockRESTMessage.getTableCredentialsResponse(); + String responseStr = OBJECT_MAPPER.writeValueAsString(response); + GetTableCredentialsResponse parseData = + OBJECT_MAPPER.readValue(responseStr, GetTableCredentialsResponse.class); + assertEquals(response.getCredential(), parseData.getCredential()); + assertEquals(response.getExpiresAtMillis(), parseData.getExpiresAtMillis()); + } } diff --git a/paimon-open-api/rest-catalog-open-api.yaml b/paimon-open-api/rest-catalog-open-api.yaml index 02ea7de8d0df..128514d7a59e 100644 --- a/paimon-open-api/rest-catalog-open-api.yaml +++ b/paimon-open-api/rest-catalog-open-api.yaml @@ -432,6 +432,43 @@ paths: $ref: '#/components/schemas/ErrorResponse' "500": description: Internal Server Error + /v1/{prefix}/databases/{database}/tables/{table}/credentials: + get: + tags: + - table + summary: List credentials + operationId: listCredentials + 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 + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/GetTableCredentialsResponse' + "404": + description: Resource not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + "500": + description: Internal Server Error /v1/{prefix}/databases/{database}/tables/{table}/partitions: get: tags: @@ -1166,6 +1203,16 @@ components: properties: success: type: boolean + GetTableCredentialsResponse: + type: object + properties: + expiresAt: + type: integer + format: int64 + credentials: + type: object + additionalProperties: + type: string AlterDatabaseRequest: type: object properties: diff --git a/paimon-open-api/src/main/java/org/apache/paimon/open/api/RESTCatalogController.java b/paimon-open-api/src/main/java/org/apache/paimon/open/api/RESTCatalogController.java index e657407a47c3..c8eae97dec64 100644 --- a/paimon-open-api/src/main/java/org/apache/paimon/open/api/RESTCatalogController.java +++ b/paimon-open-api/src/main/java/org/apache/paimon/open/api/RESTCatalogController.java @@ -37,6 +37,7 @@ import org.apache.paimon.rest.responses.CreateDatabaseResponse; import org.apache.paimon.rest.responses.ErrorResponse; import org.apache.paimon.rest.responses.GetDatabaseResponse; +import org.apache.paimon.rest.responses.GetTableCredentialsResponse; import org.apache.paimon.rest.responses.GetTableResponse; import org.apache.paimon.rest.responses.GetViewResponse; import org.apache.paimon.rest.responses.ListDatabasesResponse; @@ -49,6 +50,7 @@ import org.apache.paimon.view.ViewSchema; import org.apache.paimon.shade.guava30.com.google.common.collect.ImmutableList; +import org.apache.paimon.shade.guava30.com.google.common.collect.ImmutableMap; import org.apache.paimon.shade.guava30.com.google.common.collect.Lists; import io.swagger.v3.oas.annotations.Operation; @@ -355,6 +357,32 @@ public CommitTableResponse commitTable( return new CommitTableResponse(true); } + @Operation( + summary = "List credentials", + tags = {"table"}) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + content = { + @Content(schema = @Schema(implementation = GetTableCredentialsResponse.class)) + }), + @ApiResponse( + responseCode = "404", + description = "Resource not found", + content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}), + @ApiResponse( + responseCode = "500", + content = {@Content(schema = @Schema())}) + }) + @GetMapping("/v1/{prefix}/databases/{database}/tables/{table}/credentials") + public GetTableCredentialsResponse listCredentials( + @PathVariable String prefix, + @PathVariable String database, + @PathVariable String table) { + return new GetTableCredentialsResponse( + System.currentTimeMillis(), ImmutableMap.of("key", "value")); + } + @Operation( summary = "List partitions", tags = {"partition"})