Skip to content

Commit

Permalink
feat(3.x): add support for Firestore database id configuration (#2184)
Browse files Browse the repository at this point in the history
Also adds routing headers for all Firestore requests.

Backport of #2164.

Fixes #2145.
  • Loading branch information
meltsufin authored Sep 20, 2023
1 parent 200d4a0 commit 0c4ff8c
Show file tree
Hide file tree
Showing 8 changed files with 93 additions and 9 deletions.
8 changes: 8 additions & 0 deletions docs/src/main/asciidoc/datastore.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +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 <<spring-cloud-gcp-core,Spring Framework on Google Cloud Core Module>> | 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 <<spring-cloud-gcp-core,Spring Framework on Google Cloud Core Module>> | 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 <<spring-cloud-gcp-core,Spring Framework on Google Cloud Core Module>> | 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
Expand Down Expand Up @@ -854,6 +855,13 @@ This feature requires a bean of `DatastoreTransactionManager`, which is provided
If a method annotated with `@Transactional` calls another method also annotated, then both methods will work within the same transaction.
`performTransaction` cannot be used in `@Transactional` annotated methods because Cloud Datastore does not support transactions within transactions.

Other Google Cloud database-related integrations like Spanner and Firestore can introduce `PlatformTransactionManager` beans, and can interfere with Datastore Transaction Manager. To disambiguate, explicitly specify the name of the transaction manager bean for such `@Transactional` methods. Example:

[source,java]
----
@Transactional(transactionManager = "datastoreTransactionManager")
----

==== Read-Write Support for Maps

You can work with Maps of type `Map<String, ?>` instead of with entity objects by directly reading and writing them to and from Cloud Datastore.
Expand Down
1 change: 1 addition & 0 deletions docs/src/main/asciidoc/firestore.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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 <<spring-cloud-gcp-core,Spring Framework on Google Cloud Core Module>> | 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 <<spring-cloud-gcp-core,Spring Framework on Google Cloud Core Module>> | No |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.AutoConfigureAfter;
Expand All @@ -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;
Expand All @@ -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.
Expand All @@ -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())
Expand Down Expand Up @@ -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<String> 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();
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
*/
Expand All @@ -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;
}
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -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");
});
}

Expand All @@ -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
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit 0c4ff8c

Please sign in to comment.