Azure Functions 2.0 extensibility - extending extensions

This is my fourth post about Azure Functions extensibility. So far I've written about triggers, inputs, and outputs (maybe I should devote some more time to outputs, but that's something for another time). In this post, I want to focus on something I haven't mentioned yet - extending existing extensions. As usual, I intend to do it in a practical context.

Common problem with the Azure Cosmos DB Trigger

There is a common problem with the Azure Cosmos DB Trigger for Azure Functions. Let's consider the sample from the documentation.

public class ToDoItem
{
    public string Id { get; set; }
    public string Description { get; set; }
}
public static class CosmosTrigger
{
    [FunctionName("CosmosTrigger")]
    public static void Run([CosmosDBTrigger(
        databaseName: "ToDoItems",
        collectionName: "Items",
        ConnectionStringSetting = "CosmosDBConnection",
        LeaseCollectionName = "leases",
        CreateLeaseCollectionIfNotExists = true)]IReadOnlyList<Document> documents, 
        ILogger log)
    {
        if (documents != null && documents.Count > 0)
        {
            log.LogInformation($"Documents modified: {documents.Count}");
            log.LogInformation($"First document Id: {documents[0].Id}");
        }
    }
}

It looks ok, but it avoids the problem by not being interested in the content of the documents. The trigger has a limitation, it works only with IReadOnlyList. This means that accessing values may not be as easy as one could expect. There is GetPropertyValue method, which helps with retrieving a single property value.

public static class CosmosTrigger
{
    [FunctionName("CosmosTrigger")]
    public static void Run([CosmosDBTrigger(
        ...)]IReadOnlyList<Document> documents, 
        ILogger log)
    {
        foreach(Document document in documents)
        {
            log.LogInformation($"ToDo: {document.GetPropertyValue<string>("Description")}");
        }
    }
}

This is not perfect. What if there is a lot of properties? If what is truly needed is the entire POCO, then a conversion from Document to POCO must be added. One way is to cast through dynamic.

public static class CosmosTrigger
{
    [FunctionName("CosmosTrigger")]
    public static void Run([CosmosDBTrigger(
        ...)]IReadOnlyList<Document> documents, 
        ILogger log)
    {
        foreach(Document document in documents)
        {
            ToDoItem item = (dynamic)document;
            log.LogInformation($"ToDo: {item.Description}");
            ...
        }
    }
}

Another way is to use JSON deserialization.

public static class CosmosTrigger
{
    [FunctionName("CosmosTrigger")]
    public static void Run([CosmosDBTrigger(
        ...)]IReadOnlyList<Document> documents, 
        ILogger log)
    {
        foreach(Document document in documents)
        {
            ToDoItem item = JsonConvert.DeserializeObject(document.ToString());
            log.LogInformation($"ToDo: {item.Description}");
            ...
        }
    }
}

All this is still not perfect, especially if there are many functions like this in a project. What would be perfect is being able to take a collection of POCOs as an argument.

public static class CosmosTrigger
{
    [FunctionName("CosmosTrigger")]
    public static void Run([CosmosDBTrigger(
        ...)]IReadOnlyList<ToDoItem> items, 
        ILogger log)
    {
        foreach(ToDoItem item in items)
        {
            log.LogInformation($"ToDo: {item.Description}");
            ...
        }
    }
}

Can this be achieved?

Extending the Azure Cosmos DB Trigger

As you may already know, the heart of Azure Functions extensibility is ExtensionConfigContext. It allows registering bindings by adding binding rules, but it also allows adding converters. Typically converters are added as part of the binding rule in the original extension, but this is not the only way. The truth is that the converter manager is centralized and shared across extensions. That means it's possible to add a converter for a type which is supported by different extension. The problem with the Azure Cosmos DB Trigger can be solved by a converter from IReadOnlyList to IReadOnlyList. But first, some standard boilerplate code is needed.

[assembly: WebJobsStartup(typeof(CosmosDBExtensionsWebJobsStartup))]

public class CosmosDBExtensionsWebJobsStartup : IWebJobsStartup
{
    public void Configure(IWebJobsBuilder builder)
    {
        builder.AddExtension<CosmosDBExtensionExtensionsConfigProvider>();
    }
}
[Extension("CosmosDBExtensions")]
internal class CosmosDBExtensionExtensionsConfigProvider : IExtensionConfigProvider
{
    public void Initialize(ExtensionConfigContext context)
    {
        if (context == null)
        {
            throw new ArgumentNullException("context");
        }


    }
}

Now it's time to add the converter. There are two methods for adding converters: AddConverter<TSource, TDestination> and AddOpenConverter<TSource, TDestination>. The AddConverter<TSource, TDestination> can be used to add a converter from one concrete type to another, while AddOpenConverter<TSource, TDestination> can be used to add a converter with support for generics. But how to define TDestination when Initialize method is not generic? For this purpose, the SDK provides a sentinel type OpenType which serves as a placeholder for a generic type.

[Extension("CosmosDBExtensions")]
internal class CosmosDBExtensionExtensionsConfigProvider : IExtensionConfigProvider
{
    public void Initialize(ExtensionConfigContext context)
    {
        ...

        context.AddOpenConverter<IReadOnlyList<Document>, IReadOnlyList<OpenType>>(typeof(GenericDocumentConverter<>));
    }
}

The converter itself is just an implementation of IConverter<TInput, TOutput>.

internal class GenericDocumentConverter<T> : IConverter<IReadOnlyList<Document>, IReadOnlyList<T>>
{
    public IReadOnlyList<T> Convert(IReadOnlyList<Document> input)
    {
        List<T> output = new List<T>(input.Count);

        foreach(Document item in input)
        {
            output.Add(Convert(item));
        }

        return output.AsReadOnly();
    }

    private static T Convert(Document document)
    {
        return JsonConvert.DeserializeObject<T>(document.ToString());
    }
}

That's it. This extension will allow the Azure Cosmos DB Trigger to be used with POCO collections.

This pattern can be reused with any other extension.