Using GCP Cloud Functions with F#

I've recently been writing a new feature in one of our projects and I thought I'd take advantage of the new .NET support in GCP Cloud Functions. As the project I work on is fully F# based my cloud function was going to be written in F#. There is a great in depth introduction to .NET support in GCP by Jon Skeet that can be found here.

I thought it might be useful to write a short blog post on how you might write a simple pub/sub cloud function in F#. I will assume you have read the earlier blog post and instead of creating a C# project you have created a F# project.

Our app is going to subscribe to a topic we have already deployed, deserialize the message and send an email to someone. We will need to use dependency injection to wire up our email client and to autmotically allow us to write some logs and use the framework's configuration.

If you are used to ASP.NET Core none of this will be a shock as it's very similar. First thing we will want to do is create our own Startup type:

type Startup() =
    inherit FunctionsStartup()

    override this.ConfigureServices(context: WebHostBuilderContext, services: IServiceCollection) =
        match context.Configuration.ApiPublicKey, context.Configuration.ApiPrivateKey with
        | Some apiKey, Some apiSecret ->

            services.AddSingleton<IEmailClient>(fun _ ->
                EmailService.EmailClient(apiKey, apiSecret) :> IEmailClient)
            |> ignore
        | _ -> failwith "The PUBLIC_KEY and PRIVATE_KEY environment variables are required."

So here we are creating our type that inheits from the GCP FunctionsStartup type and then overrides the ConfigureServices function. We then check our configuration object has some keys set and if so register our IEmailClient object otherwise we throw an exception.

The nice thing with F# is that you can easily extend defined interfaces so you may spotted the match statement using properties called ApiPublicKey/ApiPrivateKey. This comes from extending Microsoft's IConfiguration like so:

module ConfigExtensions =
    open System
    open Microsoft.Extensions.Configuration

    let nonEmptyStringOption value =
        value
        |> Option.ofObj
        |> Option.filter (String.IsNullOrWhiteSpace >> not)

    type IConfiguration with

        member this.ApiPublicKey =
            this.GetValue<string> "API_PUBLIC_KEY"
            |> nonEmptyStringOption

        member this.ApiPrivateKey =
            this.GetValue<string> "API_PRIVATE_KEY"
            |> nonEmptyStringOption

Next up we want to implement our cloud function:

[<FunctionsStartup(typeof<Startup>)>]
type Function(logger: ILogger<Function>, emailClient: IEmailClient) =
    interface ICloudEventFunction<MessagePublishedData> with

      member this.HandleAsync(cloudEvent, data, cancellationToken) = task {

        let myMessage = JsonEncoding.fromJson<Message>(data.Message.TextData)
      logger.LogInformation("Sending email to {person}", myMessage.To)
        return! emailClient.Send message.To "Success!"
        }

Excuse the brevity of the function but I just wanted to illustrate the main elements in play here. You'll see that we have to apply an attribute to the function so that it knows which Startup type to use so it knows how to register dependencies for example. You'll also see our function has a ILogger injected, this comes from the library itself and the wiring up it does under the hood and then it takes in IEmailClient that we registered in our Startup class.

Our type then has to implement an interface from the GCP nugget packages we installed and call a HandleAsync function. To get an object from the pub/sub message you'll see we use the data object passed into the function to deserialize it into our defined type. We can then use the properties on our type to do things with such as log who we're going to send a message to and also actually use it to send an email to!

I'll briefly touch on testing functions locally now. Helpfully, GCP provides a testing library that wraps .NET's TestServer and provides many things to allow you to test your function. Create a new project in your solution that references your function project and also has Google.Cloud.Functions.Testing, Microsoft.NET.Test.Sdk, xunit, xunit.runner.visualstudio nuget packages installed.

type TestStartup() =
    inherit Startup()

    override this.ConfigureAppConfiguration(context: WebHostBuilderContext, configuration: IConfigurationBuilder) =
        configuration.AddInMemoryCollection
            ([ KeyValuePair<string, string>("API_PUBLIC_KEY", "123")
               KeyValuePair<string, string>("API_PRIVATE_KEY", "456") ])
        |> ignore

As our function needs configuration values set we can create a new TestStartup type that inherits from our Startup type and passes in configuration just for testing purposes. We can then write some tests:

[<FunctionsStartup(typeof<TestStartup>)>]
type FunctionTests() =
    inherit FunctionTestBase<Function>()

    let messageJson = """{
       "To":"jonathan@theinternet.com"
    }"""

    member this.ExecuteCloudEventRequestAsync cloudEvent =
        base.ExecuteCloudEventRequestAsync cloudEvent

    [<Fact>]
    member this.functionTest() =
        task {


            let message = PubsubMessage()
            message.TextData <- messageJson

            let data = MessagePublishedData()
            data.Message <- message

            let cloudEvent =
                CloudEvent
                    (MessagePublishedData.MessagePublishedCloudEventType,
                     Uri("//help", UriKind.RelativeOrAbsolute),
                     null,
                     Nullable())

            CloudEventConverters.PopulateCloudEvent(cloudEvent, data)

            do! this.ExecuteCloudEventRequestAsync(cloudEvent)

                        //Assert whatever you feel is best here
            Assert.Equal(1, 1)
        }

As you'll see even in tests we need to add an attribute to tell our test objects which Startup to use. We then inherit of a GCP type that handles sending messages to a function and pass in our function type as a generic argument. We then have a string literal of what our message looks like that comes from our pub/sub topic in GCP. We then have to define our own function that calls the base ExecuteCloudEventRequestAsync function. This is an F# idiosyncrasy in that we cannot call base functions directly due to scope issues and therefore have to be called via type members. We can then write our xUnit test by applying a Fact attribute. To mimic a pub/sub subscription calling our function we have to create some objects and then call the GCP test framework's ExecuteCloudEventRequestAsync which will call our function.

Once we have called our function we can then assert something. Obviously in a pub/sub scenario we don't get any response objects back so we can't assert anything on a response. In this scenario we could have a in-mem email client that implements the same interface we register in our function and when called it adds the message to a list , we could then assert that our in-mem email client has a message list length greater than 0 but you do whatever you feel is best. You'll notice that the objects that we have to call in our test are very C#-y as in we have to set properties using the <- operator. You'll also see we have to pass in null and Nullable() arguments to CloudEvent constructor. This is because in C# these are optional arguments and in this scenario we have to be explicit. For future F# users of GCP test framework I have already submitted a PR that should tidy this up a bit for future users!

Anyhow, I hope this brief introduction to GCP Cloud Functions for F# users has been of some help!

Zurück
Zurück

Tracing IO in .NET Core

Weiter
Weiter

Introduction to Partial Function Application in F#