using CloudNative.CloudEvents;
using Google.Cloud.Firestore;
using Google.Cloud.Functions.Framework;
using Google.Cloud.Functions.Hosting;
using Google.Events.Protobuf.Cloud.Firestore.V1;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace FirestoreReactive
{
public class Startup : FunctionsStartup
{
public override void ConfigureServices(WebHostBuilderContext context, IServiceCollection services) =>
services.AddSingleton(FirestoreDb.Create());
}
// Register the startup class to provide the Firestore dependency.
[FunctionsStartup(typeof(Startup))]
public class Function : ICloudEventFunction<DocumentEventData>
{
private readonly ILogger _logger;
private readonly FirestoreDb _firestoreDb;
public Function(ILogger<Function> logger, FirestoreDb firestoreDb) =>
(_logger, _firestoreDb) = (logger, firestoreDb);
public async Task HandleAsync(CloudEvent cloudEvent, DocumentEventData data, CancellationToken cancellationToken)
{
// Get the recently-written value. This expression will result in a null value
// if any of the following is true:
// - The event doesn't contain a "new" document
// - The value doesn't contain a field called "original"
// - The "original" field isn't a string
string currentValue = data.Value?.ConvertFields().GetValueOrDefault("original") as string;
if (currentValue is null)
{
_logger.LogWarning($"Event did not contain a suitable document");
return;
}
string newValue = currentValue.ToUpperInvariant();
if (newValue == currentValue)
{
_logger.LogInformation("Value is already upper-cased; no replacement necessary");
return;
}
// The CloudEvent subject is "documents/x/y/...".
// The Firestore SDK FirestoreDb.Document method expects a reference relative to
// "documents" (so just the "x/y/..." part). This may be simplified over time.
if (cloudEvent.Subject is null || !cloudEvent.Subject.StartsWith("documents/"))
{
_logger.LogWarning("CloudEvent subject is not a document reference.");
return;
}
string documentPath = cloudEvent.Subject.Substring("documents/".Length);
_logger.LogInformation("Replacing '{current}' with '{new}' in '{path}'", currentValue, newValue, documentPath);
await _firestoreDb.Document(documentPath).UpdateAsync("original", newValue);
}
}
}
// Package upper contains a Firestore Cloud Function.
package upper
import (
"context"
"fmt"
"log"
"os"
"strings"
"time"
"cloud.google.com/go/firestore"
firebase "firebase.google.com/go/v4"
)
// FirestoreEvent is the payload of a Firestore event.
type FirestoreEvent struct {
OldValue FirestoreValue `json:"oldValue"`
Value FirestoreValue `json:"value"`
UpdateMask struct {
FieldPaths []string `json:"fieldPaths"`
} `json:"updateMask"`
}
// FirestoreValue holds Firestore fields.
type FirestoreValue struct {
CreateTime time.Time `json:"createTime"`
// Fields is the data for this value. The type depends on the format of your
// database. Log an interface{} value and inspect the result to see a JSON
// representation of your database fields.
Fields MyData `json:"fields"`
Name string `json:"name"`
UpdateTime time.Time `json:"updateTime"`
}
// MyData represents a value from Firestore. The type definition depends on the
// format of your database.
type MyData struct {
Original struct {
StringValue string `json:"stringValue"`
} `json:"original"`
}
// GOOGLE_CLOUD_PROJECT is automatically set by the Cloud Functions runtime.
var projectID = os.Getenv("GOOGLE_CLOUD_PROJECT")
// client is a Firestore client, reused between function invocations.
var client *firestore.Client
func init() {
// Use the application default credentials.
conf := &firebase.Config{ProjectID: projectID}
// Use context.Background() because the app/client should persist across
// invocations.
ctx := context.Background()
app, err := firebase.NewApp(ctx, conf)
if err != nil {
log.Fatalf("firebase.NewApp: %v", err)
}
client, err = app.Firestore(ctx)
if err != nil {
log.Fatalf("app.Firestore: %v", err)
}
}
// MakeUpperCase is triggered by a change to a Firestore document. It updates
// the `original` value of the document to upper case.
func MakeUpperCase(ctx context.Context, e FirestoreEvent) error {
fullPath := strings.Split(e.Value.Name, "/documents/")[1]
pathParts := strings.Split(fullPath, "/")
collection := pathParts[0]
doc := strings.Join(pathParts[1:], "/")
curValue := e.Value.Fields.Original.StringValue
newValue := strings.ToUpper(curValue)
if curValue == newValue {
log.Printf("%q is already upper case: skipping", curValue)
return nil
}
log.Printf("Replacing value: %q -> %q", curValue, newValue)
data := map[string]string{"original": newValue}
_, err := client.Collection(collection).Doc(doc).Set(ctx, data)
if err != nil {
return fmt.Errorf("Set: %v", err)
}
return nil
}
require "functions_framework"
FunctionsFramework.on_startup do
# Lazily construct a Firestore client when needed, and reuse it on
# subsequent calls.
set_global :firestore_client do
require "google/cloud/firestore"
Google::Cloud::Firestore.new project_id: ENV["GOOGLE_CLOUD_PROJECT"]
end
end
# Converts strings added to /messages/{pushId}/original to uppercase
FunctionsFramework.cloud_event "make_upper_case" do |event|
# Event-triggered Ruby functions receive a CloudEvents::Event::V1 object.
# See https://cloudevents.github.io/sdk-ruby/latest/CloudEvents/Event/V1.html
# The Firebase event payload can be obtained from the event data.
cur_value = event.data["value"]["fields"]["original"]["stringValue"]
# Compute new value and determine whether it needs to be modified.
# If the value is already upper-case, don't perform another write,
# to avoid infinite loops.
new_value = cur_value.upcase
if cur_value == new_value
logger.info "Value is already upper-case"
return
end
# Use the Firestore client library to update the value.
# The document name can be obtained from the event subject.
logger.info "Replacing value: #{cur_value} --> #{new_value}"
doc_name = event.subject.split("documents/").last
affected_doc = global(:firestore_client).doc doc_name
new_doc_data = { original: new_value }
affected_doc.set new_doc_data
end