Repository: googleapis/java-storage
Pull request #2297 (2023-11-07 22:10:21)

chore: Add in TM entry for x-goog-api-client
Thank you for opening a Pull Request! Before submitting your PR, there are a few things you can do to make sure it goes smoothly:
- [ ] Make sure to open an issue as a [bug/issue](https://github.com/googleapis/java-storage/issues/new/choose) before writing your code!  That way we can discuss the change, evaluate designs, and agree on the general idea
- [ ] Ensure the tests and linter pass
- [ ] Code coverage does not decrease (if any source code was changed)
- [ ] Appropriate docs were updated (if necessary)

Fixes #<issue_number_goes_here> ☕️

If you write sample code, please follow the [samples format](
https://github.com/GoogleCloudPlatform/java-docs-samples/blob/main/SAMPLE_FORMAT.md).



Patch:
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcStorageOptions.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcStorageOptions.java
index 09dfaf5d..bee342f9 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcStorageOptions.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcStorageOptions.java
@@ -60,6 +60,7 @@ import java.io.IOException;
 import java.io.Serializable;
 import java.net.URI;
 import java.time.Clock;
+import java.util.Arrays;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
@@ -122,7 +123,7 @@ public final class GrpcStorageOptions extends StorageOptions
 
   /**
    * We have to perform several introspections and detections to cross-wire/support several features
-   * that are either gapic primitives, ServieOption primitives or GCS semantic requirements.
+   * that are either gapic primitives, ServiceOption primitives or GCS semantic requirements.
    *
    * <h2>Requester Pays, {@code quota_project_id} and {@code userProject}</h2>
    *
@@ -138,12 +139,12 @@ public final class GrpcStorageOptions extends StorageOptions
    * request metadata and we set the request metadata it results in two different entries in the
    * request. This creates ambiguity for GCS which then rejects the request.
    *
-   * <p>To account for this and to provide a similarly level of precedence we are introspecting the
+   * <p>To account for this and to provide a similar level of precedence we are introspecting the
    * credentials and service options to save any {@code quota_project_id} into an {@link
    * UserProject} which is then used by {@link GrpcStorageImpl} to resolve individual request
    * metadata.
    *
-   * <h3>The precedence we want to provide is as follows</h3>
+   * <h3>The precedence we provide is as follows</h3>
    *
    * <ol>
    *   <li>Any "userProject" Option provided to an individual method
@@ -207,13 +208,23 @@ public final class GrpcStorageOptions extends StorageOptions
       }
     }
 
+    boolean isTm =
+        Arrays.stream(Thread.currentThread().getStackTrace())
+            .anyMatch(
+                ste -> ste.getClassName().startsWith("com.google.cloud.storage.transfermanager"));
+
     HeaderProvider internalHeaderProvider =
         StorageSettings.defaultApiClientHeaderProviderBuilder()
             .setClientLibToken(ServiceOptions.getGoogApiClientLibName(), getLibraryVersion())
             .build();
+    if (isTm) {
+      internalHeaderProvider =
+          XGoogApiClientHeaderProvider.of(
+              internalHeaderProvider, ImmutableList.of("gccl-gcs-cmd/tm"));
+    }
 
     StorageSettings.Builder builder =
-        new StorageSettingsBuilder(StorageSettings.newBuilder().build())
+        new GapicStorageSettingsBuilder(StorageSettings.newBuilder().build())
             .setInternalHeaderProvider(internalHeaderProvider)
             .setEndpoint(endpoint)
             .setCredentialsProvider(credentialsProvider)
@@ -738,8 +749,8 @@ public final class GrpcStorageOptions extends StorageOptions
 
   // setInternalHeaderProvider is protected so we need to open its scope in order to set it
   // we are adding an entry for gccl which is set via this provider
-  private static final class StorageSettingsBuilder extends StorageSettings.Builder {
-    private StorageSettingsBuilder(StorageSettings settings) {
+  private static final class GapicStorageSettingsBuilder extends StorageSettings.Builder {
+    private GapicStorageSettingsBuilder(StorageSettings settings) {
       super(settings);
     }
 
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/XGoogApiClientHeaderProvider.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/XGoogApiClientHeaderProvider.java
new file mode 100644
index 00000000..263a6705
--- /dev/null
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/XGoogApiClientHeaderProvider.java
@@ -0,0 +1,105 @@
+/*
+ * Copyright 2023 Google LLC
+ *
+ * Licensed 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 com.google.cloud.storage;
+
+import com.google.api.gax.rpc.HeaderProvider;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.MapDifference;
+import com.google.common.collect.MapDifference.ValueDifference;
+import com.google.common.collect.Maps;
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.stream.Collector;
+import java.util.stream.Collectors;
+
+final class XGoogApiClientHeaderProvider implements HeaderProvider {
+
+  /** Separate entries with a space */
+  private static final Collector<CharSequence, ?, String> COMBINER = Collectors.joining(" ");
+
+  private final Map<String, String> headers;
+
+  private XGoogApiClientHeaderProvider(Map<String, String> headers) {
+    this.headers = headers;
+  }
+
+  @Override
+  public Map<String, String> getHeaders() {
+    return headers;
+  }
+
+  static XGoogApiClientHeaderProvider of(
+      HeaderProvider baseValue, ImmutableList<String> additionalEntries) {
+    if (additionalEntries.isEmpty()) {
+      return new XGoogApiClientHeaderProvider(baseValue.getHeaders());
+    } else {
+      ImmutableMap<String, String> right =
+          ImmutableMap.of("x-goog-api-client", additionalEntries.stream().collect(COMBINER));
+      ImmutableMap<String, String> union = union(baseValue.getHeaders(), right);
+      return new XGoogApiClientHeaderProvider(union);
+    }
+  }
+
+  /**
+   * Union two maps, ignoring case of their keys.
+   *
+   * <p>Any key present in both {@code left} and {@code right} will be combined to produce a new
+   * single value.
+   */
+  @VisibleForTesting
+  static ImmutableMap<String, String> union(Map<String, String> left, Map<String, String> right) {
+    if (left.equals(right)) {
+      return ImmutableMap.copyOf(left);
+    }
+    Map<String, String> l = lowerKeys(left);
+    Map<String, String> r = lowerKeys(right);
+    if (l.equals(r)) {
+      return ImmutableMap.copyOf(l);
+    }
+
+    Map<String, String> tmp = new HashMap<>();
+
+    MapDifference<String, String> diff = Maps.difference(l, r);
+
+    tmp.putAll(diff.entriesOnlyOnLeft());
+    tmp.putAll(diff.entriesOnlyOnRight());
+    tmp.putAll(diff.entriesInCommon());
+
+    for (Entry<String, ValueDifference<String>> e : diff.entriesDiffering().entrySet()) {
+      String k = e.getKey();
+      ValueDifference<String> v = e.getValue();
+
+      tmp.put(k, ImmutableList.of(v.leftValue(), v.rightValue()).stream().collect(COMBINER));
+    }
+
+    return ImmutableMap.copyOf(tmp);
+  }
+
+  private static Map<String, String> lowerKeys(Map<String, String> orig) {
+    Map<String, String> tmp = new HashMap<>();
+    for (Entry<String, String> e : orig.entrySet()) {
+      String k = e.getKey();
+      String v = e.getValue();
+      tmp.put(k.toLowerCase(Locale.US), v);
+    }
+    return tmp;
+  }
+}
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/spi/v1/HttpStorageRpc.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/spi/v1/HttpStorageRpc.java
index eb5b8e1b..a51369e1 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/spi/v1/HttpStorageRpc.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/spi/v1/HttpStorageRpc.java
@@ -87,11 +87,15 @@ import java.math.BigInteger;
 import java.net.FileNameMap;
 import java.net.URLConnection;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.LinkedList;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
 import java.util.UUID;
+import java.util.stream.Collector;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
 import javax.annotation.Nullable;
 
 public class HttpStorageRpc implements StorageRpc {
@@ -122,11 +126,17 @@ public class HttpStorageRpc implements StorageRpc {
     HttpRequestInitializer initializer = transportOptions.getHttpRequestInitializer(options);
     this.options = options;
 
+    boolean isTm =
+        Arrays.stream(Thread.currentThread().getStackTrace())
+            .anyMatch(
+                ste -> ste.getClassName().startsWith("com.google.cloud.storage.transfermanager"));
+    String tm = isTm ? "gccl-gcs-cmd/tm" : null;
+
     // Open Census initialization
     String applicationName = options.getApplicationName();
     CensusHttpModule censusHttpModule = new CensusHttpModule(tracer, IS_RECORD_EVENTS);
     initializer = censusHttpModule.getHttpRequestInitializer(initializer);
-    initializer = new InvocationIdInitializer(initializer, applicationName);
+    initializer = new InvocationIdInitializer(initializer, applicationName, tm);
     batchRequestInitializer = censusHttpModule.getHttpRequestInitializer(null);
     storage =
         new Storage.Builder(transport, jsonFactory, initializer)
@@ -142,11 +152,13 @@ public class HttpStorageRpc implements StorageRpc {
   private static final class InvocationIdInitializer implements HttpRequestInitializer {
     @Nullable HttpRequestInitializer initializer;
     @Nullable private final String applicationName;
+    @Nullable private final String tm;
 
     private InvocationIdInitializer(
-        @Nullable HttpRequestInitializer initializer, @Nullable String applicationName) {
+        @Nullable HttpRequestInitializer initializer, @Nullable String applicationName, String tm) {
       this.initializer = initializer;
       this.applicationName = applicationName;
+      this.tm = tm;
     }
 
     @Override
@@ -156,18 +168,23 @@ public class HttpStorageRpc implements StorageRpc {
         this.initializer.initialize(request);
       }
       request.setInterceptor(
-          new InvocationIdInterceptor(request.getInterceptor(), applicationName));
+          new InvocationIdInterceptor(request.getInterceptor(), applicationName, tm));
     }
   }
 
   private static final class InvocationIdInterceptor implements HttpExecuteInterceptor {
+
+    private static final Collector<CharSequence, ?, String> JOINER = Collectors.joining(" ");
     @Nullable private final HttpExecuteInterceptor interceptor;
     @Nullable private final String applicationName;
 
+    @Nullable private final String tm;
+
     private InvocationIdInterceptor(
-        @Nullable HttpExecuteInterceptor interceptor, @Nullable String applicationName) {
+        @Nullable HttpExecuteInterceptor interceptor, @Nullable String applicationName, String tm) {
       this.interceptor = interceptor;
       this.applicationName = applicationName;
+      this.tm = tm;
     }
 
     @Override
@@ -176,19 +193,18 @@ public class HttpStorageRpc implements StorageRpc {
       if (this.interceptor != null) {
         this.interceptor.intercept(request);
       }
-      UUID invocationId = HttpRpcContext.getInstance().getInvocationId();
+      HttpRpcContext httpRpcContext = HttpRpcContext.getInstance();
+      UUID invocationId = httpRpcContext.getInvocationId();
       final String signatureKey = "Signature="; // For V2 and V4 signedURLs
       final String builtURL = request.getUrl().build();
       if (invocationId != null && !builtURL.contains(signatureKey)) {
         HttpHeaders headers = request.getHeaders();
         String existing = (String) headers.get("x-goog-api-client");
         String invocationEntry = "gccl-invocation-id/" + invocationId;
-        final String newValue;
-        if (existing != null && !existing.isEmpty()) {
-          newValue = existing + " " + invocationEntry;
-        } else {
-          newValue = invocationEntry;
-        }
+        final String newValue =
+            Stream.of(existing, invocationEntry, tm)
+                .filter(java.util.Objects::nonNull)
+                .collect(JOINER);
         headers.set("x-goog-api-client", newValue);
         headers.set("x-goog-gcs-idempotency-token", invocationId);
 


Test patch:
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/XGoogApiClientHeaderProviderTest.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/XGoogApiClientHeaderProviderTest.java
new file mode 100644
index 00000000..29b3f456
--- /dev/null
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/XGoogApiClientHeaderProviderTest.java
@@ -0,0 +1,112 @@
+/*
+ * Copyright 2023 Google LLC
+ *
+ * Licensed 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 com.google.cloud.storage;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.api.gax.rpc.ApiClientHeaderProvider;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.storage.v2.StorageSettings;
+import java.util.Map;
+import org.junit.Test;
+
+public final class XGoogApiClientHeaderProviderTest {
+
+  @Test
+  public void union_ignoreKeyCase() {
+    ImmutableMap<String, String> l = ImmutableMap.of("a", "1");
+    ImmutableMap<String, String> r = ImmutableMap.of("A", "2");
+
+    ImmutableMap<String, String> union = XGoogApiClientHeaderProvider.union(l, r);
+    assertThat(union).isEqualTo(ImmutableMap.of("a", "1 2"));
+  }
+
+  @Test
+  public void union_diff_full() {
+    ImmutableMap<String, String> l = ImmutableMap.of("a", "1");
+    ImmutableMap<String, String> r = ImmutableMap.of("b", "2");
+
+    ImmutableMap<String, String> union = XGoogApiClientHeaderProvider.union(l, r);
+    assertThat(union).isEqualTo(ImmutableMap.of("a", "1", "b", "2"));
+  }
+
+  @Test
+  public void union_equal() {
+    ImmutableMap<String, String> l = ImmutableMap.of("a", "1");
+    ImmutableMap<String, String> r = ImmutableMap.of("a", "1");
+
+    ImmutableMap<String, String> union = XGoogApiClientHeaderProvider.union(l, r);
+    assertThat(union).isEqualTo(ImmutableMap.of("a", "1"));
+  }
+
+  @Test
+  public void union_equal_ignoreCase() {
+    ImmutableMap<String, String> l = ImmutableMap.of("a", "1");
+    ImmutableMap<String, String> r = ImmutableMap.of("A", "1");
+
+    ImmutableMap<String, String> union = XGoogApiClientHeaderProvider.union(l, r);
+    assertThat(union).isEqualTo(ImmutableMap.of("a", "1"));
+  }
+
+  @Test
+  public void union_mixed() {
+    ImmutableMap<String, String> l = ImmutableMap.of("a", "1", "b", "5", "d", "22");
+    ImmutableMap<String, String> r = ImmutableMap.of("A", "2", "c", "300", "d", "22");
+
+    ImmutableMap<String, String> union = XGoogApiClientHeaderProvider.union(l, r);
+    assertThat(union).isEqualTo(ImmutableMap.of("a", "1 2", "b", "5", "c", "300", "d", "22"));
+  }
+
+  @Test
+  public void getHeaders_sameInstance() {
+    XGoogApiClientHeaderProvider provider =
+        XGoogApiClientHeaderProvider.of(
+            () -> ImmutableMap.of("x-goog-api-client", "java-storage"),
+            ImmutableList.of("some/thing"));
+
+    Map<String, String> headers1 = provider.getHeaders();
+    Map<String, String> headers2 = provider.getHeaders();
+    assertThat(headers2).isSameInstanceAs(headers1);
+
+    assertThat(headers1).isEqualTo(ImmutableMap.of("x-goog-api-client", "java-storage some/thing"));
+  }
+
+  @Test
+  public void emptyAdditionalEntries() {
+    ImmutableMap<String, String> base = ImmutableMap.of("x-goog-api-client", "java-storage");
+    XGoogApiClientHeaderProvider provider =
+        XGoogApiClientHeaderProvider.of(() -> base, ImmutableList.of());
+
+    Map<String, String> headers1 = provider.getHeaders();
+    assertThat(headers1).isSameInstanceAs(base);
+  }
+
+  @Test
+  public void apiClientProvider() {
+    ApiClientHeaderProvider base = StorageSettings.defaultApiClientHeaderProviderBuilder().build();
+    XGoogApiClientHeaderProvider provider =
+        XGoogApiClientHeaderProvider.of(base, ImmutableList.of("some/thing"));
+
+    Map<String, String> headers = provider.getHeaders();
+    assertThat(headers).isNotNull();
+
+    assertThat(headers).containsKey("x-goog-api-client");
+    assertThat(headers.get("x-goog-api-client")).contains("gl-java/");
+    assertThat(headers.get("x-goog-api-client")).contains("some/thing");
+  }
+}