diff --git a/docs/src/main/asciidoc/datastore.adoc b/docs/src/main/asciidoc/datastore.adoc index db30a04b73..e7c7b78649 100644 --- a/docs/src/main/asciidoc/datastore.adoc +++ b/docs/src/main/asciidoc/datastore.adoc @@ -68,7 +68,7 @@ The following configuration options are available: | Name | Description | Required | Default value | `spring.cloud.gcp.datastore.enabled` | Enables the Cloud Datastore client | No | `true` | `spring.cloud.gcp.datastore.project-id` | Google Cloud project ID where the Google Cloud Datastore API is hosted, if different from the one in the <> | No | -| `spring.cloud.gcp.datastore.database-id` | Google Cloud project can host multiple databases. You can specify which database will be used. | No | +| `spring.cloud.gcp.datastore.database-id` | Google Cloud project can host multiple databases. You can specify which database will be used. If not specified, the database id will be "(default)". | No | | `spring.cloud.gcp.datastore.credentials.location` | OAuth2 credentials for authenticating with the Google Cloud Datastore API, if different from the ones in the <> | No | | `spring.cloud.gcp.datastore.credentials.encoded-key` | Base64-encoded OAuth2 credentials for authenticating with the Google Cloud Datastore API, if different from the ones in the <> | No | | `spring.cloud.gcp.datastore.credentials.scopes` | https://developers.google.com/identity/protocols/googlescopes[OAuth2 scope] for Spring Framework on Google CloudDatastore credentials | No | https://www.googleapis.com/auth/datastore diff --git a/docs/src/main/asciidoc/firestore.adoc b/docs/src/main/asciidoc/firestore.adoc index 5e5e7798ea..9c6644fae7 100644 --- a/docs/src/main/asciidoc/firestore.adoc +++ b/docs/src/main/asciidoc/firestore.adoc @@ -54,6 +54,7 @@ The Spring Boot starter for Google Cloud Firestore provides the following config | Name | Description | Required | Default value | `spring.cloud.gcp.firestore.enabled` | Enables or disables Firestore auto-configuration | No | `true` | `spring.cloud.gcp.firestore.project-id` | Google Cloud project ID where the Google Cloud Firestore API is hosted, if different from the one in the <> | No | +| `spring.cloud.gcp.firestore.database-id` | Google Cloud project can host multiple databases. You can specify which database will be used. If not specified, the database id will be "(default)". | No | | `spring.cloud.gcp.firestore.emulator.enabled` | Enables the usage of an emulator. If this is set to true, then you should set the `spring.cloud.gcp.firestore.host-port` to the host:port of your locally running emulator instance | No | `false` | `spring.cloud.gcp.firestore.host-port` | The host and port of the Firestore service; can be overridden to specify connecting to an already-running https://firebase.google.com/docs/emulator-suite/install_and_configure[Firestore emulator] instance. | No | `firestore.googleapis.com:443` (the host/port of official Firestore service) | `spring.cloud.gcp.firestore.credentials.location` | OAuth2 credentials for authenticating with the Google Cloud Firestore API, if different from the ones in the <> | No | diff --git a/spring-cloud-gcp-autoconfigure/src/main/java/com/google/cloud/spring/autoconfigure/firestore/GcpFirestoreAutoConfiguration.java b/spring-cloud-gcp-autoconfigure/src/main/java/com/google/cloud/spring/autoconfigure/firestore/GcpFirestoreAutoConfiguration.java index bfbc1a230e..153e112b0c 100644 --- a/spring-cloud-gcp-autoconfigure/src/main/java/com/google/cloud/spring/autoconfigure/firestore/GcpFirestoreAutoConfiguration.java +++ b/spring-cloud-gcp-autoconfigure/src/main/java/com/google/cloud/spring/autoconfigure/firestore/GcpFirestoreAutoConfiguration.java @@ -16,9 +16,11 @@ package com.google.cloud.spring.autoconfigure.firestore; +import com.google.api.client.util.escape.PercentEscaper; import com.google.api.gax.core.CredentialsProvider; import com.google.api.gax.core.NoCredentialsProvider; import com.google.api.gax.grpc.InstantiatingGrpcChannelProvider; +import com.google.api.gax.rpc.internal.Headers; import com.google.cloud.firestore.Firestore; import com.google.cloud.firestore.FirestoreOptions; import com.google.cloud.spring.autoconfigure.core.GcpContextAutoConfiguration; @@ -30,9 +32,12 @@ import com.google.cloud.spring.data.firestore.mapping.FirestoreDefaultClassMapper; import com.google.cloud.spring.data.firestore.mapping.FirestoreMappingContext; import com.google.firestore.v1.FirestoreGrpc; +import io.grpc.ClientInterceptor; import io.grpc.ManagedChannel; import io.grpc.ManagedChannelBuilder; +import io.grpc.Metadata; import io.grpc.auth.MoreCallCredentials; +import io.grpc.stub.MetadataUtils; import java.io.IOException; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.autoconfigure.AutoConfiguration; @@ -59,8 +64,12 @@ public class GcpFirestoreAutoConfiguration { private static final UserAgentHeaderProvider USER_AGENT_HEADER_PROVIDER = new UserAgentHeaderProvider(GcpFirestoreAutoConfiguration.class); + private static final PercentEscaper PERCENT_ESCAPER = new PercentEscaper("._-~"); + private final String projectId; + private final String databaseId; + private final CredentialsProvider credentialsProvider; private final String hostPort; @@ -74,6 +83,7 @@ public class GcpFirestoreAutoConfiguration { throws IOException { this.projectId = gcpFirestoreProperties.getResolvedProjectId(projectIdProvider); + this.databaseId = gcpFirestoreProperties.getResolvedDatabaseId(); if (gcpFirestoreProperties.getEmulator().isEnabled()) { // if the emulator is enabled, create CredentialsProvider for this particular case. @@ -95,6 +105,7 @@ public FirestoreOptions firestoreOptions() { return FirestoreOptions.getDefaultInstance().toBuilder() .setCredentialsProvider(this.credentialsProvider) .setProjectId(this.projectId) + .setDatabaseId(databaseId) .setHeaderProvider(USER_AGENT_HEADER_PROVIDER) .setChannelProvider( InstantiatingGrpcChannelProvider.newBuilder().setEndpoint(this.hostPort).build()) @@ -150,12 +161,27 @@ public FirestoreTemplate firestoreTemplate( firestoreMappingContext); } + @Bean + @ConditionalOnMissingBean(name = "firestoreRoutingHeadersInterceptor") + public ClientInterceptor firestoreRoutingHeadersInterceptor() { + // add routing header for custom database id + Metadata routingHeader = new Metadata(); + Metadata.Key key = + Metadata.Key.of(Headers.DYNAMIC_ROUTING_HEADER_KEY, Metadata.ASCII_STRING_MARSHALLER); + routingHeader.put(key, + "project_id=" + PERCENT_ESCAPER.escape(projectId) + + "&database_id=" + PERCENT_ESCAPER.escape(databaseId)); + return MetadataUtils.newAttachHeadersInterceptor(routingHeader); + } + @Bean @ConditionalOnMissingBean(name = "firestoreManagedChannel") - public ManagedChannel firestoreManagedChannel() { + public ManagedChannel firestoreManagedChannel( + ClientInterceptor firestoreRoutingHeadersInterceptor) { return ManagedChannelBuilder.forTarget( "dns:///" + GcpFirestoreAutoConfiguration.this.hostPort) .userAgent(USER_AGENT_HEADER_PROVIDER.getUserAgent()) + .intercept(firestoreRoutingHeadersInterceptor) .build(); } } diff --git a/spring-cloud-gcp-autoconfigure/src/main/java/com/google/cloud/spring/autoconfigure/firestore/GcpFirestoreEmulatorAutoConfiguration.java b/spring-cloud-gcp-autoconfigure/src/main/java/com/google/cloud/spring/autoconfigure/firestore/GcpFirestoreEmulatorAutoConfiguration.java index 9d8cb7c23e..da6d8eb4ae 100644 --- a/spring-cloud-gcp-autoconfigure/src/main/java/com/google/cloud/spring/autoconfigure/firestore/GcpFirestoreEmulatorAutoConfiguration.java +++ b/spring-cloud-gcp-autoconfigure/src/main/java/com/google/cloud/spring/autoconfigure/firestore/GcpFirestoreEmulatorAutoConfiguration.java @@ -59,7 +59,7 @@ public class GcpFirestoreEmulatorAutoConfiguration { GcpFirestoreEmulatorAutoConfiguration(GcpFirestoreProperties properties) { this.hostPort = properties.getHostPort(); this.projectId = StringUtils.defaultIfEmpty(properties.getProjectId(), "unused"); - this.rootPath = String.format("projects/%s/databases/(default)", this.projectId); + this.rootPath = properties.getFirestoreRootPath(() -> projectId); } @Bean diff --git a/spring-cloud-gcp-autoconfigure/src/main/java/com/google/cloud/spring/autoconfigure/firestore/GcpFirestoreProperties.java b/spring-cloud-gcp-autoconfigure/src/main/java/com/google/cloud/spring/autoconfigure/firestore/GcpFirestoreProperties.java index ac11a53abb..ff1234a123 100644 --- a/spring-cloud-gcp-autoconfigure/src/main/java/com/google/cloud/spring/autoconfigure/firestore/GcpFirestoreProperties.java +++ b/spring-cloud-gcp-autoconfigure/src/main/java/com/google/cloud/spring/autoconfigure/firestore/GcpFirestoreProperties.java @@ -30,7 +30,7 @@ */ @ConfigurationProperties("spring.cloud.gcp.firestore") public class GcpFirestoreProperties implements CredentialsSupplier { - private static final String ROOT_PATH_FORMAT = "projects/%s/databases/(default)/documents"; + private static final String ROOT_PATH_FORMAT = "projects/%s/databases/%s/documents"; /** * Overrides the GCP OAuth2 credentials specified in the Core module. Uses same URL as Datastore @@ -40,6 +40,8 @@ public class GcpFirestoreProperties implements CredentialsSupplier { private String projectId; + private String databaseId; + /** * The host and port of the Firestore emulator service; can be overridden to specify an emulator. */ @@ -65,6 +67,18 @@ public void setProjectId(String projectId) { this.projectId = projectId; } + public String getDatabaseId() { + return databaseId; + } + + public String getResolvedDatabaseId() { + return this.getDatabaseId() == null ? "(default)" : this.getDatabaseId(); + } + + public void setDatabaseId(String databaseId) { + this.databaseId = databaseId; + } + public String getHostPort() { return hostPort; } @@ -82,7 +96,8 @@ public void setEmulator(FirestoreEmulatorProperties emulator) { } public String getFirestoreRootPath(GcpProjectIdProvider projectIdProvider) { - return String.format(ROOT_PATH_FORMAT, this.getResolvedProjectId(projectIdProvider)); + return String.format(ROOT_PATH_FORMAT, this.getResolvedProjectId(projectIdProvider), + this.getResolvedDatabaseId()); } public static class FirestoreEmulatorProperties { diff --git a/spring-cloud-gcp-autoconfigure/src/test/java/com/google/cloud/spring/autoconfigure/firestore/GcpFirestoreAutoConfigurationTests.java b/spring-cloud-gcp-autoconfigure/src/test/java/com/google/cloud/spring/autoconfigure/firestore/GcpFirestoreAutoConfigurationTests.java index 5e1c601359..a86a424eed 100644 --- a/spring-cloud-gcp-autoconfigure/src/test/java/com/google/cloud/spring/autoconfigure/firestore/GcpFirestoreAutoConfigurationTests.java +++ b/spring-cloud-gcp-autoconfigure/src/test/java/com/google/cloud/spring/autoconfigure/firestore/GcpFirestoreAutoConfigurationTests.java @@ -26,14 +26,19 @@ import com.google.cloud.firestore.FirestoreOptions; import com.google.cloud.spring.autoconfigure.core.GcpContextAutoConfiguration; import com.google.firestore.v1.FirestoreGrpc; +import io.grpc.ClientInterceptor; import io.grpc.ManagedChannel; +import io.grpc.Metadata; +import io.grpc.Metadata.Key; import org.junit.jupiter.api.Test; import org.mockito.Mockito; import org.springframework.beans.factory.NoSuchBeanDefinitionException; import org.springframework.boot.autoconfigure.AutoConfigurationPackage; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; +import org.springframework.test.util.ReflectionTestUtils; import org.springframework.transaction.TransactionManager; /** @@ -55,11 +60,14 @@ class GcpFirestoreAutoConfigurationTests { .withPropertyValues("spring.cloud.gcp.firestore.project-id=test-project"); @Test - void testDatastoreOptionsCorrectlySet() { + void testFirestoreOptionsCorrectlySet() { this.contextRunner.run( context -> { FirestoreOptions datastoreOptions = context.getBean(Firestore.class).getOptions(); assertThat(datastoreOptions.getProjectId()).isEqualTo("test-project"); + assertThat(datastoreOptions.getDatabaseId()).isEqualTo("(default)"); + assertThat(getRoutingHeader(context)) + .isEqualTo("project_id=test-project&database_id=%28default%29"); }); } @@ -78,12 +86,21 @@ void testCorrectManagedChannel() { .run( ctx -> { FirestoreGrpc.FirestoreStub stub = - (FirestoreGrpc.FirestoreStub) ctx.getBean("firestoreGrpcStub"); + ctx.getBean("firestoreGrpcStub", FirestoreGrpc.FirestoreStub.class); ManagedChannel channel = (ManagedChannel) stub.getChannel(); assertThat(channel.authority()).isEqualTo("firestore.googleapis.com:443"); + assertThat(getRoutingHeader(ctx)).isEqualTo("project_id=test-project&database_id=%28default%29"); }); } + private static String getRoutingHeader(ApplicationContext ctx) { + ClientInterceptor clientInterceptor = + ctx.getBean("firestoreRoutingHeadersInterceptor", ClientInterceptor.class); + Metadata extraHeaders = (Metadata) ReflectionTestUtils.getField(clientInterceptor, + "extraHeaders"); + return extraHeaders.get(Key.of("x-goog-request-params", Metadata.ASCII_STRING_MARSHALLER)); + } + @Test void testTransactionManagerExcludedWithoutAutoConfiguration() { contextRunner @@ -95,6 +112,24 @@ void testTransactionManagerExcludedWithoutAutoConfiguration() { }); } + @Test + void testDatabaseIdOverride() { + contextRunner + .withPropertyValues("spring.cloud.gcp.firestore.database-id=mydb^2", + "spring.cloud.gcp.firestore.project-id=test") + .run( + context -> { + FirestoreOptions datastoreOptions = context.getBean(Firestore.class).getOptions(); + assertThat(datastoreOptions.getDatabaseId()).isEqualTo("mydb^2"); + String rootPath = context.getBean(GcpFirestoreProperties.class) + .getFirestoreRootPath(() -> "xyz-ignored"); + assertThat(rootPath) + .isEqualTo("projects/test/databases/mydb^2/documents"); + assertThat(getRoutingHeader(context)).isEqualTo( + "project_id=test&database_id=mydb%5E2"); + }); + } + /** Spring Boot config for tests. */ @AutoConfigurationPackage static class TestConfiguration { diff --git a/spring-cloud-gcp-data-firestore/src/test/java/com/google/cloud/spring/data/firestore/repository/query/FirestoreRepositoryTests.java b/spring-cloud-gcp-data-firestore/src/test/java/com/google/cloud/spring/data/firestore/repository/query/FirestoreRepositoryTests.java index faf56ce9ac..85a47f2fd0 100644 --- a/spring-cloud-gcp-data-firestore/src/test/java/com/google/cloud/spring/data/firestore/repository/query/FirestoreRepositoryTests.java +++ b/spring-cloud-gcp-data-firestore/src/test/java/com/google/cloud/spring/data/firestore/repository/query/FirestoreRepositoryTests.java @@ -113,8 +113,6 @@ void testSortQuery_methodName_sortByDocumentId() { @Configuration @EnableReactiveFirestoreRepositories(basePackageClasses = UserRepository.class) static class FirestoreRepositoryTestsConfiguration { - private static final String DEFAULT_PARENT = - "projects/my-project/databases/(default)/documents"; @Bean public FirestoreMappingContext firestoreMappingContext() { diff --git a/spring-cloud-gcp-samples/spring-cloud-gcp-data-firestore-sample/src/test/resources/application-test.properties b/spring-cloud-gcp-samples/spring-cloud-gcp-data-firestore-sample/src/test/resources/application-test.properties index 32c8585e05..21dd41714f 100644 --- a/spring-cloud-gcp-samples/spring-cloud-gcp-data-firestore-sample/src/test/resources/application-test.properties +++ b/spring-cloud-gcp-samples/spring-cloud-gcp-data-firestore-sample/src/test/resources/application-test.properties @@ -1 +1,2 @@ -spring.cloud.gcp.firestore.project-id=spring-cloud-gcp-ci-firestore +spring.cloud.gcp.firestore.project-id=spring-cloud-gcp-ci +spring.cloud.gcp.firestore.database-id=firestoredb \ No newline at end of file