Repository: googleapis/google-api-java-client
Pull request #2435 (2024-02-20 20:58:20)

feat: Add Universe Domain Support
Universe Domain support can be done almost all inside this PR. There is an additional change required inside the google-api-client-services generator. Need to add the setter inside the `{Service}.Builder` implementation.
```
@Override
public Builder setUniverseDomain(String universeDomain) {
  return (Builder) super.setUniverseDomain(universeDomain);
}
```

The reason this needs to be in the generated client code is because it returns the parent's implementation returns a type of `AbstractGoogleClient.Builder` instead of `Bigquery.Builder`. Having this inside the child implementation would return the correct type (`Bigquery.Builder`).

Tested this locally with a local build of Bigquery Apiary client and a local build of the api-client library. Connection to the test environment works and the validation is called on each RPC invocation.

### Changes required in Java-Storage and Java-Bigquery
```
{ApiaryClient}.Builder()
            .setRootUrl(options.getResolvedHost("{Apiary Client Service Name}"))
            .setUniverseDomain(options.getUniverseDomain())
.build()
```
1. Setting the rootUrl will now use the resolvedHost and no longer use the apiary workaround
2. Pass the universe domain in to the apiary client (even though setting the rootUrl will be the rootUrl used and the universe domain configuration value isn't used in resolving the endpoint, pass in the universe domain as this will allow users to call the universe domain getter).

### Local Tests
1. Local Bigquery Apiary - Able to connect + Universe Domain Validation
3. Local Java-Bigquery client library - Able to connect + Universe Domain Validation

### Next Steps:
PR in google-api-java-client-services: https://github.com/googleapis/google-api-java-client-services/pull/19934
- Requires merging in this PR and a release of a new feat (minor version bump).

blakeli0: > ### Changes required in Java-Storage and Java-Bigquery
> ```
> {ApiaryClient}.Builder()
>             .setRootUrl(options.getResolvedHost("{Apiary Client Service Name}"))
>             .setUniverseDomain(options.getUniverseDomain())
> .build()
> ```
> 
> 1. Setting the rootUrl will now use the resolvedHost and no longer use the apiary workaround
> 2. Pass the universe domain in to the apiary client (even though setting the rootUrl will be the rootUrl used and the universe domain configuration value isn't used in resolving the endpoint, pass in the universe domain as this will allow users to call the universe domain getter).

Why they have to set both rootUrl and universeDomain? I think setting universeDomain alone should be good enough?
lqiu96: > > ### Changes required in Java-Storage and Java-Bigquery
> > ```
> > {ApiaryClient}.Builder()
> >             .setRootUrl(options.getResolvedHost("{Apiary Client Service Name}"))
> >             .setUniverseDomain(options.getUniverseDomain())
> > .build()
> > ```
> > 
> > 
> >     
> >       
> >     
> > 
> >       
> >     
> > 
> >     
> >   
> > 
> > 1. Setting the rootUrl will now use the resolvedHost and no longer use the apiary workaround
> > 2. Pass the universe domain in to the apiary client (even though setting the rootUrl will be the rootUrl used and the universe domain configuration value isn't used in resolving the endpoint, pass in the universe domain as this will allow users to call the universe domain getter).
> 
> Why they have to set both rootUrl and universeDomain? I think setting universeDomain alone should be good enough?

The apiary wrapped libraries (java-storage and java-bigquery) may have some user configurations where they change the rootUrl value there. We'll need those configurations so that we won't override them when we resolve the endpoint here.
blakeli0: > > > ### Changes required in Java-Storage and Java-Bigquery
> > > ```
> > > {ApiaryClient}.Builder()
> > >             .setRootUrl(options.getResolvedHost("{Apiary Client Service Name}"))
> > >             .setUniverseDomain(options.getUniverseDomain())
> > > .build()
> > > ```
> > > 
> > > 
> > >     
> > >       
> > >     
> > > 
> > >       
> > >     
> > > 
> > >     
> > >   
> > > 
> > > 1. Setting the rootUrl will now use the resolvedHost and no longer use the apiary workaround
> > > 2. Pass the universe domain in to the apiary client (even though setting the rootUrl will be the rootUrl used and the universe domain configuration value isn't used in resolving the endpoint, pass in the universe domain as this will allow users to call the universe domain getter).
> > 
> > 
> > Why they have to set both rootUrl and universeDomain? I think setting universeDomain alone should be good enough?
> 
> The apiary wrapped libraries (java-storage and java-bigquery) may have some user configurations where they change the rootUrl value via settings there (for emulators, local endpoint, etc). We'll need those configurations so that we won't override them when the apiary library tries to resolve the endpoint in the code here.

I see. If that's the case, I would say we don't have to pass `universeDomain`, setting the `rootUrl` should be good enough. Because 
1. The Apiary client is hidden from customers in Bigquery/Storage, even if customers need to, they would use `ServiceOptions.getUniverseDomain()`.
2. This is less confusing to Bigquery/Storage developers and customers. As they may think it is required to pass both values.
3. This requires no change in Bigquery/Storage.
lqiu96: I believe you're right regarding resolving the host. I also just realized that the universe domain is also needed as we need it to validate the universe domain value. If not passed, the resolved universe domain will default to `googleapis.com`. Passing in the configured universe domain value will use that for validation.
blakeli0: > Previously, the handwritten libraries were validating the universe domain, but since we're moving it the apiary client, I believe we'll need to pass the configured universe domain value here.

It maybe a scenario we missed before, do we still want to validate the universe domain if the endpoint/rootUrl is provided? I think we should not because the endpoint configuration would override the universe domain configuration if both are configured. Otherwise it would be confusing if we pass both to the client, use one for validation but use another one for making calls. 
lqiu96: > > Previously, the handwritten libraries were validating the universe domain, but since we're moving it the apiary client, I believe we'll need to pass the configured universe domain value here.
> 
> It maybe a scenario we missed before, do we still want to validate the universe domain if the endpoint/rootUrl is provided? I think we should not because the endpoint configuration would override the universe domain configuration if both are configured. Otherwise it would be confusing if we pass both to the client, use one for validation but use another one for making calls.

It is a bit of weird scenario especially given Apiary's non-normal use case. I think we _should_ be validating the universe domain even if we are providing the "correct" endpoint from ServiceOptions.

Off the top of my head, I can think of these scenarios in which I think validation is needed required: 
1. User sets custom rootUrl (to the correct non-GDU endpoint) and no universeDomain. This will create a connection to the right place and the universe domain is resolved to GDU. They supply the correct Credentials that points to non-GDU. While this does _technically_ work as the Credentials is valid, this does not share the same functionality with the other client libs. GAPICs would throw an exception that the explicitly configured universe domain does not match before the call goes through.
2. User sets custom rootUrl (to the correct non-GDU endpoint) and a universeDomain. However, they supply the incorrect Credentials that points to GDU. I believe this would fail once the RPC hits the server, but the call should not go through (it should fail during validation). GAPICs would throw an exception that the explicitly configured universe domain does not match.

> Otherwise it would be confusing if we pass both to the client, use one for validation but use another one for making calls.

Agreed. Ideally the endpoint should be resolved inside the Apiary client itself for every use case. It's only a special case for these two handwritten apiary wrappers as Java-Core is used to resolved the endpoint. 

I chose this path because Java-Core sets the the host value to be the value of DEFUALT_HOST (https://github.com/googleapis/sdk-platform-java/blob/4b44a7851dc1d4fd2ac21a54df6c24db5625223c/java-core/google-cloud-core/src/main/java/com/google/cloud/ServiceOptions.java#L86) if nothing is configured. One option that I thought about would be to have the two libraries do some additional logic to filter this out. 
i.e.
```
String host = options.getHost().equals(DEFAULT_HOST) ? null : options.getHost();
{ApiaryClient}.Builder()
            .setRootUrl(host)
            .setUniverseDomain(options.getUniverseDomain())
.build()
```

I didn't go with this path since it's additional work for the two handwritten libraries. 



Patch:
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 4b1d0bbb..37fc0434 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -20,6 +20,13 @@ jobs:
     - run: .kokoro/build.sh
       env:
         JOB_TYPE: test
+    # The `envVarTest` profile runs tests that require an environment variable
+    - name: Env Var Tests
+      run: |
+        mvn test -B -ntp -Dclirr.skip=true -Denforcer.skip=true -PenvVarTest
+      # Set the Env Var for this step only
+      env:
+        GOOGLE_CLOUD_UNIVERSE_DOMAIN: random.com
   windows:
     runs-on: windows-latest
     steps:
diff --git a/google-api-client-bom/pom.xml b/google-api-client-bom/pom.xml
index 435765c0..597cffaf 100644
--- a/google-api-client-bom/pom.xml
+++ b/google-api-client-bom/pom.xml
@@ -170,7 +170,7 @@
           <plugin>
             <groupId>org.apache.maven.plugins</groupId>
             <artifactId>maven-gpg-plugin</artifactId>
-            <version>3.1.0</version>
+            <version>3.2.0</version>
             <executions>
               <execution>
                 <id>sign-artifacts</id>
diff --git a/google-api-client/pom.xml b/google-api-client/pom.xml
index 9bd09e0c..771103a3 100644
--- a/google-api-client/pom.xml
+++ b/google-api-client/pom.xml
@@ -111,6 +111,14 @@
           <usedDependencies>commons-codec:commons-codec</usedDependencies>
         </configuration>
       </plugin>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-surefire-plugin</artifactId>
+        <configuration>
+          <!-- These tests require an Env Var to be set. Use -PenvVarTest to ONLY run these tests -->
+          <test>!AbstractGoogleClientTest#testGoogleClientBuilder_noCustomUniverseDomain_universeDomainEnvVar+testGoogleClientBuilder_customUniverseDomain_universeDomainEnvVar</test>
+        </configuration>
+      </plugin>
     </plugins>
 
     <resources>
@@ -135,6 +143,14 @@
       <groupId>com.google.oauth-client</groupId>
       <artifactId>google-oauth-client</artifactId>
     </dependency>
+    <dependency>
+      <groupId>com.google.auth</groupId>
+      <artifactId>google-auth-library-credentials</artifactId>
+    </dependency>
+    <dependency>
+      <groupId>com.google.auth</groupId>
+      <artifactId>google-auth-library-oauth2-http</artifactId>
+    </dependency>
     <dependency>
       <groupId>com.google.http-client</groupId>
       <artifactId>google-http-client-gson</artifactId>
@@ -179,6 +195,27 @@
       <artifactId>junit</artifactId>
       <scope>test</scope>
     </dependency>
-
+    <dependency>
+      <groupId>org.mockito</groupId>
+      <artifactId>mockito-core</artifactId>
+      <scope>test</scope>
+    </dependency>
   </dependencies>
+
+  <profiles>
+    <profile>
+      <id>envVarTest</id>
+      <build>
+        <plugins>
+          <plugin>
+            <groupId>org.apache.maven.plugins</groupId>
+            <artifactId>maven-surefire-plugin</artifactId>
+            <configuration>
+              <test>AbstractGoogleClientTest#testGoogleClientBuilder_noCustomUniverseDomain_universeDomainEnvVar+testGoogleClientBuilder_customUniverseDomain_universeDomainEnvVar</test>
+            </configuration>
+          </plugin>
+        </plugins>
+      </build>
+    </profile>
+  </profiles>
 </project>
diff --git a/google-api-client/src/main/java/com/google/api/client/googleapis/services/AbstractGoogleClient.java b/google-api-client/src/main/java/com/google/api/client/googleapis/services/AbstractGoogleClient.java
index 421dc0a4..53cc57fa 100644
--- a/google-api-client/src/main/java/com/google/api/client/googleapis/services/AbstractGoogleClient.java
+++ b/google-api-client/src/main/java/com/google/api/client/googleapis/services/AbstractGoogleClient.java
@@ -20,8 +20,13 @@ import com.google.api.client.http.HttpTransport;
 import com.google.api.client.util.ObjectParser;
 import com.google.api.client.util.Preconditions;
 import com.google.api.client.util.Strings;
+import com.google.auth.Credentials;
+import com.google.auth.http.HttpCredentialsAdapter;
+import com.google.common.annotations.VisibleForTesting;
 import java.io.IOException;
 import java.util.logging.Logger;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 
 /**
  * Abstract thread-safe Google client.
@@ -33,6 +38,8 @@ public abstract class AbstractGoogleClient {
 
   private static final Logger logger = Logger.getLogger(AbstractGoogleClient.class.getName());
 
+  private static final String GOOGLE_CLOUD_UNIVERSE_DOMAIN = "GOOGLE_CLOUD_UNIVERSE_DOMAIN";
+
   /** The request factory for connections to the server. */
   private final HttpRequestFactory requestFactory;
 
@@ -68,13 +75,18 @@ public abstract class AbstractGoogleClient {
   /** Whether discovery required parameter checks should be suppressed. */
   private final boolean suppressRequiredParameterChecks;
 
+  private final String universeDomain;
+
+  private final HttpRequestInitializer httpRequestInitializer;
+
   /**
    * @param builder builder
    * @since 1.14
    */
   protected AbstractGoogleClient(Builder builder) {
     googleClientRequestInitializer = builder.googleClientRequestInitializer;
-    rootUrl = normalizeRootUrl(builder.rootUrl);
+    universeDomain = determineUniverseDomain(builder);
+    rootUrl = normalizeRootUrl(determineEndpoint(builder));
     servicePath = normalizeServicePath(builder.servicePath);
     batchPath = builder.batchPath;
     if (Strings.isNullOrEmpty(builder.applicationName)) {
@@ -88,6 +100,75 @@ public abstract class AbstractGoogleClient {
     objectParser = builder.objectParser;
     suppressPatternChecks = builder.suppressPatternChecks;
     suppressRequiredParameterChecks = builder.suppressRequiredParameterChecks;
+    httpRequestInitializer = builder.httpRequestInitializer;
+  }
+
+  /**
+   * Resolve the Universe Domain to be used when resolving the endpoint. The logic for resolving the
+   * universe domain is the following order: 1. Use the user configured value is set, 2. Use the
+   * Universe Domain Env Var if set, 3. Default to the Google Default Universe
+   */
+  private String determineUniverseDomain(Builder builder) {
+    String resolvedUniverseDomain = builder.universeDomain;
+    if (resolvedUniverseDomain == null) {
+      resolvedUniverseDomain = System.getenv(GOOGLE_CLOUD_UNIVERSE_DOMAIN);
+    }
+    return resolvedUniverseDomain == null
+        ? Credentials.GOOGLE_DEFAULT_UNIVERSE
+        : resolvedUniverseDomain;
+  }
+
+  /**
+   * Resolve the endpoint based on user configurations. If the user has configured a custom rootUrl,
+   * use that value. Otherwise, construct the endpoint based on the serviceName and the
+   * universeDomain.
+   */
+  private String determineEndpoint(Builder builder) {
+    boolean mtlsEnabled = builder.rootUrl.contains(".mtls.");
+    if (mtlsEnabled && !universeDomain.equals(Credentials.GOOGLE_DEFAULT_UNIVERSE)) {
+      throw new IllegalStateException(
+          "mTLS is not supported in any universe other than googleapis.com");
+    }
+    // If the serviceName is null, we cannot construct a valid resolved endpoint. Simply return
+    // the rootUrl as this was custom rootUrl passed in.
+    if (builder.isUserConfiguredEndpoint || builder.serviceName == null) {
+      return builder.rootUrl;
+    }
+    if (mtlsEnabled) {
+      return "https://" + builder.serviceName + ".mtls." + universeDomain + "/";
+    }
+    return "https://" + builder.serviceName + "." + universeDomain + "/";
+  }
+
+  /**
+   * Check that the User configured universe domain matches the Credentials' universe domain. This
+   * uses the HttpRequestInitializer to get the Credentials and is enforced that the
+   * HttpRequestInitializer is of the {@see <a
+   * href="https://github.com/googleapis/google-auth-library-java/blob/main/oauth2_http/java/com/google/auth/http/HttpCredentialsAdapter.java">HttpCredentialsAdapter</a>}
+   * from the google-auth-library.
+   *
+   * <p>To use a non-GDU Credentials, you must use the HttpCredentialsAdapter class.
+   *
+   * @throws IOException if there is an error reading the Universe Domain from the credentials
+   * @throws IllegalStateException if the configured Universe Domain does not match the Universe
+   *     Domain in the Credentials
+   */
+  public void validateUniverseDomain() throws IOException {
+    if (!(httpRequestInitializer instanceof HttpCredentialsAdapter)) {
+      return;
+    }
+    Credentials credentials = ((HttpCredentialsAdapter) httpRequestInitializer).getCredentials();
+    // No need for a null check as HttpCredentialsAdapter cannot be initialized with null
+    // Credentials
+    String expectedUniverseDomain = credentials.getUniverseDomain();
+    if (!expectedUniverseDomain.equals(getUniverseDomain())) {
+      throw new IllegalStateException(
+          String.format(
+              "The configured universe domain (%s) does not match the universe domain found"
+                  + " in the credentials (%s). If you haven't configured the universe domain"
+                  + " explicitly, `googleapis.com` is the default.",
+              getUniverseDomain(), expectedUniverseDomain));
+    }
   }
 
   /**
@@ -139,6 +220,18 @@ public abstract class AbstractGoogleClient {
     return googleClientRequestInitializer;
   }
 
+  /**
+   * Universe Domain is the domain for Google Cloud Services. It follows the format of
+   * `{ServiceName}.{UniverseDomain}`. For example, speech.googleapis.com would have a Universe
+   * Domain value of `googleapis.com` and cloudasset.test.com would have a Universe Domain of
+   * `test.com`. If this value is not set, this will default to `googleapis.com`.
+   *
+   * @return The configured Universe Domain or the Google Default Universe (googleapis.com)
+   */
+  public final String getUniverseDomain() {
+    return universeDomain;
+  }
+
   /**
    * Returns the object parser or {@code null} for none.
    *
@@ -173,6 +266,7 @@ public abstract class AbstractGoogleClient {
    * @param httpClientRequest Google client request type
    */
   protected void initialize(AbstractGoogleClientRequest<?> httpClientRequest) throws IOException {
+    validateUniverseDomain();
     if (getGoogleClientRequestInitializer() != null) {
       getGoogleClientRequestInitializer().initialize(httpClientRequest);
     }
@@ -311,6 +405,33 @@ public abstract class AbstractGoogleClient {
     /** Whether discovery required parameter checks should be suppressed. */
     boolean suppressRequiredParameterChecks;
 
+    /** User configured Universe Domain. Defaults to `googleapis.com`. */
+    String universeDomain;
+
+    /**
+     * Regex pattern to check if the URL passed in matches the default endpoint configured from a
+     * discovery doc. Follows the format of `https://{serviceName}(.mtls).googleapis.com/`
+     */
+    Pattern defaultEndpointRegex =
+        Pattern.compile("https://([a-zA-Z]*)(\\.mtls)?\\.googleapis.com/?");
+
+    /**
+     * Whether the user has configured an endpoint via {@link #setRootUrl(String)}. This is added in
+     * because the rootUrl is set in the Builder's constructor. ,
+     *
+     * <p>Apiary clients don't allow user configurations to this Builder's constructor, so this
+     * would be set to false by default for Apiary libraries. User configuration to the rootUrl is
+     * done via {@link #setRootUrl(String)}.
+     *
+     * <p>For other uses cases that touch this Builder's constructor directly, check if the rootUrl
+     * passed matches the default endpoint regex. If it doesn't match, it is a user configured
+     * endpoint.
+     */
+    boolean isUserConfiguredEndpoint;
+
+    /** The parsed serviceName value from the rootUrl from the Discovery Doc. */
+    String serviceName;
+
     /**
      * Returns an instance of a new builder.
      *
@@ -328,9 +449,15 @@ public abstract class AbstractGoogleClient {
         HttpRequestInitializer httpRequestInitializer) {
       this.transport = Preconditions.checkNotNull(transport);
       this.objectParser = objectParser;
-      setRootUrl(rootUrl);
-      setServicePath(servicePath);
+      this.rootUrl = normalizeRootUrl(rootUrl);
+      this.servicePath = normalizeServicePath(servicePath);
       this.httpRequestInitializer = httpRequestInitializer;
+      Matcher matcher = defaultEndpointRegex.matcher(rootUrl);
+      boolean matches = matcher.matches();
+      // Checked here for the use case where users extend this class and may pass in
+      // a custom endpoint
+      this.isUserConfiguredEndpoint = !matches;
+      this.serviceName = matches ? matcher.group(1) : null;
     }
 
     /** Builds a new instance of {@link AbstractGoogleClient}. */
@@ -371,6 +498,7 @@ public abstract class AbstractGoogleClient {
      * changing the return type, but nothing else.
      */
     public Builder setRootUrl(String rootUrl) {
+      this.isUserConfiguredEndpoint = true;
       this.rootUrl = normalizeRootUrl(rootUrl);
       return this;
     }
@@ -515,5 +643,24 @@ public abstract class AbstractGoogleClient {
     public Builder setSuppressAllChecks(boolean suppressAllChecks) {
       return setSuppressPatternChecks(true).setSuppressRequiredParameterChecks(true);
     }
+
+    /**
+     * Sets the user configured Universe Domain value. This value will be used to try and construct
+     * the endpoint to connect to GCP services.
+     *
+     * @throws IllegalArgumentException if universeDomain is passed in with an empty string ("")
+     */
+    public Builder setUniverseDomain(String universeDomain) {
+      if (universeDomain != null && universeDomain.isEmpty()) {
+        throw new IllegalArgumentException("The universe domain value cannot be empty.");
+      }
+      this.universeDomain = universeDomain;
+      return this;
+    }
+
+    @VisibleForTesting
+    String getServiceName() {
+      return serviceName;
+    }
   }
 }
diff --git a/pom.xml b/pom.xml
index bd575a92..58803a85 100644
--- a/pom.xml
+++ b/pom.xml
@@ -102,6 +102,13 @@
         <groupId>junit</groupId>
         <artifactId>junit</artifactId>
         <version>4.13.2</version>
+        <scope>test</scope>
+      </dependency>
+      <dependency>
+        <groupId>org.mockito</groupId>
+        <artifactId>mockito-core</artifactId>
+        <version>4.11.0</version>
+        <scope>test</scope>
       </dependency>
       <dependency>
         <groupId>com.google.appengine</groupId>
@@ -157,6 +164,13 @@
         <type>pom</type>
         <scope>import</scope>
       </dependency>
+      <dependency>
+        <groupId>com.google.auth</groupId>
+        <artifactId>google-auth-library-bom</artifactId>
+        <version>${project.auth.version}</version>
+        <type>pom</type>
+        <scope>import</scope>
+      </dependency>
       <dependency>
         <groupId>com.google.api-client</groupId>
         <artifactId>google-api-client</artifactId>
@@ -242,7 +256,7 @@
         </plugin>
         <plugin>
           <artifactId>maven-assembly-plugin</artifactId>
-          <version>3.6.0</version>
+          <version>3.7.0</version>
         </plugin>
         <plugin>
           <artifactId>maven-compiler-plugin</artifactId>
@@ -497,13 +511,14 @@
     <project.http.version>1.44.1</project.http.version>
     <project.httpcore.version>4.4.16</project.httpcore.version>
     <project.httpclient.version>4.5.14</project.httpclient.version>
-    <project.commons-codec.version>1.16.0</project.commons-codec.version>
+    <project.commons-codec.version>1.16.1</project.commons-codec.version>
     <project.oauth.version>1.35.0</project.oauth.version>
+    <project.auth.version>1.22.0</project.auth.version>
     <project.jsr305.version>3.0.2</project.jsr305.version>
     <project.gson.version>2.8.6</project.gson.version>
-    <project.protobuf-java.version>3.25.2</project.protobuf-java.version>
-    <project.guava.version>32.0.0-jre</project.guava.version>
-    <project.appengine.version>2.0.24</project.appengine.version>
+    <project.protobuf-java.version>3.25.3</project.protobuf-java.version>
+    <project.guava.version>33.0.0-jre</project.guava.version>
+    <project.appengine.version>2.0.25</project.appengine.version>
     <project.xpp3.version>1.1.4c</project.xpp3.version>
     <project.jdo2-api.version>2.3-20090302111651</project.jdo2-api.version>
     <project.datanucleus-core.version>3.2.2</project.datanucleus-core.version>
@@ -528,7 +543,7 @@
           <plugin>
             <groupId>org.apache.maven.plugins</groupId>
             <artifactId>maven-gpg-plugin</artifactId>
-            <version>3.1.0</version>
+            <version>3.2.0</version>
             <executions>
               <execution>
                 <id>sign-artifacts</id>
@@ -645,5 +660,23 @@
         </plugins>
       </build>
     </profile>
+
+    <profile>
+      <id>envVarTest</id>
+      <build>
+        <plugins>
+          <plugin>
+            <groupId>org.apache.maven.plugins</groupId>
+            <artifactId>maven-surefire-plugin</artifactId>
+            <configuration>
+              <!-- Unless overriden in the submodules, this profile run no tests in a submodule -->
+              <excludes>
+                <exclude>**/*.java</exclude>
+              </excludes>
+            </configuration>
+          </plugin>
+        </plugins>
+      </build>
+    </profile>
   </profiles>
 </project>
diff --git a/samples/install-without-bom/pom.xml b/samples/install-without-bom/pom.xml
index dc2b466f..69008cc8 100644
--- a/samples/install-without-bom/pom.xml
+++ b/samples/install-without-bom/pom.xml
@@ -29,7 +29,7 @@
     <dependency>
       <groupId>com.google.api-client</groupId>
       <artifactId>google-api-client</artifactId>
-      <version>2.2.0</version>
+      <version>2.3.0</version>
     </dependency>
     <!-- [END google-api-client_install_without_bom] -->
 
@@ -42,7 +42,7 @@
     <dependency>
       <groupId>com.google.truth</groupId>
       <artifactId>truth</artifactId>
-      <version>1.3.0</version>
+      <version>1.4.2</version>
       <scope>test</scope>
     </dependency>
   </dependencies>
diff --git a/samples/snapshot/pom.xml b/samples/snapshot/pom.xml
index 7798d5fc..ba42d268 100644
--- a/samples/snapshot/pom.xml
+++ b/samples/snapshot/pom.xml
@@ -28,7 +28,7 @@
     <dependency>
       <groupId>com.google.api-client</groupId>
       <artifactId>google-api-client</artifactId>
-      <version>2.2.0</version>
+      <version>2.3.0</version>
     </dependency>
   <!-- {x-version-update-end} -->
 
@@ -41,7 +41,7 @@
     <dependency>
       <groupId>com.google.truth</groupId>
       <artifactId>truth</artifactId>
-      <version>1.3.0</version>
+      <version>1.4.2</version>
       <scope>test</scope>
     </dependency>
   </dependencies>
diff --git a/samples/snippets/pom.xml b/samples/snippets/pom.xml
index 3dc9c14a..a006534e 100644
--- a/samples/snippets/pom.xml
+++ b/samples/snippets/pom.xml
@@ -30,7 +30,7 @@
       <dependency>
         <groupId>com.google.cloud</groupId>
         <artifactId>libraries-bom</artifactId>
-        <version>26.30.0</version>
+        <version>26.34.0</version>
         <type>pom</type>
         <scope>import</scope>
       </dependency>
@@ -53,7 +53,7 @@
     <dependency>
       <groupId>com.google.truth</groupId>
       <artifactId>truth</artifactId>
-      <version>1.3.0</version>
+      <version>1.4.2</version>
       <scope>test</scope>
     </dependency>
   </dependencies>


Test patch:
diff --git a/google-api-client/src/test/java/com/google/api/client/googleapis/services/AbstractGoogleClientTest.java b/google-api-client/src/test/java/com/google/api/client/googleapis/services/AbstractGoogleClientTest.java
index 7d2ff638..bcb6a377 100644
--- a/google-api-client/src/test/java/com/google/api/client/googleapis/services/AbstractGoogleClientTest.java
+++ b/google-api-client/src/test/java/com/google/api/client/googleapis/services/AbstractGoogleClientTest.java
@@ -12,6 +12,8 @@
 
 package com.google.api.client.googleapis.services;
 
+import static org.junit.Assert.assertThrows;
+
 import com.google.api.client.googleapis.media.MediaHttpUploader;
 import com.google.api.client.googleapis.testing.services.MockGoogleClient;
 import com.google.api.client.googleapis.testing.services.MockGoogleClientRequest;
@@ -32,23 +34,46 @@ import com.google.api.client.testing.http.MockHttpTransport;
 import com.google.api.client.testing.http.MockLowLevelHttpRequest;
 import com.google.api.client.testing.http.MockLowLevelHttpResponse;
 import com.google.api.client.util.Key;
+import com.google.auth.Credentials;
+import com.google.auth.http.HttpCredentialsAdapter;
+import com.google.auth.oauth2.GoogleCredentials;
 import java.io.ByteArrayInputStream;
+import java.io.IOException;
 import java.io.InputStream;
 import java.util.ArrayList;
 import java.util.List;
 import junit.framework.TestCase;
+import org.junit.Test;
+import org.junit.function.ThrowingRunnable;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.junit.MockitoJUnitRunner;
 
 /**
  * Tests {@link AbstractGoogleClient}.
  *
  * @author Yaniv Inbar
  */
+@RunWith(MockitoJUnitRunner.class)
 public class AbstractGoogleClientTest extends TestCase {
 
+  @Mock private GoogleCredentials googleCredentials;
+
+  @Mock private HttpCredentialsAdapter httpCredentialsAdapter;
+
   private static final JsonFactory JSON_FACTORY = new GsonFactory();
   private static final JsonObjectParser JSON_OBJECT_PARSER = new JsonObjectParser(JSON_FACTORY);
   private static final HttpTransport TRANSPORT = new MockHttpTransport();
 
+  private static class TestHttpRequestInitializer implements HttpRequestInitializer {
+
+    @Override
+    public void initialize(HttpRequest httpRequest) {
+      // no-op
+    }
+  }
+
   private static class TestRemoteRequestInitializer implements GoogleClientRequestInitializer {
 
     boolean isCalled;
@@ -60,9 +85,10 @@ public class AbstractGoogleClientTest extends TestCase {
     }
   }
 
+  @Test
   public void testGoogleClientBuilder() {
-    String rootUrl = "http://www.testgoogleapis.com/test/";
-    String servicePath = "path/v1/";
+    String rootUrl = "https://test.googleapis.com/";
+    String servicePath = "test/path/v1/";
     GoogleClientRequestInitializer jsonHttpRequestInitializer = new TestRemoteRequestInitializer();
     String applicationName = "Test Application";
 
@@ -82,6 +108,142 @@ public class AbstractGoogleClientTest extends TestCase {
     assertTrue(client.getSuppressRequiredParameterChecks());
   }
 
+  @Test
+  public void testGoogleClientBuilder_setsCorrectRootUrl_nonMtlsUrl() {
+    String rootUrl = "https://test.googleapis.com/";
+    String applicationName = "Test Application";
+    String servicePath = "test/";
+
+    AbstractGoogleClient client =
+        new MockGoogleClient.Builder(TRANSPORT, rootUrl, servicePath, JSON_OBJECT_PARSER, null)
+            .setApplicationName(applicationName)
+            .build();
+    assertEquals(rootUrl, client.getRootUrl());
+    assertEquals(Credentials.GOOGLE_DEFAULT_UNIVERSE, client.getUniverseDomain());
+  }
+
+  @Test
+  public void testGoogleClientBuilder_setsCorrectRootUrl_mtlsUrl() {
+    String rootUrl = "https://test.mtls.googleapis.com/";
+    String applicationName = "Test Application";
+    String servicePath = "test/";
+
+    AbstractGoogleClient client =
+        new MockGoogleClient.Builder(TRANSPORT, rootUrl, servicePath, JSON_OBJECT_PARSER, null)
+            .setApplicationName(applicationName)
+            .build();
+    assertEquals(rootUrl, client.getRootUrl());
+    assertEquals(Credentials.GOOGLE_DEFAULT_UNIVERSE, client.getUniverseDomain());
+  }
+
+  @Test
+  public void testGoogleClientBuilder_customUniverseDomain_nonMtlsUrl() {
+    String rootUrl = "https://test.googleapis.com/";
+    String applicationName = "Test Application";
+    String servicePath = "test/";
+    String universeDomain = "random.com";
+
+    AbstractGoogleClient client =
+        new MockGoogleClient.Builder(TRANSPORT, rootUrl, servicePath, JSON_OBJECT_PARSER, null)
+            .setApplicationName(applicationName)
+            .setUniverseDomain(universeDomain)
+            .build();
+    assertEquals("https://test.random.com/", client.getRootUrl());
+    assertEquals(universeDomain, client.getUniverseDomain());
+  }
+
+  @Test
+  public void testGoogleClientBuilder_customUniverseDomain_mtlsUrl() {
+    String rootUrl = "https://test.mtls.googleapis.com/";
+    String applicationName = "Test Application";
+    String servicePath = "test/";
+    final AbstractGoogleClient.Builder builder =
+        new MockGoogleClient.Builder(TRANSPORT, rootUrl, servicePath, JSON_OBJECT_PARSER, null)
+            .setApplicationName(applicationName)
+            .setUniverseDomain("random.com");
+
+    IllegalStateException exception =
+        assertThrows(
+            IllegalStateException.class,
+            new ThrowingRunnable() {
+              @Override
+              public void run() {
+                builder.build();
+              }
+            });
+    assertEquals(
+        "mTLS is not supported in any universe other than googleapis.com", exception.getMessage());
+  }
+
+  @Test
+  public void testGoogleClientBuilder_customEndpoint_defaultUniverseDomain() {
+    String rootUrl = "https://test.googleapis.com/";
+    String applicationName = "Test Application";
+    String servicePath = "test/";
+
+    AbstractGoogleClient client =
+        new MockGoogleClient.Builder(TRANSPORT, rootUrl, servicePath, JSON_OBJECT_PARSER, null)
+            .setApplicationName(applicationName)
+            .setRootUrl("https://randomendpoint.com/")
+            .build();
+    assertEquals("https://randomendpoint.com/", client.getRootUrl());
+    assertEquals(Credentials.GOOGLE_DEFAULT_UNIVERSE, client.getUniverseDomain());
+  }
+
+  @Test
+  public void testGoogleClientBuilder_customEndpoint_customUniverseDomain() {
+    String rootUrl = "https://test.googleapis.com/";
+    String applicationName = "Test Application";
+    String servicePath = "test/";
+    String universeDomain = "random.com";
+    String customRootUrl = "https://randomendpoint.com/";
+
+    AbstractGoogleClient client =
+        new MockGoogleClient.Builder(TRANSPORT, rootUrl, servicePath, JSON_OBJECT_PARSER, null)
+            .setApplicationName(applicationName)
+            .setRootUrl(customRootUrl)
+            .setUniverseDomain(universeDomain)
+            .build();
+    assertEquals(customRootUrl, client.getRootUrl());
+    assertEquals(universeDomain, client.getUniverseDomain());
+  }
+
+  @Test
+  public void testGoogleClientBuilder_noCustomUniverseDomain_universeDomainEnvVar() {
+    String rootUrl = "https://test.googleapis.com/";
+    String applicationName = "Test Application";
+    String servicePath = "test/";
+    // Env Var Universe Domain is `random.com`
+    String envVarUniverseDomain = "random.com";
+    String expectedRootUrl = "https://test.random.com/";
+
+    AbstractGoogleClient client =
+        new MockGoogleClient.Builder(TRANSPORT, rootUrl, servicePath, JSON_OBJECT_PARSER, null)
+            .setApplicationName(applicationName)
+            .build();
+    assertEquals(expectedRootUrl, client.getRootUrl());
+    assertEquals(envVarUniverseDomain, client.getUniverseDomain());
+  }
+
+  @Test
+  public void testGoogleClientBuilder_customUniverseDomain_universeDomainEnvVar() {
+    String rootUrl = "https://test.googleapis.com/";
+    String applicationName = "Test Application";
+    String servicePath = "test/";
+    // Env Var Universe Domain is `random.com`
+    String customUniverseDomain = "test.com";
+    String expectedRootUrl = "https://test.test.com/";
+
+    AbstractGoogleClient client =
+        new MockGoogleClient.Builder(TRANSPORT, rootUrl, servicePath, JSON_OBJECT_PARSER, null)
+            .setApplicationName(applicationName)
+            .setUniverseDomain(customUniverseDomain)
+            .build();
+    assertEquals(expectedRootUrl, client.getRootUrl());
+    assertEquals(customUniverseDomain, client.getUniverseDomain());
+  }
+
+  @Test
   public void testGoogleClientSuppressionDefaults() {
     String rootUrl = "http://www.testgoogleapis.com/test/";
     String servicePath = "path/v1/";
@@ -97,6 +259,7 @@ public class AbstractGoogleClientTest extends TestCase {
     assertFalse(googleClient.getSuppressRequiredParameterChecks());
   }
 
+  @Test
   public void testBaseServerAndBasePathBuilder() {
     AbstractGoogleClient client =
         new MockGoogleClient.Builder(
@@ -113,6 +276,7 @@ public class AbstractGoogleClientTest extends TestCase {
     assertEquals("http://www.googleapis.com/test/path/v2/", client.getBaseUrl());
   }
 
+  @Test
   public void testInitialize() throws Exception {
     TestRemoteRequestInitializer remoteRequestInitializer = new TestRemoteRequestInitializer();
     AbstractGoogleClient client =
@@ -125,6 +289,138 @@ public class AbstractGoogleClientTest extends TestCase {
     assertTrue(remoteRequestInitializer.isCalled);
   }
 
+  @Test
+  public void testParseServiceName_nonMtlsRootUrl() {
+    AbstractGoogleClient.Builder clientBuilder =
+        new MockGoogleClient.Builder(
+                TRANSPORT, "https://random.googleapis.com/", "", JSON_OBJECT_PARSER, null)
+            .setApplicationName("Test Application");
+    assertEquals(clientBuilder.getServiceName(), "random");
+  }
+
+  @Test
+  public void testParseServiceName_mtlsRootUrl() {
+    AbstractGoogleClient.Builder clientBuilder =
+        new MockGoogleClient.Builder(
+                TRANSPORT, "https://test.mtls.googleapis.com/", "", JSON_OBJECT_PARSER, null)
+            .setApplicationName("Test Application");
+    assertEquals(clientBuilder.getServiceName(), "test");
+  }
+
+  @Test
+  public void testParseServiceName_nonGDURootUrl() {
+    AbstractGoogleClient.Builder clientBuilder =
+        new MockGoogleClient.Builder(
+                TRANSPORT, "https://test.random.com/", "", JSON_OBJECT_PARSER, null)
+            .setApplicationName("Test Application");
+    assertNull(clientBuilder.getServiceName());
+  }
+
+  @Test
+  public void testIsUserSetEndpoint_nonMtlsRootUrl() {
+    AbstractGoogleClient.Builder clientBuilder =
+        new MockGoogleClient.Builder(
+                TRANSPORT, "https://random.googleapis.com/", "", JSON_OBJECT_PARSER, null)
+            .setApplicationName("Test Application");
+    assertFalse(clientBuilder.isUserConfiguredEndpoint);
+  }
+
+  @Test
+  public void testIsUserSetEndpoint_mtlsRootUrl() {
+    AbstractGoogleClient.Builder clientBuilder =
+        new MockGoogleClient.Builder(
+                TRANSPORT, "https://test.mtls.googleapis.com/", "", JSON_OBJECT_PARSER, null)
+            .setApplicationName("Test Application");
+    assertFalse(clientBuilder.isUserConfiguredEndpoint);
+  }
+
+  @Test
+  public void testIsUserSetEndpoint_nonGDURootUrl() {
+    AbstractGoogleClient.Builder clientBuilder =
+        new MockGoogleClient.Builder(
+                TRANSPORT, "https://test.random.com/", "", JSON_OBJECT_PARSER, null)
+            .setApplicationName("Test Application");
+    assertTrue(clientBuilder.isUserConfiguredEndpoint);
+  }
+
+  @Test
+  public void testIsUserSetEndpoint_regionalEndpoint() {
+    AbstractGoogleClient.Builder clientBuilder =
+        new MockGoogleClient.Builder(
+                TRANSPORT,
+                "https://us-east-4.coolservice.googleapis.com/",
+                "",
+                JSON_OBJECT_PARSER,
+                null)
+            .setApplicationName("Test Application");
+    assertTrue(clientBuilder.isUserConfiguredEndpoint);
+  }
+
+  @Test
+  public void validateUniverseDomain_validUniverseDomain() throws IOException {
+    String rootUrl = "https://test.googleapis.com/";
+    String applicationName = "Test Application";
+    String servicePath = "test/";
+
+    Mockito.when(httpCredentialsAdapter.getCredentials()).thenReturn(googleCredentials);
+    Mockito.when(googleCredentials.getUniverseDomain())
+        .thenReturn(Credentials.GOOGLE_DEFAULT_UNIVERSE);
+
+    AbstractGoogleClient client =
+        new MockGoogleClient.Builder(
+                TRANSPORT, rootUrl, servicePath, JSON_OBJECT_PARSER, httpCredentialsAdapter)
+            .setApplicationName(applicationName)
+            .build();
+
+    // Nothing throws
+    client.validateUniverseDomain();
+  }
+
+  @Test
+  public void validateUniverseDomain_invalidUniverseDomain() throws IOException {
+    String rootUrl = "https://test.googleapis.com/";
+    String applicationName = "Test Application";
+    String servicePath = "test/";
+
+    Mockito.when(httpCredentialsAdapter.getCredentials()).thenReturn(googleCredentials);
+    Mockito.when(googleCredentials.getUniverseDomain()).thenReturn("invalid.universe.domain");
+
+    final AbstractGoogleClient client =
+        new MockGoogleClient.Builder(
+                TRANSPORT, rootUrl, servicePath, JSON_OBJECT_PARSER, httpCredentialsAdapter)
+            .setApplicationName(applicationName)
+            .build();
+    assertThrows(
+        IllegalStateException.class,
+        new ThrowingRunnable() {
+          @Override
+          public void run() throws IOException {
+            client.validateUniverseDomain();
+          }
+        });
+  }
+
+  @Test
+  public void validateUniverseDomain_notUsingHttpCredentialsAdapter_defaultUniverseDomain()
+      throws IOException {
+    String rootUrl = "https://test.googleapis.com/";
+    String applicationName = "Test Application";
+    String servicePath = "test/";
+
+    AbstractGoogleClient client =
+        new MockGoogleClient.Builder(
+                TRANSPORT,
+                rootUrl,
+                servicePath,
+                JSON_OBJECT_PARSER,
+                new TestHttpRequestInitializer())
+            .setApplicationName(applicationName)
+            .build();
+
+    // Nothing throws
+    client.validateUniverseDomain();
+  }
+
   private static final String TEST_RESUMABLE_REQUEST_URL =
       "http://www.test.com/request/url?uploadType=resumable";
   private static final String TEST_UPLOAD_URL = "http://www.test.com/media/upload/location";