From 586ac9f668e0ae647e3ef233458d2c754959f6e5 Mon Sep 17 00:00:00 2001 From: Lawrence Qiu Date: Thu, 4 Jan 2024 19:59:15 +0000 Subject: [PATCH] feat: Add Universe Domain to Java-Core (#2329) * feat: Add Java-Core Universe Domain changes * chore: Move validate universe domain logic to ServiceOptions * chore: Add javadocs * chore: Add tests * chore: Fix lint issues * chore: Add project id to tests * chore: Fix format issues * chore: Address PR comments * chore: Update Apiary to return rootHostUrl * chore: Use Google Auth Library v1.21.0 * chore: Add tests for normalizeEndpoint() * chore: Address PR comments * chore: Address PR comments * chore: Fix comments * chore: Address PR comments * chore: Address PR comments * chore: Add links * chore: Add format to match DEFAULT_HOST * chore: Fix failing tests * chore: Update javadocs * chore: Remove www. prefix --- .../java/com/google/cloud/ServiceOptions.java | 92 +++++++++- .../com/google/cloud/ServiceOptionsTest.java | 165 +++++++++++++++++- 2 files changed, 253 insertions(+), 4 deletions(-) diff --git a/java-core/google-cloud-core/src/main/java/com/google/cloud/ServiceOptions.java b/java-core/google-cloud-core/src/main/java/com/google/cloud/ServiceOptions.java index 231b9040c9..16879b8914 100644 --- a/java-core/google-cloud-core/src/main/java/com/google/cloud/ServiceOptions.java +++ b/java-core/google-cloud-core/src/main/java/com/google/cloud/ServiceOptions.java @@ -81,7 +81,6 @@ public abstract class ServiceOptions< implements Serializable { public static final String CREDENTIAL_ENV_NAME = "GOOGLE_APPLICATION_CREDENTIALS"; - private static final String DEFAULT_HOST = "https://www.googleapis.com"; private static final String LEGACY_PROJECT_ENV_NAME = "GCLOUD_PROJECT"; private static final String PROJECT_ENV_NAME = "GOOGLE_CLOUD_PROJECT"; @@ -95,6 +94,7 @@ public abstract class ServiceOptions< protected final String clientLibToken; private final String projectId; + private final String universeDomain; private final String host; private final RetrySettings retrySettings; private final String serviceRpcFactoryClassName; @@ -125,6 +125,7 @@ public abstract static class Builder< private final ImmutableSet allowedClientLibTokens = ImmutableSet.of(ServiceOptions.getGoogApiClientLibName()); private String projectId; + private String universeDomain; private String host; protected Credentials credentials; private RetrySettings retrySettings; @@ -142,6 +143,7 @@ protected Builder() {} @InternalApi("This class should only be extended within google-cloud-java") protected Builder(ServiceOptions options) { projectId = options.projectId; + universeDomain = options.universeDomain; host = options.host; credentials = options.credentials; retrySettings = options.retrySettings; @@ -199,6 +201,22 @@ public B setHost(String host) { return self(); } + /** + * Universe Domain is the domain for Google Cloud Services. A Google Cloud endpoint 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, the resolved UniverseDomain will default to `googleapis.com`. + * + * @throws NullPointerException if {@code universeDomain} is {@code null}. The resolved + * universeDomain will be `googleapis.com` if this value is not set. + */ + public B setUniverseDomain(String universeDomain) { + this.universeDomain = checkNotNull(universeDomain); + return self(); + } + /** * Sets the service authentication credentials. If no credentials are set, {@link * GoogleCredentials#getApplicationDefault()} will be used to attempt getting credentials from @@ -306,6 +324,7 @@ protected ServiceOptions( "A project ID is required for this service but could not be determined from the builder " + "or the environment. Please set a project ID using the builder."); } + universeDomain = builder.universeDomain; host = firstNonNull(builder.host, getDefaultHost()); credentials = builder.credentials != null ? builder.credentials : defaultCredentials(); retrySettings = firstNonNull(builder.retrySettings, getDefaultRetrySettings()); @@ -582,6 +601,19 @@ public String getProjectId() { return projectId; } + /** + * Universe Domain is the domain for Google Cloud Services. A Google Cloud endpoint 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`. + * + * @return The universe domain value set in the Builder's setter. This is not the resolved + * Universe Domain + */ + public String getUniverseDomain() { + return universeDomain; + } + /** Returns the service host. */ public String getHost() { return host; @@ -767,4 +799,62 @@ public String getClientLibToken() { public String getQuotaProjectId() { return quotaProjectId; } + + /** + * Returns the resolved host for the Service to connect to Google Cloud + * + *

The resolved host will be in `https://{serviceName}.{resolvedUniverseDomain}` format. The + * resolvedUniverseDomain will be set to `googleapis.com` if universeDomain is null. The format is + * similar to the DEFAULT_HOST value in java-core. + * + * @see DEFAULT_HOST + */ + @InternalApi + public String getResolvedHost(String serviceName) { + if (universeDomain != null && universeDomain.isEmpty()) { + throw new IllegalArgumentException("The universe domain cannot be empty"); + } + String resolvedUniverseDomain = + universeDomain != null ? universeDomain : Credentials.GOOGLE_DEFAULT_UNIVERSE; + // The host value set to DEFAULT_HOST if the user didn't configure a host. If the + // user set a host the library uses that value, otherwise, construct the host for the user. + // The DEFAULT_HOST value is not a valid host for handwritten libraries and should be + // overriden to include the serviceName. + if (!DEFAULT_HOST.equals(host)) { + return host; + } + return "https://" + serviceName + "." + resolvedUniverseDomain; + } + + /** + * Temporarily used for BigQuery and Storage Apiary Wrapped Libraries. To be removed in the future + * when Apiary clients can resolve their endpoints. Returns the host to be used as the rootUrl. + * + *

The resolved host will be in `https://{serviceName}.{resolvedUniverseDomain}/` format. The + * resolvedUniverseDomain will be set to `googleapis.com` if universeDomain is null. + * + * @see rootUrl + */ + @InternalApi + public String getResolvedApiaryHost(String serviceName) { + String resolvedUniverseDomain = + universeDomain != null ? universeDomain : Credentials.GOOGLE_DEFAULT_UNIVERSE; + return "https://" + serviceName + "." + resolvedUniverseDomain + "/"; + } + + /** + * Validates that Credentials' Universe Domain matches the resolved Universe Domain. Currently, + * this is only intended for BigQuery and Storage Apiary Wrapped Libraries. + * + *

This validation call should be made prior to any RPC invocation. This call is used to gate + * the RPC invocation if there is no valid universe domain. + */ + @InternalApi + public boolean hasValidUniverseDomain() throws IOException { + String resolvedUniverseDomain = + universeDomain != null ? universeDomain : Credentials.GOOGLE_DEFAULT_UNIVERSE; + return resolvedUniverseDomain.equals(getCredentials().getUniverseDomain()); + } } diff --git a/java-core/google-cloud-core/src/test/java/com/google/cloud/ServiceOptionsTest.java b/java-core/google-cloud-core/src/test/java/com/google/cloud/ServiceOptionsTest.java index bf75fca161..3d5ca3eef5 100644 --- a/java-core/google-cloud-core/src/test/java/com/google/cloud/ServiceOptionsTest.java +++ b/java-core/google-cloud-core/src/test/java/com/google/cloud/ServiceOptionsTest.java @@ -22,6 +22,7 @@ import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; @@ -55,6 +56,7 @@ public class ServiceOptionsTest { private static GoogleCredentials credentials; private static GoogleCredentials credentialsWithProjectId; private static GoogleCredentials credentialsWithQuotaProject; + private static GoogleCredentials credentialsNotInGDU; private static final String JSON_KEY = "{\n" @@ -81,7 +83,8 @@ public class ServiceOptionsTest { + "XyRDW4IG1Oa2p\\nrALStNBx5Y9t0/LQnFI4w3aG\\n-----END PRIVATE KEY-----\\n\",\n" + " \"client_email\": \"someclientid@developer.gserviceaccount.com\",\n" + " \"client_id\": \"someclientid.apps.googleusercontent.com\",\n" - + " \"type\": \"service_account\"\n" + + " \"type\": \"service_account\",\n" + + " \"universe_domain\": \"googleapis.com\"\n" + "}"; private static final String JSON_KEY_PROJECT_ID = @@ -110,7 +113,8 @@ public class ServiceOptionsTest { + " \"project_id\": \"someprojectid\",\n" + " \"client_email\": \"someclientid@developer.gserviceaccount.com\",\n" + " \"client_id\": \"someclientid.apps.googleusercontent.com\",\n" - + " \"type\": \"service_account\"\n" + + " \"type\": \"service_account\",\n" + + " \"universe_domain\": \"googleapis.com\"\n" + "}"; private static final String JSON_KEY_QUOTA_PROJECT_ID = @@ -140,13 +144,45 @@ public class ServiceOptionsTest { + " \"client_email\": \"someclientid@developer.gserviceaccount.com\",\n" + " \"client_id\": \"someclientid.apps.googleusercontent.com\",\n" + " \"type\": \"service_account\",\n" - + " \"quota_project_id\": \"some-quota-project-id\"\n" + + " \"quota_project_id\": \"some-quota-project-id\",\n" + + " \"universe_domain\": \"googleapis.com\"\n" + + "}"; + + // Key added by copying the keys above and adding in the universe domain field + private static final String JSON_KEY_NON_GDU = + "{\n" + + " \"private_key_id\": \"somekeyid\",\n" + + " \"private_key\": \"-----BEGIN PRIVATE KEY-----\\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggS" + + "kAgEAAoIBAQC+K2hSuFpAdrJI\\nnCgcDz2M7t7bjdlsadsasad+fvRSW6TjNQZ3p5LLQY1kSZRqBqylRkzteMOyHg" + + "aR\\n0Pmxh3ILCND5men43j3h4eDbrhQBuxfEMalkG92sL+PNQSETY2tnvXryOvmBRwa/\\nQP/9dJfIkIDJ9Fw9N4" + + "Bhhhp6mCcRpdQjV38H7JsyJ7lih/oNjECgYAt\\nknddadwkwewcVxHFhcZJO+XWf6ofLUXpRwiTZakGMn8EE1uVa2" + + "LgczOjwWHGi99MFjxSer5m9\\n1tCa3/KEGKiS/YL71JvjwX3mb+cewlkcmweBKZHM2JPTk0ZednFSpVZMtycjkbLa" + + "\\ndYOS8V85AgMBewECggEBAKksaldajfDZDV6nGqbFjMiizAKJolr/M3OQw16K6o3/\\n0S31xIe3sSlgW0+UbYlF" + + "4U8KifhManD1apVSC3csafaspP4RZUHFhtBywLO9pR5c\\nr6S5aLp+gPWFyIp1pfXbWGvc5VY/v9x7ya1VEa6rXvL" + + "sKupSeWAW4tMj3eo/64ge\\nsdaceaLYw52KeBYiT6+vpsnYrEkAHO1fF/LavbLLOFJmFTMxmsNaG0tuiJHgjshB\\" + + "n82DpMCbXG9YcCgI/DbzuIjsdj2JC1cascSP//3PmefWysucBQe7Jryb6NQtASmnv\\nCdDw/0jmZTEjpe4S1lxfHp" + + "lAhHFtdgYTvyYtaLZiVVkCgYEA8eVpof2rceecw/I6\\n5ng1q3Hl2usdWV/4mZMvR0fOemacLLfocX6IYxT1zA1FF" + + "JlbXSRsJMf/Qq39mOR2\\nSpW+hr4jCoHeRVYLgsbggtrevGmILAlNoqCMpGZ6vDmJpq6ECV9olliDvpPgWOP+\\nm" + + "YPDreFBGxWvQrADNbRt2dmGsrsCgYEAyUHqB2wvJHFqdmeBsaacewzV8x9WgmeX\\ngUIi9REwXlGDW0Mz50dxpxcK" + + "CAYn65+7TCnY5O/jmL0VRxU1J2mSWyWTo1C+17L0\\n3fUqjxL1pkefwecxwecvC+gFFYdJ4CQ/MHHXU81Lwl1iWdF" + + "Cd2UoGddYaOF+KNeM\\nHC7cmqra+JsCgYEAlUNywzq8nUg7282E+uICfCB0LfwejuymR93CtsFgb7cRd6ak\\nECR" + + "8FGfCpH8ruWJINllbQfcHVCX47ndLZwqv3oVFKh6pAS/vVI4dpOepP8++7y1u\\ncoOvtreXCX6XqfrWDtKIvv0vjl" + + "HBhhhp6mCcRpdQjV38H7JsyJ7lih/oNjECgYAt\\nkndj5uNl5SiuVxHFhcZJO+XWf6ofLUregtevZakGMn8EE1uVa" + + "2AY7eafmoU/nZPT\\n00YB0TBATdCbn/nBSuKDESkhSg9s2GEKQZG5hBmL5uCMfo09z3SfxZIhJdlerreP\\nJ7gSi" + + "dI12N+EZxYd4xIJh/HFDgp7RRO87f+WJkofMQKBgGTnClK1VMaCRbJZPriw\\nEfeFCoOX75MxKwXs6xgrw4W//AYG" + + "GUjDt83lD6AZP6tws7gJ2IwY/qP7+lyhjEqN\\nHtfPZRGFkGZsdaksdlaksd323423d+15/UvrlRSFPNj1tWQmNKk" + + "XyRDW4IG1Oa2p\\nrALStNBx5Y9t0/LQnFI4w3aG\\n-----END PRIVATE KEY-----\\n\",\n" + + " \"client_email\": \"someclientid@developer.gserviceaccount.com\",\n" + + " \"client_id\": \"someclientid.apps.googleusercontent.com\",\n" + + " \"type\": \"service_account\",\n" + + " \"universe_domain\": \"random.com\"\n" + "}"; static { credentials = loadCredentials(JSON_KEY); credentialsWithProjectId = loadCredentials(JSON_KEY_PROJECT_ID); credentialsWithQuotaProject = loadCredentials(JSON_KEY_QUOTA_PROJECT_ID); + credentialsNotInGDU = loadCredentials(JSON_KEY_NON_GDU); } static GoogleCredentials loadCredentials(String credentialFile) { @@ -471,6 +507,129 @@ public void testResponseHeaderDoesNotContainMetaDataFlavor() throws Exception { assertThat(ServiceOptions.headerContainsMetadataFlavor(httpResponse)).isFalse(); } + @Test + public void testGetResolvedEndpoint_noUniverseDomain() { + TestServiceOptions options = TestServiceOptions.newBuilder().setProjectId("project-id").build(); + assertThat(options.getResolvedHost("service")).isEqualTo("https://service.googleapis.com"); + } + + @Test + public void testGetResolvedEndpoint_emptyUniverseDomain() { + TestServiceOptions options = + TestServiceOptions.newBuilder().setUniverseDomain("").setProjectId("project-id").build(); + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> options.getResolvedHost("service")); + assertThat(exception.getMessage()).isEqualTo("The universe domain cannot be empty"); + } + + @Test + public void testGetResolvedEndpoint_customUniverseDomain() { + TestServiceOptions options = + TestServiceOptions.newBuilder() + .setUniverseDomain("test.com") + .setProjectId("project-id") + .build(); + assertThat(options.getResolvedHost("service")).isEqualTo("https://service.test.com"); + } + + @Test + public void testGetResolvedEndpoint_customUniverseDomain_customHost() { + TestServiceOptions options = + TestServiceOptions.newBuilder() + .setUniverseDomain("test.com") + .setHost("https://service.random.com/") + .setProjectId("project-id") + .build(); + assertThat(options.getResolvedHost("service")).isEqualTo("https://service.random.com/"); + } + + @Test + public void testGetResolvedApiaryHost_noUniverseDomain() { + TestServiceOptions options = TestServiceOptions.newBuilder().setProjectId("project-id").build(); + assertThat(options.getResolvedApiaryHost("service")) + .isEqualTo("https://service.googleapis.com/"); + } + + @Test + public void testGetResolvedApiaryHost_customUniverseDomain_noHost() { + TestServiceOptions options = + TestServiceOptions.newBuilder() + .setUniverseDomain("test.com") + .setHost(null) + .setProjectId("project-id") + .build(); + assertThat(options.getResolvedApiaryHost("service")).isEqualTo("https://service.test.com/"); + } + + @Test + public void testGetResolvedApiaryHost_customUniverseDomain_customHost() { + TestServiceOptions options = + TestServiceOptions.newBuilder() + .setUniverseDomain("test.com") + .setHost("https://service.random.com") + .setProjectId("project-id") + .build(); + assertThat(options.getResolvedApiaryHost("service")).isEqualTo("https://service.test.com/"); + } + + // No User Configuration = GDU, Default Credentials = GDU + @Test + public void testIsValidUniverseDomain_noUserUniverseDomainConfig_defaultCredentials() + throws IOException { + TestServiceOptions options = + TestServiceOptions.newBuilder() + .setProjectId("project-id") + .setHost("https://test.random.com") + .setCredentials(credentials) + .build(); + assertThat(options.hasValidUniverseDomain()).isTrue(); + } + + // No User Configuration = GDU, non Default Credentials = random.com + // non-GDU Credentials could be any domain, the tests use random.com + @Test + public void testIsValidUniverseDomain_noUserUniverseDomainConfig_nonGDUCredentials() + throws IOException { + TestServiceOptions options = + TestServiceOptions.newBuilder() + .setProjectId("project-id") + .setHost("https://test.random.com") + .setCredentials(credentialsNotInGDU) + .build(); + assertThat(options.hasValidUniverseDomain()).isFalse(); + } + + // User Configuration = random.com, Default Credentials = GDU + // User Credentials could be set to any domain, the tests use random.com + @Test + public void testIsValidUniverseDomain_userUniverseDomainConfig_defaultCredentials() + throws IOException { + TestServiceOptions options = + TestServiceOptions.newBuilder() + .setProjectId("project-id") + .setHost("https://test.random.com") + .setUniverseDomain("random.com") + .setCredentials(credentials) + .build(); + assertThat(options.hasValidUniverseDomain()).isFalse(); + } + + // User Configuration = random.com, non Default Credentials = random.com + // User Credentials and non GDU Credentials could be set to any domain, + // the tests use random.com + @Test + public void testIsValidUniverseDomain_userUniverseDomainConfig_nonGDUCredentials() + throws IOException { + TestServiceOptions options = + TestServiceOptions.newBuilder() + .setProjectId("project-id") + .setHost("https://test.random.com") + .setUniverseDomain("random.com") + .setCredentials(credentialsNotInGDU) + .build(); + assertThat(options.hasValidUniverseDomain()).isTrue(); + } + private HttpResponse createHttpResponseWithHeader(final Multimap headers) throws Exception { HttpTransport mockHttpTransport =