diff --git a/core/src/main/java/org/apache/iceberg/rest/ClientCapability.java b/core/src/main/java/org/apache/iceberg/rest/ClientCapability.java new file mode 100644 index 000000000000..1a616b56b5c6 --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/rest/ClientCapability.java @@ -0,0 +1,92 @@ +/* + * 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.iceberg.rest; + +import java.util.Arrays; +import org.apache.iceberg.relocated.com.google.common.base.Joiner; + +/** + * Capabilities the REST client SDK declares it supports to the catalog server. + * + *
The set of supported capabilities is sent on every REST request via the {@code + * X-Iceberg-Client-Capabilities} header as a comma-separated list of {@link #headerValue() header + * values}. The header is purely informational: the server MAY use it to tailor responses, but MUST + * NOT require it and MUST NOT fail when it is absent. + * + *
Capabilities are independent of one another. A client that supports {@link + * #VENDED_CREDENTIALS} does not preclude support for {@link #REMOTE_SIGNING} or {@link + * #SCAN_PLANNING}; clients should advertise every capability they support. + */ +public enum ClientCapability { + /** + * Client supports receiving and using storage credentials vended by the catalog server. + * + *
When advertised, the server MAY return a {@code storage-credentials} array in {@code + * LoadTableResult} (and equivalent load responses) where each entry contains a storage location + * prefix and a config map of credentials (e.g. {@code s3.access-key-id}, {@code + * s3.session-token}, GCS or ADLS equivalents). The client passes these to its {@link + * org.apache.iceberg.io.FileIO} when the implementation also implements {@link + * org.apache.iceberg.io.SupportsStorageCredentials}. Vended credentials take precedence over + * inline credentials in the response {@code config} map. + */ + VENDED_CREDENTIALS("vended-credentials"), + + /** + * Client supports delegating S3 request signing to a remote signing service exposed by the + * catalog server. + * + *
When advertised, the client may issue per-request signing calls to {@code POST + * /v1/{prefix}/namespaces/{namespace}/tables/{table}/sign} with a {@link + * org.apache.iceberg.rest.requests.RemoteSignRequest} (region, method, URI, headers, optional + * body) and use the signed URI and headers returned in {@link + * org.apache.iceberg.rest.responses.RemoteSignResponse} to perform the actual S3 operation. This + * allows the catalog to centrally control AWS credentials without distributing them to clients. + */ + REMOTE_SIGNING("remote-signing"), + + /** + * Client supports server-side scan planning via the REST API. + * + *
When advertised, the client can drive the asynchronous planning protocol: submit a {@link
+ * org.apache.iceberg.rest.requests.PlanTableScanRequest} to {@code POST
+ * /v1/{prefix}/namespaces/{namespace}/tables/{table}/plan}, poll {@code GET .../plan/{plan-id}}
+ * while the plan status is {@code SUBMITTED}, and fetch paginated {@link
+ * org.apache.iceberg.rest.requests.FetchScanTasksRequest} results once the plan is {@code
+ * COMPLETED}. This shifts manifest reading and task generation from the client to the server.
+ */
+ SCAN_PLANNING("scan-planning");
+
+ /**
+ * Comma-separated list of every capability's {@link #headerValue()}, suitable for use as the
+ * {@code X-Iceberg-Client-Capabilities} request header value.
+ */
+ public static final String HEADER_VALUE =
+ Joiner.on(',').join(Arrays.stream(values()).map(ClientCapability::headerValue).iterator());
+
+ private final String headerValue;
+
+ ClientCapability(String headerValue) {
+ this.headerValue = headerValue;
+ }
+
+ /** Returns the header token used to advertise this capability. */
+ public String headerValue() {
+ return headerValue;
+ }
+}
diff --git a/core/src/main/java/org/apache/iceberg/rest/HTTPClient.java b/core/src/main/java/org/apache/iceberg/rest/HTTPClient.java
index 46d9177b9571..7f5b4e7a31cb 100644
--- a/core/src/main/java/org/apache/iceberg/rest/HTTPClient.java
+++ b/core/src/main/java/org/apache/iceberg/rest/HTTPClient.java
@@ -80,6 +80,9 @@ public class HTTPClient extends BaseHTTPClient {
@VisibleForTesting
static final String CLIENT_GIT_COMMIT_SHORT_HEADER = "X-Client-Git-Commit-Short";
+ @VisibleForTesting
+ static final String CLIENT_CAPABILITIES_HEADER = "X-Iceberg-Client-Capabilities";
+
private static final String REST_MAX_RETRIES = "rest.client.max-retries";
static final String REST_MAX_CONNECTIONS = "rest.client.max-connections";
static final int REST_MAX_CONNECTIONS_DEFAULT = 100;
@@ -548,6 +551,7 @@ public Builder withAuthSession(AuthSession session) {
public HTTPClient build() {
withHeader(CLIENT_VERSION_HEADER, IcebergBuild.fullVersion());
withHeader(CLIENT_GIT_COMMIT_SHORT_HEADER, IcebergBuild.gitCommitShortId());
+ withHeader(CLIENT_CAPABILITIES_HEADER, ClientCapability.HEADER_VALUE);
String proxyHostname =
PropertyUtil.propertyAsString(properties, HTTPClient.REST_PROXY_HOSTNAME, null);
diff --git a/core/src/test/java/org/apache/iceberg/rest/TestClientCapability.java b/core/src/test/java/org/apache/iceberg/rest/TestClientCapability.java
new file mode 100644
index 000000000000..29cd5b6cbe67
--- /dev/null
+++ b/core/src/test/java/org/apache/iceberg/rest/TestClientCapability.java
@@ -0,0 +1,43 @@
+/*
+ * 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.iceberg.rest;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.util.Arrays;
+import org.junit.jupiter.api.Test;
+
+public class TestClientCapability {
+
+ @Test
+ public void testHeaderValuesAreCorrect() {
+ assertThat(ClientCapability.VENDED_CREDENTIALS.headerValue()).isEqualTo("vended-credentials");
+ assertThat(ClientCapability.REMOTE_SIGNING.headerValue()).isEqualTo("remote-signing");
+ assertThat(ClientCapability.SCAN_PLANNING.headerValue()).isEqualTo("scan-planning");
+ }
+
+ @Test
+ public void testHeaderValueIncludesAllEnumConstants() {
+ String[] expected =
+ Arrays.stream(ClientCapability.values())
+ .map(ClientCapability::headerValue)
+ .toArray(String[]::new);
+ assertThat(ClientCapability.HEADER_VALUE.split(",")).containsExactlyInAnyOrder(expected);
+ }
+}
diff --git a/core/src/test/java/org/apache/iceberg/rest/TestHTTPClient.java b/core/src/test/java/org/apache/iceberg/rest/TestHTTPClient.java
index 701ae699f136..6ec62af1486e 100644
--- a/core/src/test/java/org/apache/iceberg/rest/TestHTTPClient.java
+++ b/core/src/test/java/org/apache/iceberg/rest/TestHTTPClient.java
@@ -169,6 +169,27 @@ public void testHeadFailure() throws JsonProcessingException {
testHttpMethodOnFailure(HttpMethod.HEAD);
}
+ @Test
+ public void testCapabilityHeaderOverridesUserConfig() throws IOException {
+ String userBogusValue = "user-supplied-bogus";
+ try (RESTClient clientWithUserCapabilities =
+ HTTPClient.builder(ImmutableMap.of())
+ .uri(URI)
+ .withHeader(HTTPClient.CLIENT_CAPABILITIES_HEADER, userBogusValue)
+ .withAuthSession(AuthSession.EMPTY)
+ .build()) {
+ String path = "v1/test-capability-override";
+ HttpRequest mockRequest =
+ request("/" + path)
+ .withMethod(HttpMethod.HEAD.name().toUpperCase(Locale.ROOT))
+ .withHeader(HTTPClient.CLIENT_CAPABILITIES_HEADER, ClientCapability.HEADER_VALUE);
+ HttpResponse mockResponse = response().withStatusCode(200);
+ mockServer.when(mockRequest).respond(mockResponse);
+ clientWithUserCapabilities.head(path, ImmutableMap.of(), (onError) -> {});
+ mockServer.verify(mockRequest, VerificationTimes.exactly(1));
+ }
+ }
+
@Test
public void testProxyServer() throws IOException {
int proxyPort = 1070;
@@ -672,6 +693,7 @@ private static String addRequestTestCaseAndGetPath(
.withHeader("Authorization", "Bearer " + BEARER_AUTH_TOKEN)
.withHeader(HTTPClient.CLIENT_VERSION_HEADER, icebergBuildFullVersion)
.withHeader(HTTPClient.CLIENT_GIT_COMMIT_SHORT_HEADER, icebergBuildGitCommitShort)
+ .withHeader(HTTPClient.CLIENT_CAPABILITIES_HEADER, ClientCapability.HEADER_VALUE)
.withHeader(USER_AGENT, TEST_USER_AGENT);
if (method.usesRequestBody()) {
diff --git a/open-api/rest-catalog-open-api.yaml b/open-api/rest-catalog-open-api.yaml
index 06d13ec133d9..d72862935a2c 100644
--- a/open-api/rest-catalog-open-api.yaml
+++ b/open-api/rest-catalog-open-api.yaml
@@ -76,6 +76,7 @@ paths:
schema:
type: string
description: Warehouse location or identifier to request from the service
+ - $ref: '#/components/parameters/client-capabilities'
description:
"
All REST clients should first call this route to get catalog configuration
@@ -280,6 +281,7 @@ paths:
schema:
type: string
example: "accounting%1Ftax"
+ - $ref: '#/components/parameters/client-capabilities'
responses:
200:
$ref: '#/components/responses/ListNamespacesResponse'
@@ -311,6 +313,7 @@ paths:
summary: Create a namespace
parameters:
- $ref: '#/components/parameters/idempotency-key'
+ - $ref: '#/components/parameters/client-capabilities'
description:
Create a namespace, with an optional set of properties.
The server might also add properties, such as `last_modified_time` etc.
@@ -358,6 +361,8 @@ paths:
- Catalog API
summary: Load the metadata properties for a namespace
operationId: loadNamespaceMetadata
+ parameters:
+ - $ref: '#/components/parameters/client-capabilities'
description: Return all stored metadata properties for a given namespace
responses:
200:
@@ -389,6 +394,8 @@ paths:
- Catalog API
summary: Check if a namespace exists
operationId: namespaceExists
+ parameters:
+ - $ref: '#/components/parameters/client-capabilities'
description:
Check if a namespace exists. The response does not contain a body.
responses:
@@ -423,6 +430,7 @@ paths:
operationId: dropNamespace
parameters:
- $ref: '#/components/parameters/idempotency-key'
+ - $ref: '#/components/parameters/client-capabilities'
responses:
204:
description: Success, no content
@@ -469,6 +477,7 @@ paths:
operationId: updateProperties
parameters:
- $ref: '#/components/parameters/idempotency-key'
+ - $ref: '#/components/parameters/client-capabilities'
description:
Set and/or remove properties on a namespace.
The request body specifies a list of properties to remove and a map
@@ -536,6 +545,7 @@ paths:
parameters:
- $ref: '#/components/parameters/page-token'
- $ref: '#/components/parameters/page-size'
+ - $ref: '#/components/parameters/client-capabilities'
responses:
200:
$ref: '#/components/responses/ListTablesResponse'
@@ -582,6 +592,7 @@ paths:
parameters:
- $ref: '#/components/parameters/data-access'
- $ref: '#/components/parameters/idempotency-key'
+ - $ref: '#/components/parameters/client-capabilities'
requestBody:
required: true
content:
@@ -674,6 +685,7 @@ paths:
operationId: planTableScan
parameters:
- $ref: '#/components/parameters/idempotency-key'
+ - $ref: '#/components/parameters/client-capabilities'
requestBody:
content:
application/json:
@@ -724,6 +736,8 @@ paths:
- Catalog API
summary: Fetches the result of scan planning for a plan-id
operationId: fetchPlanningResult
+ parameters:
+ - $ref: '#/components/parameters/client-capabilities'
description: >
Fetches the result of scan planning for a plan-id.
@@ -784,6 +798,7 @@ paths:
operationId: cancelPlanning
parameters:
- $ref: '#/components/parameters/idempotency-key'
+ - $ref: '#/components/parameters/client-capabilities'
description: >
Cancels scan planning for a plan-id.
@@ -847,6 +862,7 @@ paths:
operationId: fetchScanTasks
parameters:
- $ref: '#/components/parameters/idempotency-key'
+ - $ref: '#/components/parameters/client-capabilities'
description: Fetches result tasks for a plan task.
requestBody:
content:
@@ -898,6 +914,7 @@ paths:
parameters:
- $ref: '#/components/parameters/data-access'
- $ref: '#/components/parameters/idempotency-key'
+ - $ref: '#/components/parameters/client-capabilities'
description:
Register a table using given metadata file location.
@@ -993,6 +1010,7 @@ paths:
type: string
enum: [ all, refs ]
- $ref: '#/components/parameters/referenced-by'
+ - $ref: '#/components/parameters/client-capabilities'
responses:
200:
$ref: '#/components/responses/LoadTableResponse'
@@ -1030,6 +1048,7 @@ paths:
operationId: updateTable
parameters:
- $ref: '#/components/parameters/idempotency-key'
+ - $ref: '#/components/parameters/client-capabilities'
description:
Commit updates to a table.
@@ -1158,6 +1177,7 @@ paths:
schema:
type: boolean
default: false
+ - $ref: '#/components/parameters/client-capabilities'
responses:
204:
description: Success, no content
@@ -1189,6 +1209,8 @@ paths:
- Catalog API
summary: Check if a table exists
operationId: tableExists
+ parameters:
+ - $ref: '#/components/parameters/client-capabilities'
description:
Check if a table exists within a given namespace. The response does not contain a body.
responses:
@@ -1236,6 +1258,7 @@ paths:
type: string
description: The plan ID that has been used for server-side scan planning
- $ref: '#/components/parameters/referenced-by'
+ - $ref: '#/components/parameters/client-capabilities'
description: Load vended credentials for a table from the catalog.
responses:
200:
@@ -1274,6 +1297,8 @@ paths:
- Catalog API
summary: Remotely signs requests to object storage
operationId: signRequest
+ parameters:
+ - $ref: '#/components/parameters/client-capabilities'
requestBody:
description: The request to be signed
content:
@@ -1307,6 +1332,7 @@ paths:
summary: Rename a table from its current name to a new name
parameters:
- $ref: '#/components/parameters/idempotency-key'
+ - $ref: '#/components/parameters/client-capabilities'
description:
Rename a table from one identifier to another. It's valid to move a table
across namespaces, but the server implementation is not required to support it.
@@ -1372,6 +1398,8 @@ paths:
- Catalog API
summary: Send a metrics report to this endpoint to be processed by the backend
operationId: reportMetrics
+ parameters:
+ - $ref: '#/components/parameters/client-capabilities'
requestBody:
description: The request containing the metrics report to be sent
content:
@@ -1416,6 +1444,7 @@ paths:
operationId: commitTransaction
parameters:
- $ref: '#/components/parameters/idempotency-key'
+ - $ref: '#/components/parameters/client-capabilities'
requestBody:
description:
Commit updates to multiple tables in an atomic operation
@@ -1536,6 +1565,7 @@ paths:
parameters:
- $ref: '#/components/parameters/page-token'
- $ref: '#/components/parameters/page-size'
+ - $ref: '#/components/parameters/client-capabilities'
responses:
200:
$ref: '#/components/responses/ListTablesResponse'
@@ -1568,6 +1598,8 @@ paths:
description:
Create a view in the given namespace.
operationId: createView
+ parameters:
+ - $ref: '#/components/parameters/client-capabilities'
requestBody:
required: true
content:
@@ -1636,6 +1668,7 @@ paths:
key. For example, "urn:ietf:params:oauth:token-type:jwt=