This is an automated email from the ASF dual-hosted git repository.
roryqi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/main by this push:
new 9419878b5d [#11138][#11137] refactor(iceberg-rest): federation
handling via FederatedCatalogWrapper subclass (#11367)
9419878b5d is described below
commit 9419878b5d2bccc7bb9f1f3a3220b11807d3be8a
Author: roryqi <[email protected]>
AuthorDate: Fri Jun 5 20:58:54 2026 +0800
[#11138][#11137] refactor(iceberg-rest): federation handling via
FederatedCatalogWrapper subclass (#11367)
### What changes were proposed in this pull request?
Federation logic in `CatalogWrapperForREST` was implemented as scattered
`instanceof RESTCatalog` / `isRESTCatalog()` short-circuits across the
class.
This PR refactors them into polymorphic dispatch and fixes the missing
`registerTable` federation path:
- Add `FederatedCatalogWrapper extends CatalogWrapperForREST` that
overrides the
operations needing federation-aware behavior: `createTable`,
`loadTable`,
`updateTable`, `registerTable`, and `buildCatalogConfigToClients`.
- Add a `CatalogWrapperForREST.create(...)` factory that returns the
federated
subclass when the backend is `rest`, and wire
`IcebergCatalogWrapperManager`
to it.
- Add `registerTableInternal`, mirroring `loadTableInternal`, so
federated
`registerTable` extracts FileIO-derived client config and credential
fields
from the remote catalog's `table.io()` (previously dropped). (#11137)
- Remove the now-dead `instanceof`/`isRESTCatalog()` branches from the
base
class (`createTable`, `loadTable`, `updateTable`,
`shouldGenerateCredential`),
and make `buildCatalogConfigToClients` an overridable instance method
backed by
a shared `filterCatalogConfigForClients` helper.
### Why are the changes needed?
Adding a new federated operation required remembering to add a
corresponding
`instanceof` check; nothing in the type system enforced it. The missing
`registerTable` federation handling was a direct bug from this pattern:
a
federated `registerTable` returned a `LoadTableResponse` without the
FileIO-derived properties and credential fields that
`createTable`/`loadTable`
include. Polymorphic dispatch removes the foot-gun and fixes the bug.
Fix: #11138
Fix: #11137
### Does this PR introduce _any_ user-facing change?
No. Federated `registerTable` responses now include the
FileIO/credential client
config they should have included before; no API or property-key changes.
### How was this patch tested?
`./gradlew :iceberg:iceberg-rest-server:test -PskipITs` (full module
unit suite
green), plus new unit tests:
- `testCreateReturnsFederatedWrapperForRestBackend`
- `testCreateReturnsBaseWrapperForNonRestBackend`
- `testFederatedRegisterTableIncludesFileIo`
Existing `buildCatalogConfigToClients` tests were updated to exercise
the
polymorphic source selection and the shared filter helper.
---
.../iceberg/service/CatalogWrapperForREST.java | 474 ++-----------------
.../iceberg/service/FederatedCatalogWrapper.java | 506 +++++++++++++++++++++
.../service/IcebergCatalogWrapperManager.java | 14 +-
.../iceberg/service/IcebergRESTUtils.java | 52 ++-
.../iceberg/service/TestCatalogWrapperForREST.java | 115 +++--
.../TestIcebergCatalogWrapperManagerForREST.java | 42 ++
.../iceberg/service/TestIcebergRESTUtils.java | 32 ++
7 files changed, 766 insertions(+), 469 deletions(-)
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/CatalogWrapperForREST.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/CatalogWrapperForREST.java
index 7e1aac0c0d..e02c6d4f55 100644
---
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/CatalogWrapperForREST.java
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/CatalogWrapperForREST.java
@@ -20,14 +20,10 @@
package org.apache.gravitino.iceberg.service;
import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Maps;
import java.io.IOException;
import java.net.URI;
-import java.time.OffsetDateTime;
-import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
@@ -36,7 +32,6 @@ import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
-import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.apache.commons.lang3.StringUtils;
import org.apache.gravitino.catalog.lakehouse.iceberg.IcebergConstants;
@@ -44,7 +39,6 @@ import
org.apache.gravitino.credential.CatalogCredentialManager;
import org.apache.gravitino.credential.Credential;
import org.apache.gravitino.credential.CredentialConstants;
import org.apache.gravitino.credential.CredentialPrivilege;
-import org.apache.gravitino.credential.CredentialPropertyUtils;
import org.apache.gravitino.credential.PathBasedCredentialContext;
import org.apache.gravitino.iceberg.common.IcebergConfig;
import org.apache.gravitino.iceberg.common.ops.IcebergCatalogWrapper;
@@ -54,48 +48,22 @@ import org.apache.gravitino.storage.GCSProperties;
import org.apache.gravitino.utils.ClassUtils;
import org.apache.gravitino.utils.MapUtils;
import org.apache.gravitino.utils.PrincipalUtils;
-import org.apache.iceberg.BaseMetadataTable;
-import org.apache.iceberg.BaseTable;
-import org.apache.iceberg.BaseTransaction;
-import org.apache.iceberg.CatalogProperties;
import org.apache.iceberg.FileScanTask;
import org.apache.iceberg.IncrementalAppendScan;
-import org.apache.iceberg.MetadataUpdate;
-import org.apache.iceberg.PartitionSpec;
import org.apache.iceberg.Scan;
-import org.apache.iceberg.Schema;
-import org.apache.iceberg.SortOrder;
import org.apache.iceberg.Table;
import org.apache.iceberg.TableMetadata;
-import org.apache.iceberg.TableOperations;
import org.apache.iceberg.TableProperties;
import org.apache.iceberg.TableScan;
-import org.apache.iceberg.Transaction;
-import org.apache.iceberg.UpdateRequirement;
-import org.apache.iceberg.catalog.Catalog;
import org.apache.iceberg.catalog.Namespace;
import org.apache.iceberg.catalog.TableIdentifier;
-import org.apache.iceberg.exceptions.AlreadyExistsException;
-import org.apache.iceberg.exceptions.NoSuchTableException;
import org.apache.iceberg.exceptions.ServiceUnavailableException;
-import org.apache.iceberg.inmemory.InMemoryFileIO;
import org.apache.iceberg.io.CloseableIterable;
-import org.apache.iceberg.io.FileIO;
import org.apache.iceberg.rest.CatalogHandlers;
-import org.apache.iceberg.rest.ErrorHandlers;
-import org.apache.iceberg.rest.HTTPClient;
import org.apache.iceberg.rest.PlanStatus;
-import org.apache.iceberg.rest.RESTCatalog;
-import org.apache.iceberg.rest.RESTClient;
-import org.apache.iceberg.rest.RESTUtil;
-import org.apache.iceberg.rest.ResourcePaths;
-import org.apache.iceberg.rest.auth.AuthManager;
-import org.apache.iceberg.rest.auth.AuthManagers;
-import org.apache.iceberg.rest.auth.AuthSession;
import org.apache.iceberg.rest.requests.CreateTableRequest;
import org.apache.iceberg.rest.requests.PlanTableScanRequest;
import org.apache.iceberg.rest.requests.RegisterTableRequest;
-import org.apache.iceberg.rest.requests.UpdateTableRequest;
import org.apache.iceberg.rest.responses.ImmutableLoadCredentialsResponse;
import org.apache.iceberg.rest.responses.LoadCredentialsResponse;
import org.apache.iceberg.rest.responses.LoadTableResponse;
@@ -104,8 +72,8 @@ import
org.apache.iceberg.rest.responses.PlanTableScanResponse;
/** Process Iceberg REST specific operations, like credential vending. */
public class CatalogWrapperForREST extends IcebergCatalogWrapper {
- private static final String FORMAT_VERSION = "format-version";
- private final CatalogCredentialManager catalogCredentialManager;
+ /** Vends Gravitino-managed credentials and exposes the catalog name for
credential refresh. */
+ protected final CatalogCredentialManager catalogCredentialManager;
private volatile Map<String, String> catalogConfigToClients;
private final Object catalogConfigToClientsLock = new Object();
@@ -114,9 +82,12 @@ public class CatalogWrapperForREST extends
IcebergCatalogWrapper {
private static final String DATA_ACCESS_VENDED_CREDENTIALS =
"vended-credentials";
private static final String DATA_ACCESS_REMOTE_SIGNING = "remote-signing";
- private static final Schema EMPTY_SCHEMA = new Schema();
- private static final Set<String> catalogPropertiesToClientKeys =
+ /**
+ * Client-facing catalog property keys retained when building the IRC {@code
/v1/config} defaults
+ * and when extracting FileIO-derived config in {@link
FederatedCatalogWrapper}.
+ */
+ protected static final Set<String> catalogPropertiesToClientKeys =
ImmutableSet.of(
IcebergConstants.IO_IMPL,
IcebergConstants.AWS_S3_REGION,
@@ -144,12 +115,7 @@ public class CatalogWrapperForREST extends
IcebergCatalogWrapper {
public LoadTableResponse createTable(
Namespace namespace, CreateTableRequest request, boolean
requestCredential) {
- LoadTableResponse loadTableResponse;
- if (isRESTCatalog()) {
- loadTableResponse = createTableInternal(namespace, request);
- } else {
- loadTableResponse = super.createTable(namespace, request);
- }
+ LoadTableResponse loadTableResponse = super.createTable(namespace,
request);
if (shouldGenerateCredential(loadTableResponse, requestCredential)) {
return injectCredentialConfig(
TableIdentifier.of(namespace, request.name()),
@@ -161,12 +127,7 @@ public class CatalogWrapperForREST extends
IcebergCatalogWrapper {
public LoadTableResponse loadTable(
TableIdentifier identifier, boolean requestCredential,
CredentialPrivilege privilege) {
- LoadTableResponse loadTableResponse;
- if (isRESTCatalog()) {
- loadTableResponse = loadTableInternal(identifier);
- } else {
- loadTableResponse = super.loadTable(identifier);
- }
+ LoadTableResponse loadTableResponse = super.loadTable(identifier);
if (shouldGenerateCredential(loadTableResponse, requestCredential)) {
return injectCredentialConfig(identifier, loadTableResponse, privilege);
}
@@ -188,34 +149,19 @@ public class CatalogWrapperForREST extends
IcebergCatalogWrapper {
return loadTableResponse;
}
- @Override
- public LoadTableResponse updateTable(
- TableIdentifier tableIdentifier, UpdateTableRequest updateTableRequest) {
- if (isRESTCatalog()) {
- return tableUpdateInternal(tableIdentifier, updateTableRequest);
- } else {
- return super.updateTable(tableIdentifier, updateTableRequest);
- }
- }
-
/**
- * Get table credentials.
+ * Get table credentials from the local (Gravitino-managed) catalog backend.
+ *
+ * <p>{@link FederatedCatalogWrapper} overrides this to fetch credentials
from the remote REST
+ * catalog instead, so the base implementation never needs an {@code
instanceof RESTCatalog}
+ * check.
*
* @param identifier table identifier
- * @param privilege used for local credential vending; ignored for REST
catalog backends
+ * @param privilege used for local credential vending
* @return table credentials response
*/
public LoadCredentialsResponse getTableCredentials(
TableIdentifier identifier, CredentialPrivilege privilege) {
- if (isRESTCatalog()) {
- return getRESTTableCredentials((RESTCatalog) getCatalog(), identifier);
- } else {
- return getLocalTableCredentials(identifier, privilege);
- }
- }
-
- private LoadCredentialsResponse getLocalTableCredentials(
- TableIdentifier identifier, CredentialPrivilege privilege) {
try {
LoadTableResponse loadTableResponse = super.loadTable(identifier);
Credential credential = getCredential(loadTableResponse.tableMetadata(),
privilege);
@@ -233,60 +179,6 @@ public class CatalogWrapperForREST extends
IcebergCatalogWrapper {
}
}
- private static LoadCredentialsResponse getRESTTableCredentials(
- RESTCatalog restCatalog, TableIdentifier identifier) {
- Map<String, String> properties = Maps.newHashMap(restCatalog.properties());
- String credentialsPath =
- ResourcePaths.forCatalogProperties(properties).table(identifier) +
"/credentials";
-
- AuthManager authManager = null;
- RESTClient client = null;
- AuthSession authSession = null;
- try {
- authManager = AuthManagers.loadAuthManager(restCatalog.name(),
properties);
- client =
- HTTPClient.builder(properties)
- .uri(properties.get(CatalogProperties.URI))
- .withHeaders(RESTUtil.configHeaders(properties))
- .build();
- authSession = authManager.catalogSession(client, properties);
- return client
- .withAuthSession(authSession)
- .get(
- credentialsPath,
- LoadCredentialsResponse.class,
- Collections.emptyMap(),
- ErrorHandlers.tableErrorHandler());
- } finally {
- if (authSession != null) {
- try {
- authSession.close();
- } catch (Exception e) {
- LOG.warn(
- "Failed to close auth session when loading credentials for
table: {}", identifier, e);
- }
- }
-
- if (client != null) {
- try {
- client.close();
- } catch (Exception e) {
- LOG.warn(
- "Failed to close REST client when loading credentials for table:
{}", identifier, e);
- }
- }
-
- if (authManager != null) {
- try {
- authManager.close();
- } catch (Exception e) {
- LOG.warn(
- "Failed to close auth manager when loading credentials for
table: {}", identifier, e);
- }
- }
- }
- }
-
@Override
public void close() throws Exception {
try {
@@ -313,7 +205,7 @@ public class CatalogWrapperForREST extends
IcebergCatalogWrapper {
synchronized (catalogConfigToClientsLock) {
if (catalogConfigToClients == null) {
- catalogConfigToClients =
buildCatalogConfigToClients(getIcebergConfig(), getCatalog());
+ catalogConfigToClients = buildCatalogConfigToClients();
}
return catalogConfigToClients;
}
@@ -322,29 +214,35 @@ public class CatalogWrapperForREST extends
IcebergCatalogWrapper {
/**
* Builds properties exposed to Iceberg clients via the IRC {@code
/v1/config} defaults.
*
- * <p>For {@link RESTCatalog}, uses {@link RESTCatalog#properties()} so
defaults reflect the
- * remote catalog's config response merged with client properties (after
REST handshake), not only
- * static Gravitino catalog configuration.
+ * <p>The base implementation uses the static Gravitino catalog
configuration as the property
+ * source. {@link FederatedCatalogWrapper} overrides this to use {@code
RESTCatalog.properties()}
+ * so defaults reflect the remote catalog's config response merged with
client properties (after
+ * REST handshake).
*
* <p>{@link IcebergConstants#IO_IMPL} is passed through when present (e.g.
Iceberg {@link
* org.apache.iceberg.io.ResolvingFileIO}), so clients multiplex by URI
scheme without server-side
* rewriting per table.
+ *
+ * @return the immutable, filtered properties exposed to Iceberg clients.
*/
@VisibleForTesting
- static Map<String, String> buildCatalogConfigToClients(IcebergConfig config,
Catalog catalog) {
- Map<String, String> sourceProps;
- if (catalog instanceof RESTCatalog) {
- Map<String, String> merged = ((RESTCatalog) catalog).properties();
- sourceProps = merged != null ? new HashMap<>(merged) : new HashMap<>();
- } else {
- sourceProps = new HashMap<>(config.getIcebergCatalogProperties());
- }
+ Map<String, String> buildCatalogConfigToClients() {
+ return
filterCatalogConfigForClients(getIcebergConfig().getIcebergCatalogProperties());
+ }
+ /**
+ * Keeps only the client-facing catalog properties and validates the
data-access property.
+ *
+ * @param sourceProps the candidate properties to filter.
+ * @return the immutable, filtered properties exposed to Iceberg clients.
+ */
+ protected static Map<String, String> filterCatalogConfigForClients(
+ Map<String, String> sourceProps) {
Map<String, String> filtered =
- MapUtils.getFilteredMap(sourceProps, key ->
catalogPropertiesToClientKeys.contains(key));
- filtered = new HashMap<>(filtered);
+ new HashMap<>(
+ MapUtils.getFilteredMap(
+ sourceProps, key ->
catalogPropertiesToClientKeys.contains(key)));
validateAndNormalizeDataAccessProperty(filtered);
-
return Collections.unmodifiableMap(filtered);
}
@@ -388,20 +286,21 @@ public class CatalogWrapperForREST extends
IcebergCatalogWrapper {
credential.credentialType(),
tableIdentifier);
- // Merge temporary credential fields as Iceberg client config entries in
the load-table
- // response.
- Map<String, String> credentialConfig =
+ // Vend the temporary credential both as a first-class REST credential
(storage-prefix scoped,
+ // for Iceberg 1.7+ clients) and, for backward compatibility, flattened
into the load-table
+ // response config. This mirrors FederatedCatalogWrapper, which also
populates both fields.
+ org.apache.iceberg.rest.credentials.Credential restCredential =
IcebergRESTUtils.toRESTCredential(
- catalogCredentialManager.catalogName(),
- tableIdentifier,
- credential,
- loadTableResponse.tableMetadata())
- .config();
+ catalogCredentialManager.catalogName(),
+ tableIdentifier,
+ credential,
+ loadTableResponse.tableMetadata());
return LoadTableResponse.builder()
.withTableMetadata(loadTableResponse.tableMetadata())
.addAllConfig(loadTableResponse.config())
.addAllConfig(getCatalogConfigToClient())
- .addAllConfig(credentialConfig)
+ .addAllConfig(restCredential.config())
+ .addCredential(restCredential)
.build();
}
@@ -437,11 +336,6 @@ public class CatalogWrapperForREST extends
IcebergCatalogWrapper {
return false;
}
- // RESTCatalog will fetch credential from the remote catalog instead of
generating credential
- if (getCatalog() instanceof RESTCatalog) {
- return false;
- }
-
validateCredentialLocation(loadTableResponse.tableMetadata().location());
return !isLocalOrHdfsTable(loadTableResponse.tableMetadata());
}
@@ -730,280 +624,4 @@ public class CatalogWrapperForREST extends
IcebergCatalogWrapper {
properties.put(newProperty, deprecatedValue);
}
}
-
- private LoadTableResponse createTableInternal(Namespace namespace,
CreateTableRequest request) {
- Catalog loadedCatalog = getCatalog();
-
- request.validate();
-
- if (request.stageCreate()) {
- return stageTableCreateInternal(namespace, request);
- }
-
- TableIdentifier ident = TableIdentifier.of(namespace, request.name());
- Table table =
- loadedCatalog
- .buildTable(ident, request.schema())
- .withLocation(request.location())
- .withPartitionSpec(request.spec())
- .withSortOrder(request.writeOrder())
- .withProperties(request.properties())
- .create();
-
- if (table instanceof BaseTable) {
- Map<String, String> properties = retrieveFileIOProperties(table.io());
- Map<String, String> filteredCredentialProperties =
- CredentialPropertyUtils.filterCredentialProperties(properties);
- return LoadTableResponse.builder()
- .withTableMetadata(((BaseTable) table).operations().current())
- .addAllConfig(
- MapUtils.getFilteredMap(
- properties, key ->
catalogPropertiesToClientKeys.contains(key)))
- // Keep only credential fields from FileIO properties before
returning them to the
- // client.
- .addAllConfig(filteredCredentialProperties)
- .addAllConfig(
- IcebergRESTUtils.buildRefreshProps(
- catalogCredentialManager.catalogName(), ident,
filteredCredentialProperties))
- .addAllCredentials(
- IcebergRESTUtils.buildStorageCreds(
- catalogCredentialManager.catalogName(), ident, table.io()))
- .build();
- }
-
- throw new IllegalStateException("Cannot wrap catalog that does not produce
BaseTable");
- }
-
- private LoadTableResponse stageTableCreateInternal(
- Namespace namespace, CreateTableRequest request) {
- Catalog loadedCatalog = getCatalog();
- TableIdentifier ident = TableIdentifier.of(namespace, request.name());
- if (loadedCatalog.tableExists(ident)) {
- throw new AlreadyExistsException("Table already exists: %s", ident);
- }
-
- Map<String, String> properties = Maps.newHashMap();
- properties.put("created-at",
OffsetDateTime.now(ZoneOffset.UTC).toString());
- properties.putAll(request.properties());
-
- Map<String, String> config = Maps.newHashMap();
- Catalog.TableBuilder tableBuilder =
- loadedCatalog
- .buildTable(ident, request.schema())
- .withPartitionSpec(request.spec())
- .withSortOrder(request.writeOrder())
- .withProperties(properties);
-
- Table table;
- if (request.location() != null) {
- table =
tableBuilder.withLocation(request.location()).createTransaction().table();
- } else {
- table = tableBuilder.createTransaction().table();
- }
-
- Map<String, String> tableProperties = retrieveFileIOProperties(table.io());
- Map<String, String> filteredCredentialProperties =
- CredentialPropertyUtils.filterCredentialProperties(tableProperties);
- config.putAll(
- MapUtils.getFilteredMap(
- tableProperties, key ->
catalogPropertiesToClientKeys.contains(key)));
- config.putAll(filteredCredentialProperties);
- config.putAll(
- IcebergRESTUtils.buildRefreshProps(
- catalogCredentialManager.catalogName(), ident,
filteredCredentialProperties));
-
- List<org.apache.iceberg.rest.credentials.Credential> credentials =
- IcebergRESTUtils.buildStorageCreds(
- catalogCredentialManager.catalogName(), ident, table.io());
-
- TableMetadata metadata =
- TableMetadata.newTableMetadata(
- request.schema(),
- request.spec() != null ? request.spec() :
PartitionSpec.unpartitioned(),
- request.writeOrder() != null ? request.writeOrder() :
SortOrder.unsorted(),
- table.location(),
- properties);
-
- return LoadTableResponse.builder()
- .withTableMetadata(metadata)
- .addAllConfig(config)
- .addAllCredentials(credentials)
- .build();
- }
-
- private LoadTableResponse tableUpdateInternal(TableIdentifier ident,
UpdateTableRequest request) {
- if (isCreate(request)) {
- // this is a hacky way to get TableOperations for an uncommitted table
- Optional<Integer> formatVersion =
- request.updates().stream()
- .filter(update -> update instanceof
MetadataUpdate.UpgradeFormatVersion)
- .map(update -> ((MetadataUpdate.UpgradeFormatVersion)
update).formatVersion())
- .findFirst();
-
- Schema schema =
- request.updates().stream()
- .filter(update -> update instanceof MetadataUpdate.AddSchema)
- .map(update -> ((MetadataUpdate.AddSchema) update).schema())
- .findFirst()
- .orElse(EMPTY_SCHEMA);
-
- Catalog.TableBuilder tableBuilder = getCatalog().buildTable(ident,
schema);
-
- TableMetadata.Builder changedMetadata =
-
formatVersion.map(TableMetadata::buildFromEmpty).orElse(TableMetadata.buildFromEmpty());
- request.updates().forEach(update -> update.applyTo(changedMetadata));
-
- TableMetadata changedTableMeta = changedMetadata.build();
- tableBuilder.withPartitionSpec(changedTableMeta.spec());
- tableBuilder.withSortOrder(changedTableMeta.sortOrder());
- tableBuilder.withLocation(changedTableMeta.location());
- tableBuilder.withProperty(FORMAT_VERSION,
String.valueOf(changedTableMeta.formatVersion()));
- tableBuilder.withProperties(changedTableMeta.properties());
-
- Transaction transaction = tableBuilder.createOrReplaceTransaction();
- if (transaction instanceof BaseTransaction) {
- BaseTransaction baseTransaction = (BaseTransaction) transaction;
-
- return LoadTableResponse.builder()
- .withTableMetadata(create(baseTransaction, request))
- .build();
- } else {
- throw new IllegalStateException(
- "Cannot wrap catalog that does not produce BaseTransaction");
- }
-
- } else {
- return CatalogHandlers.updateTable(getCatalog(), ident, request);
- }
- }
-
- private LoadTableResponse loadTableInternal(TableIdentifier ident) {
- Table table = getCatalog().loadTable(ident);
-
- if (table instanceof BaseTable) {
- Map<String, String> properties = retrieveFileIOProperties(table.io());
- Map<String, String> filteredCredentialProperties =
- CredentialPropertyUtils.filterCredentialProperties(properties);
- return LoadTableResponse.builder()
- .withTableMetadata(((BaseTable) table).operations().current())
- .addAllConfig(
- MapUtils.getFilteredMap(
- properties, key ->
catalogPropertiesToClientKeys.contains(key)))
- // Keep only credential fields from FileIO properties before
returning them to the client.
- .addAllConfig(filteredCredentialProperties)
- .addAllConfig(
- IcebergRESTUtils.buildRefreshProps(
- catalogCredentialManager.catalogName(), ident,
filteredCredentialProperties))
- .addAllCredentials(
- IcebergRESTUtils.buildStorageCreds(
- catalogCredentialManager.catalogName(), ident, table.io()))
- .build();
- } else if (table instanceof BaseMetadataTable) {
- // metadata tables are loaded on the client side, return
NoSuchTableException for now
- throw new NoSuchTableException("Table does not exist: %s",
ident.toString());
- }
-
- throw new IllegalStateException("Cannot wrap catalog that does not produce
BaseTable");
- }
-
- private static boolean isCreate(UpdateTableRequest request) {
- boolean isCreate =
- request.requirements().stream()
-
.anyMatch(UpdateRequirement.AssertTableDoesNotExist.class::isInstance);
-
- if (isCreate) {
- List<UpdateRequirement> invalidRequirements =
- request.requirements().stream()
- .filter(req -> !(req instanceof
UpdateRequirement.AssertTableDoesNotExist))
- .collect(Collectors.toList());
- Preconditions.checkArgument(
- invalidRequirements.isEmpty(), "Invalid create requirements: %s",
invalidRequirements);
- }
-
- return isCreate;
- }
-
- private static TableMetadata create(BaseTransaction baseTransaction,
UpdateTableRequest request) {
- // the only valid requirement is that the table will be created
- TableOperations ops = baseTransaction.underlyingOps();
- request.requirements().forEach(requirement ->
requirement.validate(ops.current()));
-
- TableMetadata.Builder builder =
TableMetadata.buildFrom(baseTransaction.currentMetadata());
- request
- .updates()
- .forEach(
- update -> {
- if (shouldApplyMetadataUpdateAfterBuilder(update)) {
- update.applyTo(builder);
- }
- });
-
- // create transactions do not retry. if the table exists, retrying is not
a solution
- ops.commit(null, builder.build());
-
- return ops.current();
- }
-
- /**
- * Returns {@code false} for updates already reflected through {@link
Catalog.TableBuilder} during
- * staged create; those must not be applied again on {@link
TableMetadata.Builder}.
- */
- @VisibleForTesting
- static boolean shouldApplyMetadataUpdateAfterBuilder(MetadataUpdate update) {
- if (update instanceof MetadataUpdate.UpgradeFormatVersion) {
- return false;
- }
-
- if (update instanceof MetadataUpdate.AddSchema) {
- return false;
- }
-
- if (update instanceof MetadataUpdate.SetCurrentSchema) {
- return false;
- }
-
- if (update instanceof MetadataUpdate.RemoveSchemas) {
- return false;
- }
-
- if (update instanceof MetadataUpdate.SetLocation) {
- return false;
- }
-
- if (update instanceof MetadataUpdate.SetProperties) {
- return false;
- }
-
- if (update instanceof MetadataUpdate.RemoveProperties) {
- return false;
- }
-
- if (update instanceof MetadataUpdate.AddSortOrder) {
- return false;
- }
-
- if (update instanceof MetadataUpdate.SetDefaultSortOrder) {
- return false;
- }
-
- if (update instanceof MetadataUpdate.AddPartitionSpec) {
- return false;
- }
-
- if (update instanceof MetadataUpdate.SetDefaultPartitionSpec) {
- return false;
- }
-
- if (update instanceof MetadataUpdate.RemovePartitionSpecs) {
- return false;
- }
-
- return true;
- }
-
- private static Map<String, String> retrieveFileIOProperties(FileIO fileIO) {
- return fileIO instanceof InMemoryFileIO
- ? Maps.newHashMap()
- : new HashMap<>(fileIO.properties());
- }
}
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/FederatedCatalogWrapper.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/FederatedCatalogWrapper.java
new file mode 100644
index 0000000000..e726ca5617
--- /dev/null
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/FederatedCatalogWrapper.java
@@ -0,0 +1,506 @@
+/*
+ * 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.gravitino.iceberg.service;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Preconditions;
+import com.google.common.collect.Maps;
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+import org.apache.gravitino.credential.CredentialPrivilege;
+import org.apache.gravitino.credential.CredentialPropertyUtils;
+import org.apache.gravitino.iceberg.common.IcebergConfig;
+import org.apache.gravitino.utils.MapUtils;
+import org.apache.iceberg.BaseMetadataTable;
+import org.apache.iceberg.BaseTable;
+import org.apache.iceberg.BaseTransaction;
+import org.apache.iceberg.CatalogProperties;
+import org.apache.iceberg.MetadataUpdate;
+import org.apache.iceberg.PartitionSpec;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.SortOrder;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.TableMetadata;
+import org.apache.iceberg.TableOperations;
+import org.apache.iceberg.Transaction;
+import org.apache.iceberg.UpdateRequirement;
+import org.apache.iceberg.catalog.Catalog;
+import org.apache.iceberg.catalog.Namespace;
+import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.exceptions.AlreadyExistsException;
+import org.apache.iceberg.exceptions.NoSuchTableException;
+import org.apache.iceberg.inmemory.InMemoryFileIO;
+import org.apache.iceberg.io.FileIO;
+import org.apache.iceberg.rest.CatalogHandlers;
+import org.apache.iceberg.rest.ErrorHandlers;
+import org.apache.iceberg.rest.HTTPClient;
+import org.apache.iceberg.rest.RESTCatalog;
+import org.apache.iceberg.rest.RESTClient;
+import org.apache.iceberg.rest.RESTUtil;
+import org.apache.iceberg.rest.ResourcePaths;
+import org.apache.iceberg.rest.auth.AuthManager;
+import org.apache.iceberg.rest.auth.AuthManagers;
+import org.apache.iceberg.rest.auth.AuthSession;
+import org.apache.iceberg.rest.credentials.Credential;
+import org.apache.iceberg.rest.requests.CreateTableRequest;
+import org.apache.iceberg.rest.requests.RegisterTableRequest;
+import org.apache.iceberg.rest.requests.UpdateTableRequest;
+import org.apache.iceberg.rest.responses.LoadCredentialsResponse;
+import org.apache.iceberg.rest.responses.LoadTableResponse;
+
+/**
+ * A {@link CatalogWrapperForREST} for a federated Iceberg REST catalog (the
underlying catalog is a
+ * {@link RESTCatalog}).
+ *
+ * <p>Federation-specific behavior is expressed through polymorphic overrides
instead of {@code
+ * instanceof RESTCatalog} checks scattered across the base class. Table
operations are routed to
+ * federation-aware {@code *Internal} methods so client-facing FileIO and
credential properties are
+ * extracted from the remote catalog's {@code table.io()}. Credentials are
vended by the remote
+ * catalog, so this wrapper never injects Gravitino-generated credentials.
+ */
+public class FederatedCatalogWrapper extends CatalogWrapperForREST {
+
+ private static final String FORMAT_VERSION = "format-version";
+ private static final Schema EMPTY_SCHEMA = new Schema();
+
+ /**
+ * Creates a federated wrapper.
+ *
+ * @param catalogName the catalog name.
+ * @param config the Iceberg catalog configuration (backend {@code rest}).
+ */
+ public FederatedCatalogWrapper(String catalogName, IcebergConfig config) {
+ super(catalogName, config);
+ }
+
+ @Override
+ public LoadTableResponse createTable(
+ Namespace namespace, CreateTableRequest request, boolean
requestCredential) {
+ // The remote REST catalog vends its own credentials, so the
requestCredential flag is not used
+ // here; FileIO-derived client config is extracted by createTableInternal.
+ return createTableInternal(namespace, request);
+ }
+
+ @Override
+ public LoadTableResponse loadTable(
+ TableIdentifier identifier, boolean requestCredential,
CredentialPrivilege privilege) {
+ return loadTableInternal(identifier);
+ }
+
+ @Override
+ public LoadTableResponse registerTable(
+ Namespace namespace, RegisterTableRequest request, boolean
requestCredential) {
+ return registerTableInternal(namespace, request);
+ }
+
+ @Override
+ public LoadTableResponse updateTable(
+ TableIdentifier tableIdentifier, UpdateTableRequest updateTableRequest) {
+ return tableUpdateInternal(tableIdentifier, updateTableRequest);
+ }
+
+ @Override
+ Map<String, String> buildCatalogConfigToClients() {
+ Map<String, String> merged = ((RESTCatalog) getCatalog()).properties();
+ return filterCatalogConfigForClients(merged != null ? merged :
Collections.emptyMap());
+ }
+
+ /**
+ * Fetches table credentials from the remote REST catalog instead of vending
Gravitino-managed
+ * credentials. The {@code privilege} is ignored because the remote catalog
decides what to vend.
+ *
+ * <p>The upstream credentials are rewritten via {@link
IcebergRESTUtils#rewriteTableCredentials}
+ * so their {@code refresh-credentials-endpoint} points at this IRC catalog
rather than the remote
+ * catalog, consistent with the {@link #loadTable}/{@code createTable}
federation paths.
+ *
+ * @param identifier the table identifier.
+ * @param privilege ignored; the remote REST catalog vends its own
credentials.
+ * @return the remote credentials with IRC-local refresh endpoints.
+ */
+ @Override
+ public LoadCredentialsResponse getTableCredentials(
+ TableIdentifier identifier, CredentialPrivilege privilege) {
+ LoadCredentialsResponse upstream =
+ getRESTTableCredentials((RESTCatalog) getCatalog(), identifier);
+ return IcebergRESTUtils.rewriteTableCredentials(
+ catalogCredentialManager.catalogName(), identifier, upstream);
+ }
+
+ private static LoadCredentialsResponse getRESTTableCredentials(
+ RESTCatalog restCatalog, TableIdentifier identifier) {
+ Map<String, String> properties = Maps.newHashMap(restCatalog.properties());
+ String credentialsPath =
+ ResourcePaths.forCatalogProperties(properties).table(identifier) +
"/credentials";
+
+ AuthManager authManager = null;
+ RESTClient client = null;
+ AuthSession authSession = null;
+ try {
+ authManager = AuthManagers.loadAuthManager(restCatalog.name(),
properties);
+ client =
+ HTTPClient.builder(properties)
+ .uri(properties.get(CatalogProperties.URI))
+ .withHeaders(RESTUtil.configHeaders(properties))
+ .build();
+ authSession = authManager.catalogSession(client, properties);
+ return client
+ .withAuthSession(authSession)
+ .get(
+ credentialsPath,
+ LoadCredentialsResponse.class,
+ Collections.emptyMap(),
+ ErrorHandlers.tableErrorHandler());
+ } finally {
+ if (authSession != null) {
+ try {
+ authSession.close();
+ } catch (Exception e) {
+ LOG.warn(
+ "Failed to close auth session when loading credentials for
table: {}", identifier, e);
+ }
+ }
+
+ if (client != null) {
+ try {
+ client.close();
+ } catch (Exception e) {
+ LOG.warn(
+ "Failed to close REST client when loading credentials for table:
{}", identifier, e);
+ }
+ }
+
+ if (authManager != null) {
+ try {
+ authManager.close();
+ } catch (Exception e) {
+ LOG.warn(
+ "Failed to close auth manager when loading credentials for
table: {}", identifier, e);
+ }
+ }
+ }
+ }
+
+ /**
+ * Federation-aware {@code createTable}: creates the table on the underlying
(remote) catalog and
+ * extracts client-facing FileIO/credential properties from {@code
table.io()}.
+ */
+ private LoadTableResponse createTableInternal(Namespace namespace,
CreateTableRequest request) {
+ Catalog loadedCatalog = getCatalog();
+
+ request.validate();
+
+ if (request.stageCreate()) {
+ return stageTableCreateInternal(namespace, request);
+ }
+
+ TableIdentifier ident = TableIdentifier.of(namespace, request.name());
+ Table table =
+ loadedCatalog
+ .buildTable(ident, request.schema())
+ .withLocation(request.location())
+ .withPartitionSpec(request.spec())
+ .withSortOrder(request.writeOrder())
+ .withProperties(request.properties())
+ .create();
+
+ if (table instanceof BaseTable) {
+ return buildLoadTableResponseFromFileIo(ident, (BaseTable) table);
+ }
+
+ throw new IllegalStateException("Cannot wrap catalog that does not produce
BaseTable");
+ }
+
+ private LoadTableResponse stageTableCreateInternal(
+ Namespace namespace, CreateTableRequest request) {
+ Catalog loadedCatalog = getCatalog();
+ TableIdentifier ident = TableIdentifier.of(namespace, request.name());
+ if (loadedCatalog.tableExists(ident)) {
+ throw new AlreadyExistsException("Table already exists: %s", ident);
+ }
+
+ Map<String, String> properties = Maps.newHashMap();
+ properties.put("created-at",
OffsetDateTime.now(ZoneOffset.UTC).toString());
+ properties.putAll(request.properties());
+
+ Map<String, String> config = Maps.newHashMap();
+ Catalog.TableBuilder tableBuilder =
+ loadedCatalog
+ .buildTable(ident, request.schema())
+ .withPartitionSpec(request.spec())
+ .withSortOrder(request.writeOrder())
+ .withProperties(properties);
+
+ Table table;
+ if (request.location() != null) {
+ table =
tableBuilder.withLocation(request.location()).createTransaction().table();
+ } else {
+ table = tableBuilder.createTransaction().table();
+ }
+
+ Map<String, String> tableProperties = retrieveFileIOProperties(table.io());
+ Map<String, String> filteredCredentialProperties =
+ CredentialPropertyUtils.filterCredentialProperties(tableProperties);
+ config.putAll(
+ MapUtils.getFilteredMap(
+ tableProperties, key ->
catalogPropertiesToClientKeys.contains(key)));
+ config.putAll(filteredCredentialProperties);
+ config.putAll(
+ IcebergRESTUtils.buildRefreshProps(
+ catalogCredentialManager.catalogName(), ident,
filteredCredentialProperties));
+
+ List<Credential> credentials =
+ IcebergRESTUtils.buildStorageCreds(
+ catalogCredentialManager.catalogName(), ident, table.io());
+
+ TableMetadata metadata =
+ TableMetadata.newTableMetadata(
+ request.schema(),
+ request.spec() != null ? request.spec() :
PartitionSpec.unpartitioned(),
+ request.writeOrder() != null ? request.writeOrder() :
SortOrder.unsorted(),
+ table.location(),
+ properties);
+
+ return LoadTableResponse.builder()
+ .withTableMetadata(metadata)
+ .addAllConfig(config)
+ .addAllCredentials(credentials)
+ .build();
+ }
+
+ /**
+ * Federation-aware {@code registerTable}: registers the existing table
metadata on the underlying
+ * (remote) catalog and extracts client-facing FileIO/credential properties
from {@code
+ * table.io()}, mirroring {@link #loadTableInternal(TableIdentifier)}.
+ */
+ private LoadTableResponse registerTableInternal(
+ Namespace namespace, RegisterTableRequest request) {
+ TableIdentifier ident = TableIdentifier.of(namespace, request.name());
+ Table table = getCatalog().registerTable(ident,
request.metadataLocation());
+
+ if (table instanceof BaseTable) {
+ return buildLoadTableResponseFromFileIo(ident, (BaseTable) table);
+ }
+
+ throw new IllegalStateException("Cannot wrap catalog that does not produce
BaseTable");
+ }
+
+ /**
+ * Federation-aware {@code updateTable}: applies the update against the
underlying (remote)
+ * catalog, including the staged-create path used by federated table
creation.
+ */
+ private LoadTableResponse tableUpdateInternal(TableIdentifier ident,
UpdateTableRequest request) {
+ if (isCreate(request)) {
+ // this is a hacky way to get TableOperations for an uncommitted table
+ Optional<Integer> formatVersion =
+ request.updates().stream()
+ .filter(update -> update instanceof
MetadataUpdate.UpgradeFormatVersion)
+ .map(update -> ((MetadataUpdate.UpgradeFormatVersion)
update).formatVersion())
+ .findFirst();
+
+ Schema schema =
+ request.updates().stream()
+ .filter(update -> update instanceof MetadataUpdate.AddSchema)
+ .map(update -> ((MetadataUpdate.AddSchema) update).schema())
+ .findFirst()
+ .orElse(EMPTY_SCHEMA);
+
+ Catalog.TableBuilder tableBuilder = getCatalog().buildTable(ident,
schema);
+
+ TableMetadata.Builder changedMetadata =
+
formatVersion.map(TableMetadata::buildFromEmpty).orElse(TableMetadata.buildFromEmpty());
+ request.updates().forEach(update -> update.applyTo(changedMetadata));
+
+ TableMetadata changedTableMeta = changedMetadata.build();
+ tableBuilder.withPartitionSpec(changedTableMeta.spec());
+ tableBuilder.withSortOrder(changedTableMeta.sortOrder());
+ tableBuilder.withLocation(changedTableMeta.location());
+ tableBuilder.withProperty(FORMAT_VERSION,
String.valueOf(changedTableMeta.formatVersion()));
+ tableBuilder.withProperties(changedTableMeta.properties());
+
+ Transaction transaction = tableBuilder.createOrReplaceTransaction();
+ if (transaction instanceof BaseTransaction) {
+ BaseTransaction baseTransaction = (BaseTransaction) transaction;
+
+ return LoadTableResponse.builder()
+ .withTableMetadata(create(baseTransaction, request))
+ .build();
+ } else {
+ throw new IllegalStateException(
+ "Cannot wrap catalog that does not produce BaseTransaction");
+ }
+
+ } else {
+ return CatalogHandlers.updateTable(getCatalog(), ident, request);
+ }
+ }
+
+ /**
+ * Federation-aware {@code loadTable}: loads the table from the underlying
(remote) catalog and
+ * extracts client-facing FileIO/credential properties from {@code
table.io()}.
+ */
+ private LoadTableResponse loadTableInternal(TableIdentifier ident) {
+ Table table = getCatalog().loadTable(ident);
+
+ if (table instanceof BaseTable) {
+ return buildLoadTableResponseFromFileIo(ident, (BaseTable) table);
+ } else if (table instanceof BaseMetadataTable) {
+ // metadata tables are loaded on the client side, return
NoSuchTableException for now
+ throw new NoSuchTableException("Table does not exist: %s",
ident.toString());
+ }
+
+ throw new IllegalStateException("Cannot wrap catalog that does not produce
BaseTable");
+ }
+
+ /**
+ * Builds a {@link LoadTableResponse} from a remote {@link BaseTable},
exposing the client-facing
+ * FileIO and credential properties extracted from {@code table.io()},
including the refreshable
+ * vended credentials and refresh properties for the remote storage.
+ *
+ * @param ident the table identifier, used to build the credential-refresh
endpoint.
+ * @param table the remote base table whose {@code io()} carries the storage
credentials.
+ * @return the load-table response including FileIO-derived client config
and vended credentials.
+ */
+ private LoadTableResponse buildLoadTableResponseFromFileIo(
+ TableIdentifier ident, BaseTable table) {
+ Map<String, String> properties = retrieveFileIOProperties(table.io());
+ Map<String, String> filteredCredentialProperties =
+ CredentialPropertyUtils.filterCredentialProperties(properties);
+ return LoadTableResponse.builder()
+ .withTableMetadata(table.operations().current())
+ .addAllConfig(
+ MapUtils.getFilteredMap(properties, key ->
catalogPropertiesToClientKeys.contains(key)))
+ // Keep only credential fields from FileIO properties before returning
them to the client.
+ .addAllConfig(filteredCredentialProperties)
+ .addAllConfig(
+ IcebergRESTUtils.buildRefreshProps(
+ catalogCredentialManager.catalogName(), ident,
filteredCredentialProperties))
+ .addAllCredentials(
+ IcebergRESTUtils.buildStorageCreds(
+ catalogCredentialManager.catalogName(), ident, table.io()))
+ .build();
+ }
+
+ private static boolean isCreate(UpdateTableRequest request) {
+ boolean isCreate =
+ request.requirements().stream()
+
.anyMatch(UpdateRequirement.AssertTableDoesNotExist.class::isInstance);
+
+ if (isCreate) {
+ List<UpdateRequirement> invalidRequirements =
+ request.requirements().stream()
+ .filter(req -> !(req instanceof
UpdateRequirement.AssertTableDoesNotExist))
+ .collect(Collectors.toList());
+ Preconditions.checkArgument(
+ invalidRequirements.isEmpty(), "Invalid create requirements: %s",
invalidRequirements);
+ }
+
+ return isCreate;
+ }
+
+ private static TableMetadata create(BaseTransaction baseTransaction,
UpdateTableRequest request) {
+ // the only valid requirement is that the table will be created
+ TableOperations ops = baseTransaction.underlyingOps();
+ request.requirements().forEach(requirement ->
requirement.validate(ops.current()));
+
+ TableMetadata.Builder builder =
TableMetadata.buildFrom(baseTransaction.currentMetadata());
+ request
+ .updates()
+ .forEach(
+ update -> {
+ if (shouldApplyMetadataUpdateAfterBuilder(update)) {
+ update.applyTo(builder);
+ }
+ });
+
+ // create transactions do not retry. if the table exists, retrying is not
a solution
+ ops.commit(null, builder.build());
+
+ return ops.current();
+ }
+
+ /**
+ * Returns {@code false} for updates already reflected through {@link
Catalog.TableBuilder} during
+ * staged create; those must not be applied again on {@link
TableMetadata.Builder}.
+ */
+ @VisibleForTesting
+ static boolean shouldApplyMetadataUpdateAfterBuilder(MetadataUpdate update) {
+ if (update instanceof MetadataUpdate.UpgradeFormatVersion) {
+ return false;
+ }
+
+ if (update instanceof MetadataUpdate.AddSchema) {
+ return false;
+ }
+
+ if (update instanceof MetadataUpdate.SetCurrentSchema) {
+ return false;
+ }
+
+ if (update instanceof MetadataUpdate.RemoveSchemas) {
+ return false;
+ }
+
+ if (update instanceof MetadataUpdate.SetLocation) {
+ return false;
+ }
+
+ if (update instanceof MetadataUpdate.SetProperties) {
+ return false;
+ }
+
+ if (update instanceof MetadataUpdate.RemoveProperties) {
+ return false;
+ }
+
+ if (update instanceof MetadataUpdate.AddSortOrder) {
+ return false;
+ }
+
+ if (update instanceof MetadataUpdate.SetDefaultSortOrder) {
+ return false;
+ }
+
+ if (update instanceof MetadataUpdate.AddPartitionSpec) {
+ return false;
+ }
+
+ if (update instanceof MetadataUpdate.SetDefaultPartitionSpec) {
+ return false;
+ }
+
+ if (update instanceof MetadataUpdate.RemovePartitionSpecs) {
+ return false;
+ }
+
+ return true;
+ }
+
+ private static Map<String, String> retrieveFileIOProperties(FileIO fileIO) {
+ return fileIO instanceof InMemoryFileIO
+ ? Maps.newHashMap()
+ : new HashMap<>(fileIO.properties());
+ }
+}
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergCatalogWrapperManager.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergCatalogWrapperManager.java
index 8d8edcf185..f9561e3f8f 100644
---
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergCatalogWrapperManager.java
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergCatalogWrapperManager.java
@@ -23,11 +23,13 @@ import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.Scheduler;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.util.concurrent.ThreadFactoryBuilder;
+import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import org.apache.gravitino.GravitinoEnv;
+import org.apache.gravitino.catalog.lakehouse.iceberg.IcebergCatalogBackend;
import org.apache.gravitino.exceptions.NoSuchCatalogException;
import org.apache.gravitino.iceberg.common.IcebergConfig;
import org.apache.gravitino.iceberg.common.authentication.AuthenticationConfig;
@@ -127,7 +129,17 @@ public class IcebergCatalogWrapperManager implements
AutoCloseable {
@VisibleForTesting
protected CatalogWrapperForREST createCatalogWrapper(
String catalogName, IcebergConfig icebergConfig) {
- CatalogWrapperForREST rest = new CatalogWrapperForREST(catalogName,
icebergConfig);
+ // When the backend is a federated Iceberg REST catalog, use
FederatedCatalogWrapper so
+ // federation-aware behavior (FileIO property extraction, remote
credential vending, remote
+ // /v1/config defaults) is applied through polymorphic dispatch rather
than scattered
+ // instanceof checks. All other backends use the base
CatalogWrapperForREST.
+ IcebergCatalogBackend backend =
+ IcebergCatalogBackend.valueOf(
+
icebergConfig.get(IcebergConfig.CATALOG_BACKEND).toUpperCase(Locale.ROOT));
+ CatalogWrapperForREST rest =
+ backend == IcebergCatalogBackend.REST
+ ? new FederatedCatalogWrapper(catalogName, icebergConfig)
+ : new CatalogWrapperForREST(catalogName, icebergConfig);
AuthenticationConfig authenticationConfig =
new AuthenticationConfig(icebergConfig.getAllConfig());
if (authenticationConfig.isKerberosAuth() && rest.getCatalog() instanceof
SupportsKerberos) {
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergRESTUtils.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergRESTUtils.java
index 465ebfc138..9a4d74091d 100644
---
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergRESTUtils.java
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergRESTUtils.java
@@ -55,6 +55,8 @@ import org.apache.iceberg.io.StorageCredential;
import org.apache.iceberg.io.SupportsStorageCredentials;
import org.apache.iceberg.rest.RESTUtil;
import org.apache.iceberg.rest.responses.ErrorResponse;
+import org.apache.iceberg.rest.responses.ImmutableLoadCredentialsResponse;
+import org.apache.iceberg.rest.responses.LoadCredentialsResponse;
import org.apache.iceberg.rest.responses.LoadTableResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -178,16 +180,58 @@ public class IcebergRESTUtils {
List<org.apache.iceberg.rest.credentials.Credential> restCredentials = new
ArrayList<>();
for (StorageCredential credential : credentials) {
- Map<String, String> filteredConfig =
-
CredentialPropertyUtils.filterCredentialProperties(credential.config());
- filteredConfig.putAll(buildRefreshProps(catalogName, tableIdentifier,
filteredConfig));
restCredentials.add(
- toRESTCredential(credential.prefix(),
ImmutableMap.copyOf(filteredConfig)));
+ rewriteCredential(
+ catalogName, tableIdentifier, credential.prefix(),
credential.config()));
}
return List.copyOf(restCredentials);
}
+ /**
+ * Rewrites credentials returned by an upstream REST catalog so their {@code
+ * *.refresh-credentials-endpoint} entries point at this IRC instance
instead of the upstream
+ * catalog. Without this, a federated {@code getTableCredentials} would
return refresh endpoints
+ * scoped to the remote catalog's prefix, inconsistent with the
loadTable/createTable paths that
+ * already rewrite via {@link #buildStorageCreds}.
+ *
+ * @param catalogName IRC catalog name used to build refresh paths
+ * @param tableIdentifier table receiving the credentials
+ * @param upstream the credentials response returned by the upstream REST
catalog
+ * @return a credentials response with IRC-local refresh endpoints
+ */
+ public static LoadCredentialsResponse rewriteTableCredentials(
+ String catalogName, TableIdentifier tableIdentifier,
LoadCredentialsResponse upstream) {
+ ImmutableLoadCredentialsResponse.Builder builder =
ImmutableLoadCredentialsResponse.builder();
+ for (org.apache.iceberg.rest.credentials.Credential credential :
upstream.credentials()) {
+ builder.addCredentials(
+ rewriteCredential(
+ catalogName, tableIdentifier, credential.prefix(),
credential.config()));
+ }
+ return builder.build();
+ }
+
+ /**
+ * Filters an upstream credential's config to recognized credential
properties and re-applies
+ * IRC-local refresh-endpoint properties, dropping any refresh endpoint
scoped to the upstream
+ * catalog.
+ *
+ * @param catalogName IRC catalog name used to build refresh paths
+ * @param tableIdentifier table receiving the credential
+ * @param prefix storage location prefix for the credential
+ * @param config upstream credential config
+ * @return an Iceberg REST credential with IRC-local refresh endpoints
+ */
+ private static org.apache.iceberg.rest.credentials.Credential
rewriteCredential(
+ String catalogName,
+ TableIdentifier tableIdentifier,
+ String prefix,
+ Map<String, String> config) {
+ Map<String, String> filteredConfig =
CredentialPropertyUtils.filterCredentialProperties(config);
+ filteredConfig.putAll(buildRefreshProps(catalogName, tableIdentifier,
filteredConfig));
+ return toRESTCredential(prefix, ImmutableMap.copyOf(filteredConfig));
+ }
+
public static <T> Response ok(T t) {
return
Response.status(Response.Status.OK).entity(t).type(MediaType.APPLICATION_JSON).build();
}
diff --git
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestCatalogWrapperForREST.java
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestCatalogWrapperForREST.java
index 55d30018a0..ce845ce020 100644
---
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestCatalogWrapperForREST.java
+++
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestCatalogWrapperForREST.java
@@ -75,6 +75,8 @@ import org.apache.iceberg.rest.RESTCatalog;
import org.apache.iceberg.rest.auth.AuthProperties;
import org.apache.iceberg.rest.credentials.Credential;
import org.apache.iceberg.rest.requests.CreateTableRequest;
+import org.apache.iceberg.rest.requests.ImmutableRegisterTableRequest;
+import org.apache.iceberg.rest.requests.RegisterTableRequest;
import org.apache.iceberg.rest.requests.UpdateTableRequest;
import org.apache.iceberg.rest.responses.LoadCredentialsResponse;
import org.apache.iceberg.rest.responses.LoadTableResponse;
@@ -129,7 +131,9 @@ public class TestCatalogWrapperForREST {
"{\"storage-credentials\":[{\"prefix\":\"s3://upstream/db/tbl/\",\"config\":{"
+ "\"s3.access-key-id\":\"upstream-key\","
+ "\"s3.secret-access-key\":\"upstream-secret\","
- + "\"s3.session-token\":\"upstream-token\"}}]}";
+ + "\"s3.session-token\":\"upstream-token\","
+ + "\"client.refresh-credentials-endpoint\":"
+ + "\"v1/upstream/namespaces/db/tables/tbl/credentials\"}}]}";
AtomicReference<String> requestPath = new AtomicReference<>();
HttpServer server = HttpServer.create(new InetSocketAddress(0), 0);
@@ -178,6 +182,11 @@ public class TestCatalogWrapperForREST {
Assertions.assertEquals("upstream-key",
credential.config().get("s3.access-key-id"));
Assertions.assertEquals("upstream-secret",
credential.config().get("s3.secret-access-key"));
Assertions.assertEquals("upstream-token",
credential.config().get("s3.session-token"));
+ // The upstream refresh endpoint is rewritten to this IRC catalog's
prefix ("local"), not the
+ // remote catalog's ("upstream"), matching the loadTable/createTable
federation paths.
+ Assertions.assertEquals(
+ "v1/local/namespaces/db/tables/tbl/credentials",
+ credential.config().get("client.refresh-credentials-endpoint"));
} finally {
server.stop(0);
}
@@ -552,8 +561,9 @@ public class TestCatalogWrapperForREST {
IcebergConstants.WAREHOUSE,
"s3://remote/warehouse"));
- Map<String, String> configToClients =
- CatalogWrapperForREST.buildCatalogConfigToClients(config, restCatalog);
+ // FederatedCatalogWrapper sources the client config from the remote
RESTCatalog's properties().
+ CatalogWrapperForREST wrapper = new StaticCatalogWrapperForREST("test",
config, restCatalog);
+ Map<String, String> configToClients =
wrapper.buildCatalogConfigToClients();
Assertions.assertEquals(
"org.apache.iceberg.aws.s3.S3FileIO",
configToClients.get(IcebergConstants.IO_IMPL));
@@ -570,33 +580,30 @@ public class TestCatalogWrapperForREST {
ImmutableMap.of(
IcebergConstants.CATALOG_BACKEND,
"hive",
+ IcebergConstants.URI,
+ "thrift://hive-metastore:9083",
IcebergConstants.IO_IMPL,
ResolvingFileIO.class.getName(),
IcebergConstants.WAREHOUSE,
"s3://bucket/warehouse"));
- Catalog catalog = mock(Catalog.class);
- Map<String, String> configToClients =
- CatalogWrapperForREST.buildCatalogConfigToClients(config, catalog);
+ // Base CatalogWrapperForREST sources the client config from static
catalog configuration.
+ CatalogWrapperForREST wrapper = new CatalogWrapperForREST("test", config);
+ Map<String, String> configToClients =
wrapper.buildCatalogConfigToClients();
Assertions.assertEquals(
ResolvingFileIO.class.getName(),
configToClients.get(IcebergConstants.IO_IMPL));
}
@Test
- void testNonRESTCatalogClientConfig() {
- Catalog catalog = mock(Catalog.class);
- IcebergConfig config =
- new IcebergConfig(
+ void testNonRestCatalogClientConfig() {
+ Map<String, String> configToClients =
+ CatalogWrapperForREST.filterCatalogConfigForClients(
ImmutableMap.of(
- IcebergConstants.CATALOG_BACKEND,
- "hive",
IcebergConstants.URI,
"thrift://hive-metastore:9083",
IcebergConstants.IO_IMPL,
"org.apache.iceberg.aws.s3.S3FileIO"));
- Map<String, String> configToClients =
- CatalogWrapperForREST.buildCatalogConfigToClients(config, catalog);
Assertions.assertFalse(configToClients.containsKey(IcebergConstants.URI));
Assertions.assertEquals(
"org.apache.iceberg.aws.s3.S3FileIO",
configToClients.get(IcebergConstants.IO_IMPL));
@@ -605,18 +612,52 @@ public class TestCatalogWrapperForREST {
@Test
void testCatalogClientConfigRejectsBadDataAccess() {
- Catalog catalog = mock(Catalog.class);
+ Map<String, String> source =
+ ImmutableMap.of(IcebergConstants.ICEBERG_ACCESS_DELEGATION,
"invalid-mode");
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> CatalogWrapperForREST.filterCatalogConfigForClients(source));
+ }
+
+ @Test
+ void testFederatedRegisterTableIncludesFileIo() {
+ RESTCatalog catalog = mock(RESTCatalog.class);
+ BaseTable table = mock(BaseTable.class);
+ TableOperations ops = mock(TableOperations.class);
+ FileIO fileIO = mock(FileIO.class);
+ when(catalog.registerTable(any(TableIdentifier.class),
anyString())).thenReturn(table);
+ when(table.operations()).thenReturn(ops);
+ when(ops.current()).thenReturn(minimalTableMetadataForStagedCreateTest());
+ when(table.io()).thenReturn(fileIO);
+ when(fileIO.properties())
+ .thenReturn(
+ ImmutableMap.of(
+ IcebergConstants.IO_IMPL,
+ "org.apache.iceberg.aws.s3.S3FileIO",
+ IcebergConstants.ICEBERG_S3_ENDPOINT,
+ "http://localhost:9000"));
+
IcebergConfig config =
new IcebergConfig(
ImmutableMap.of(
IcebergConstants.CATALOG_BACKEND,
- "hive",
- IcebergConstants.ICEBERG_ACCESS_DELEGATION,
- "invalid-mode"));
+ "memory",
+ IcebergConstants.WAREHOUSE,
+ "/tmp/warehouse"));
+ CatalogWrapperForREST wrapper = new StaticCatalogWrapperForREST("test",
config, catalog);
- Assertions.assertThrows(
- IllegalArgumentException.class,
- () -> CatalogWrapperForREST.buildCatalogConfigToClients(config,
catalog));
+ RegisterTableRequest request =
+ ImmutableRegisterTableRequest.builder()
+ .name("tbl")
+
.metadataLocation("s3://bucket/warehouse/tbl/metadata/v1.metadata.json")
+ .build();
+
+ LoadTableResponse response = wrapper.registerTable(Namespace.of("db"),
request, false);
+
+ Assertions.assertEquals(
+ "org.apache.iceberg.aws.s3.S3FileIO",
response.config().get(IcebergConstants.IO_IMPL));
+ Assertions.assertEquals(
+ "http://localhost:9000",
response.config().get(IcebergConstants.ICEBERG_S3_ENDPOINT));
}
@Test
@@ -760,41 +801,41 @@ public class TestCatalogWrapperForREST {
void testPostBuilderMetadataSkipsHandledKinds() {
Schema schema = new Schema(Types.NestedField.required(1, "c",
Types.LongType.get()));
Assertions.assertFalse(
- CatalogWrapperForREST.shouldApplyMetadataUpdateAfterBuilder(
+ FederatedCatalogWrapper.shouldApplyMetadataUpdateAfterBuilder(
new MetadataUpdate.AddSchema(schema)));
Assertions.assertFalse(
- CatalogWrapperForREST.shouldApplyMetadataUpdateAfterBuilder(
+ FederatedCatalogWrapper.shouldApplyMetadataUpdateAfterBuilder(
new MetadataUpdate.UpgradeFormatVersion(2)));
Assertions.assertFalse(
- CatalogWrapperForREST.shouldApplyMetadataUpdateAfterBuilder(
+ FederatedCatalogWrapper.shouldApplyMetadataUpdateAfterBuilder(
new MetadataUpdate.SetCurrentSchema(-1)));
Assertions.assertFalse(
- CatalogWrapperForREST.shouldApplyMetadataUpdateAfterBuilder(
+ FederatedCatalogWrapper.shouldApplyMetadataUpdateAfterBuilder(
new MetadataUpdate.SetLocation("file:///tmp/loc")));
Assertions.assertFalse(
- CatalogWrapperForREST.shouldApplyMetadataUpdateAfterBuilder(
+ FederatedCatalogWrapper.shouldApplyMetadataUpdateAfterBuilder(
new MetadataUpdate.SetProperties(ImmutableMap.of("k", "v"))));
Assertions.assertFalse(
- CatalogWrapperForREST.shouldApplyMetadataUpdateAfterBuilder(
+ FederatedCatalogWrapper.shouldApplyMetadataUpdateAfterBuilder(
new MetadataUpdate.RemoveProperties(Collections.singleton("k"))));
Assertions.assertFalse(
- CatalogWrapperForREST.shouldApplyMetadataUpdateAfterBuilder(
+ FederatedCatalogWrapper.shouldApplyMetadataUpdateAfterBuilder(
new
MetadataUpdate.AddPartitionSpec(PartitionSpec.unpartitioned())));
Assertions.assertFalse(
- CatalogWrapperForREST.shouldApplyMetadataUpdateAfterBuilder(
+ FederatedCatalogWrapper.shouldApplyMetadataUpdateAfterBuilder(
new
MetadataUpdate.SetDefaultPartitionSpec(PartitionSpec.unpartitioned().specId())));
Assertions.assertFalse(
- CatalogWrapperForREST.shouldApplyMetadataUpdateAfterBuilder(
+ FederatedCatalogWrapper.shouldApplyMetadataUpdateAfterBuilder(
new MetadataUpdate.AddSortOrder(SortOrder.unsorted())));
Assertions.assertFalse(
- CatalogWrapperForREST.shouldApplyMetadataUpdateAfterBuilder(
+ FederatedCatalogWrapper.shouldApplyMetadataUpdateAfterBuilder(
new
MetadataUpdate.SetDefaultSortOrder(SortOrder.unsorted().orderId())));
}
@Test
void testPostBuilderMetadataAllowsAssignUuid() {
Assertions.assertTrue(
- CatalogWrapperForREST.shouldApplyMetadataUpdateAfterBuilder(
+ FederatedCatalogWrapper.shouldApplyMetadataUpdateAfterBuilder(
new MetadataUpdate.AssignUUID(UUID.randomUUID().toString())));
}
@@ -906,8 +947,8 @@ public class TestCatalogWrapperForREST {
}
/**
- * Same derivation as {@link CatalogWrapperForREST#tableUpdateInternal} for
staged create: replay
- * metadata updates and read {@link TableMetadata#formatVersion()}.
+ * Same derivation as {@link FederatedCatalogWrapper#tableUpdateInternal}
for staged create:
+ * replay metadata updates and read {@link TableMetadata#formatVersion()}.
*/
private static String expectedFormatVersionStringAfterStagedUpdates(
Schema schema, Optional<Integer> formatVersionForUpgrade) {
@@ -925,7 +966,7 @@ public class TestCatalogWrapperForREST {
/**
* Minimal valid Iceberg staged-create update sequence so {@link
TableMetadata.Builder#build()}
- * succeeds in {@link CatalogWrapperForREST#tableUpdateInternal}.
+ * succeeds in {@link FederatedCatalogWrapper#tableUpdateInternal}.
*/
private static List<MetadataUpdate> stagedCreateMetadataUpdates(
Schema schema, Optional<Integer> formatVersion) {
@@ -969,7 +1010,9 @@ public class TestCatalogWrapperForREST {
}
}
- private static class StaticCatalogWrapperForREST extends
CatalogWrapperForREST {
+ // Extends FederatedCatalogWrapper so table operations route through the
federation-aware
+ // *Internal paths (FileIO extraction) against the injected catalog.
+ private static class StaticCatalogWrapperForREST extends
FederatedCatalogWrapper {
private final Catalog catalog;
StaticCatalogWrapperForREST(String catalogName, IcebergConfig config,
Catalog catalog) {
diff --git
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergCatalogWrapperManagerForREST.java
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergCatalogWrapperManagerForREST.java
index f81ea32633..2d4386b730 100644
---
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergCatalogWrapperManagerForREST.java
+++
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergCatalogWrapperManagerForREST.java
@@ -18,12 +18,15 @@
*/
package org.apache.gravitino.iceberg.service;
+import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Maps;
import java.util.Map;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.reflect.FieldUtils;
import org.apache.gravitino.GravitinoEnv;
import org.apache.gravitino.catalog.CatalogManager;
+import org.apache.gravitino.catalog.lakehouse.iceberg.IcebergConstants;
+import org.apache.gravitino.iceberg.common.IcebergConfig;
import org.apache.gravitino.iceberg.common.ops.IcebergCatalogWrapper;
import
org.apache.gravitino.iceberg.service.authorization.IcebergRESTServerContext;
import org.apache.gravitino.iceberg.service.provider.IcebergConfigProvider;
@@ -110,4 +113,43 @@ public class TestIcebergCatalogWrapperManagerForREST {
.getMessage()
.contains("gravitino.iceberg-rest.catalog-config-provider=dynamic-config-provider"));
}
+
+ @Test
+ public void testCreateFederatedWrapperForRestBackend() {
+ IcebergConfig icebergConfig =
+ new IcebergConfig(
+ ImmutableMap.of(
+ IcebergConstants.CATALOG_BACKEND,
+ "rest",
+ IcebergConstants.URI,
+ "http://localhost:8181"));
+
+ CatalogWrapperForREST wrapper = newManager().createCatalogWrapper("test",
icebergConfig);
+
+ Assertions.assertInstanceOf(FederatedCatalogWrapper.class, wrapper);
+ }
+
+ @Test
+ public void testCreateBaseWrapperForNonRestBackend() {
+ IcebergConfig icebergConfig =
+ new IcebergConfig(
+ ImmutableMap.of(
+ IcebergConstants.CATALOG_BACKEND,
+ "memory",
+ IcebergConstants.WAREHOUSE,
+ "/tmp/warehouse"));
+
+ CatalogWrapperForREST wrapper = newManager().createCatalogWrapper("test",
icebergConfig);
+
+ Assertions.assertFalse(wrapper instanceof FederatedCatalogWrapper);
+ Assertions.assertEquals(CatalogWrapperForREST.class, wrapper.getClass());
+ }
+
+ private static IcebergCatalogWrapperManager newManager() {
+ Map<String, String> config = Maps.newHashMap();
+ IcebergConfigProvider configProvider =
IcebergConfigProviderFactory.create(config);
+ configProvider.initialize(config);
+ return new IcebergCatalogWrapperManager(
+ config, configProvider, false, configProvider.getMetalakeName());
+ }
}
diff --git
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergRESTUtils.java
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergRESTUtils.java
index f7dd7cb2f0..2ad1f85c3d 100644
---
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergRESTUtils.java
+++
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergRESTUtils.java
@@ -45,6 +45,8 @@ import org.apache.iceberg.io.StorageCredential;
import org.apache.iceberg.io.SupportsStorageCredentials;
import org.apache.iceberg.rest.credentials.Credential;
import org.apache.iceberg.rest.requests.CreateTableRequest;
+import org.apache.iceberg.rest.responses.ImmutableLoadCredentialsResponse;
+import org.apache.iceberg.rest.responses.LoadCredentialsResponse;
import org.apache.iceberg.types.Types.IntegerType;
import org.apache.iceberg.types.Types.NestedField;
import org.apache.iceberg.types.Types.StringType;
@@ -318,4 +320,34 @@ public class TestIcebergRESTUtils {
Assertions.assertFalse(config.containsKey("client.refresh-credentials-endpoint"));
}
+
+ @Test
+ void testRewriteTableCredentials() {
+ TableIdentifier table = TableIdentifier.of(Namespace.of("db"), "tbl");
+ LoadCredentialsResponse upstream =
+ ImmutableLoadCredentialsResponse.builder()
+ .addCredentials(
+ IcebergRESTUtils.toRESTCredential(
+ "s3://bucket/db/tbl/",
+ ImmutableMap.of(
+ "s3.session-token",
+ "upstream-token",
+ "s3.session-token-expires-at-ms",
+ "123",
+ "client.refresh-credentials-endpoint",
+ "v1/upstream/namespaces/db/tables/tbl/credentials")))
+ .build();
+
+ LoadCredentialsResponse rewritten =
+ IcebergRESTUtils.rewriteTableCredentials("irc1", table, upstream);
+
+ Assertions.assertEquals(1, rewritten.credentials().size());
+ Credential credential = rewritten.credentials().get(0);
+ Assertions.assertEquals("s3://bucket/db/tbl/", credential.prefix());
+ Assertions.assertEquals("upstream-token",
credential.config().get("s3.session-token"));
+ // The refresh endpoint is re-scoped to the IRC catalog, dropping the
upstream-scoped one.
+ Assertions.assertEquals(
+ "v1/irc1/namespaces/db/tables/tbl/credentials",
+ credential.config().get("client.refresh-credentials-endpoint"));
+ }
}