diff --git a/extensions/Worker.Extensions.CosmosDB/release_notes.md b/extensions/Worker.Extensions.CosmosDB/release_notes.md index 9176a67d3..b557ab76e 100644 --- a/extensions/Worker.Extensions.CosmosDB/release_notes.md +++ b/extensions/Worker.Extensions.CosmosDB/release_notes.md @@ -2,4 +2,5 @@ -- Fixed incorrect type of CosmosDBTriggerAttribute's `StartFromTime` property. + +- Add support for SDK-type bindings via deferred binding feature (#1406) diff --git a/extensions/Worker.Extensions.CosmosDB/src/Config/CosmosDBBindingOptions.cs b/extensions/Worker.Extensions.CosmosDB/src/Config/CosmosDBBindingOptions.cs new file mode 100644 index 000000000..21f6446ca --- /dev/null +++ b/extensions/Worker.Extensions.CosmosDB/src/Config/CosmosDBBindingOptions.cs @@ -0,0 +1,52 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Collections.Concurrent; +using Azure.Core; +using Microsoft.Azure.Cosmos; +using Microsoft.Azure.Functions.Worker.Extensions.CosmosDB; + +namespace Microsoft.Azure.Functions.Worker +{ + internal class CosmosDBBindingOptions + { + public string? ConnectionString { get; set; } + + public string? AccountEndpoint { get; set; } + + public TokenCredential? Credential { get; set; } + + internal string BuildCacheKey(string connectionString, string region) => $"{connectionString}|{region}"; + internal ConcurrentDictionary ClientCache { get; } = new ConcurrentDictionary(); + + internal virtual CosmosClient GetClient(string connection, string preferredLocations = "") + { + if (string.IsNullOrEmpty(connection)) + { + throw new ArgumentNullException(nameof(connection)); + } + + string cacheKey = BuildCacheKey(connection, preferredLocations); + + CosmosClientOptions cosmosClientOptions = new () + { + ConnectionMode = ConnectionMode.Gateway + }; + + if (!string.IsNullOrEmpty(preferredLocations)) + { + cosmosClientOptions.ApplicationPreferredRegions = Utilities.ParsePreferredLocations(preferredLocations); + } + + return ClientCache.GetOrAdd(cacheKey, (c) => CreateService(cosmosClientOptions)); + } + + private CosmosClient CreateService(CosmosClientOptions cosmosClientOptions) + { + return string.IsNullOrEmpty(ConnectionString) + ? new CosmosClient(AccountEndpoint, Credential, cosmosClientOptions) // AAD auth + : new CosmosClient(ConnectionString, cosmosClientOptions); // Connection string based auth + } + } +} \ No newline at end of file diff --git a/extensions/Worker.Extensions.CosmosDB/src/Config/CosmosDBBindingOptionsSetup.cs b/extensions/Worker.Extensions.CosmosDB/src/Config/CosmosDBBindingOptionsSetup.cs new file mode 100644 index 000000000..60c2a374a --- /dev/null +++ b/extensions/Worker.Extensions.CosmosDB/src/Config/CosmosDBBindingOptionsSetup.cs @@ -0,0 +1,56 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using Microsoft.Azure.Functions.Worker.Extensions; +using Microsoft.Azure.Functions.Worker.Extensions.CosmosDB; +using Microsoft.Extensions.Azure; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; + +namespace Microsoft.Azure.Functions.Worker +{ + internal class CosmosDBBindingOptionsSetup : IConfigureNamedOptions + { + private readonly IConfiguration _configuration; + private readonly AzureComponentFactory _componentFactory; + + public CosmosDBBindingOptionsSetup(IConfiguration configuration, AzureComponentFactory componentFactory) + { + _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); + _componentFactory = componentFactory ?? throw new ArgumentNullException(nameof(componentFactory)); + } + + public void Configure(CosmosDBBindingOptions options) + { + Configure(Options.DefaultName, options); + } + + public void Configure(string name, CosmosDBBindingOptions options) + { + IConfigurationSection connectionSection = _configuration.GetWebJobsConnectionStringSection(name); + + if (!connectionSection.Exists()) + { + throw new InvalidOperationException($"Cosmos DB connection configuration '{name}' does not exist. " + + "Make sure that it is a defined App Setting."); + } + + if (!string.IsNullOrWhiteSpace(connectionSection.Value)) + { + options.ConnectionString = connectionSection.Value; + } + else + { + options.AccountEndpoint = connectionSection[Constants.AccountEndpoint]; + if (string.IsNullOrWhiteSpace(options.AccountEndpoint)) + { + throw new InvalidOperationException($"Connection should have an '{Constants.AccountEndpoint}' property or be a " + + $"string representing a connection string."); + } + + options.Credential = _componentFactory.CreateTokenCredential(connectionSection); + } + } + } +} \ No newline at end of file diff --git a/extensions/Worker.Extensions.CosmosDB/src/Constants.cs b/extensions/Worker.Extensions.CosmosDB/src/Constants.cs new file mode 100644 index 000000000..99f3cd088 --- /dev/null +++ b/extensions/Worker.Extensions.CosmosDB/src/Constants.cs @@ -0,0 +1,15 @@ + +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +namespace Microsoft.Azure.Functions.Worker.Extensions.CosmosDB +{ + internal static class Constants + { + internal const string CosmosExtensionName = "CosmosDB"; + internal const string ConfigurationSectionName = "AzureWebJobs"; + internal const string ConnectionStringsSectionName = "ConnectionStrings"; + internal const string AccountEndpoint = "accountEndpoint"; + internal const string JsonContentType = "application/json"; + } +} \ No newline at end of file diff --git a/extensions/Worker.Extensions.CosmosDB/src/CosmosDBConverter.cs b/extensions/Worker.Extensions.CosmosDB/src/CosmosDBConverter.cs new file mode 100644 index 000000000..a50823f1c --- /dev/null +++ b/extensions/Worker.Extensions.CosmosDB/src/CosmosDBConverter.cs @@ -0,0 +1,206 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.Azure.Cosmos; +using Microsoft.Azure.Functions.Worker.Core; +using Microsoft.Azure.Functions.Worker.Converters; +using Microsoft.Azure.Functions.Worker.Extensions.CosmosDB; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Azure.Functions.Worker +{ + /// + /// Converter to bind Cosmos DB type parameters. + /// + internal class CosmosDBConverter : IInputConverter + { + private readonly IOptionsSnapshot _cosmosOptions; + private readonly ILogger _logger; + + public CosmosDBConverter(IOptionsSnapshot cosmosOptions, ILogger logger) + { + _cosmosOptions = cosmosOptions ?? throw new ArgumentNullException(nameof(cosmosOptions)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async ValueTask ConvertAsync(ConverterContext context) + { + return context?.Source switch + { + ModelBindingData binding => await ConvertFromBindingDataAsync(context, binding), + _ => ConversionResult.Unhandled(), + }; + } + + private async ValueTask ConvertFromBindingDataAsync(ConverterContext context, ModelBindingData modelBindingData) + { + if (!IsCosmosExtension(modelBindingData)) + { + return ConversionResult.Unhandled(); + } + + try + { + var cosmosAttribute = GetBindingDataContent(modelBindingData); + object result = await ToTargetTypeAsync(context.TargetType, cosmosAttribute); + + if (result is not null) + { + return ConversionResult.Success(result); + } + } + catch (Exception ex) + { + return ConversionResult.Failed(ex); + } + + return ConversionResult.Unhandled(); + } + + private bool IsCosmosExtension(ModelBindingData bindingData) + { + if (bindingData?.Source is not Constants.CosmosExtensionName) + { + _logger.LogTrace("Source '{source}' is not supported by {converter}", bindingData?.Source, nameof(CosmosDBConverter)); + return false; + } + + return true; + } + + private CosmosDBInputAttribute GetBindingDataContent(ModelBindingData bindingData) + { + return bindingData?.ContentType switch + { + Constants.JsonContentType => bindingData.Content.ToObjectFromJson(), + _ => throw new NotSupportedException($"Unexpected content-type. Currently only '{Constants.JsonContentType}' is supported.") + }; + } + + private async Task ToTargetTypeAsync(Type targetType, CosmosDBInputAttribute cosmosAttribute) => targetType switch + { + Type _ when targetType == typeof(CosmosClient) => CreateCosmosClient(cosmosAttribute), + Type _ when targetType == typeof(Database) => CreateCosmosClient(cosmosAttribute), + Type _ when targetType == typeof(Container) => CreateCosmosClient(cosmosAttribute), + _ => await CreateTargetObjectAsync(targetType, cosmosAttribute) + }; + + private async Task CreateTargetObjectAsync(Type targetType, CosmosDBInputAttribute cosmosAttribute) + { + MethodInfo createPOCOMethod; + + if (targetType.GenericTypeArguments.Any()) + { + targetType = targetType.GenericTypeArguments.FirstOrDefault(); + + createPOCOMethod = GetType() + .GetMethod(nameof(CreatePOCOCollectionAsync), BindingFlags.Instance | BindingFlags.NonPublic) + .MakeGenericMethod(targetType); + } + else + { + createPOCOMethod = GetType() + .GetMethod(nameof(CreatePOCOAsync), BindingFlags.Instance | BindingFlags.NonPublic) + .MakeGenericMethod(targetType); + } + + + var container = CreateCosmosClient(cosmosAttribute) as Container; + + if (container is null) + { + throw new InvalidOperationException($"Unable to create Cosmos container client for '{cosmosAttribute.ContainerName}'."); + } + + return await (Task)createPOCOMethod.Invoke(this, new object[] { container, cosmosAttribute }); + } + + private async Task CreatePOCOAsync(Container container, CosmosDBInputAttribute cosmosAttribute) + { + if (String.IsNullOrEmpty(cosmosAttribute.Id) || String.IsNullOrEmpty(cosmosAttribute.PartitionKey)) + { + throw new InvalidOperationException("The 'Id' and 'PartitionKey' properties of a CosmosDB single-item input binding cannot be null or empty."); + } + + ItemResponse item = await container.ReadItemAsync(cosmosAttribute.Id, new PartitionKey(cosmosAttribute.PartitionKey)); + + if (item is null || item?.StatusCode is not System.Net.HttpStatusCode.OK || item.Resource is null) + { + throw new InvalidOperationException($"Unable to retrieve document with ID '{cosmosAttribute.Id}' and PartitionKey '{cosmosAttribute.PartitionKey}'"); + } + + return item.Resource; + } + + private async Task CreatePOCOCollectionAsync(Container container, CosmosDBInputAttribute cosmosAttribute) + { + QueryDefinition queryDefinition = null!; + if (!String.IsNullOrEmpty(cosmosAttribute.SqlQuery)) + { + queryDefinition = new QueryDefinition(cosmosAttribute.SqlQuery); + if (cosmosAttribute.SqlQueryParameters?.Count() > 0) + { + foreach (var parameter in cosmosAttribute.SqlQueryParameters) + { + queryDefinition.WithParameter(parameter.Key, parameter.Value.ToString()); + } + } + } + + PartitionKey partitionKey = String.IsNullOrEmpty(cosmosAttribute.PartitionKey) + ? PartitionKey.None + : new PartitionKey(cosmosAttribute.PartitionKey); + + // Workaround until bug in Cosmos SDK is fixed + // Currently pending release: https://github.com/Azure/azure-cosmos-dotnet-v3/commit/d6e04a92f8778565eb1d1452738d37c7faf3c47a + QueryRequestOptions queryRequestOptions = new(); + if (partitionKey != PartitionKey.None) + { + queryRequestOptions = new() { PartitionKey = partitionKey }; + } + + using (var iterator = container.GetItemQueryIterator(queryDefinition: queryDefinition, requestOptions: queryRequestOptions)) + { + if (iterator is null) + { + throw new InvalidOperationException($"Unable to retrieve documents for container '{container.Id}'."); + } + + return await ExtractCosmosDocumentsAsync(iterator); + } + } + + private async Task> ExtractCosmosDocumentsAsync(FeedIterator iterator) + { + var documentList = new List(); + while (iterator.HasMoreResults) + { + FeedResponse response = await iterator.ReadNextAsync(); + documentList.AddRange(response.Resource); + } + return documentList; + } + + private T CreateCosmosClient(CosmosDBInputAttribute cosmosAttribute) + { + var cosmosDBOptions = _cosmosOptions.Get(cosmosAttribute?.Connection); + CosmosClient cosmosClient = cosmosDBOptions.GetClient(cosmosAttribute?.Connection!, cosmosAttribute?.PreferredLocations!); + + Type targetType = typeof(T); + object cosmosReference = targetType switch + { + Type _ when targetType == typeof(Database) => cosmosClient.GetDatabase(cosmosAttribute?.DatabaseName), + Type _ when targetType == typeof(Container) => cosmosClient.GetContainer(cosmosAttribute?.DatabaseName, cosmosAttribute?.ContainerName), + _ => cosmosClient + }; + + return (T)cosmosReference; + } + } +} diff --git a/extensions/Worker.Extensions.CosmosDB/src/CosmosDBInputAttribute.cs b/extensions/Worker.Extensions.CosmosDB/src/CosmosDBInputAttribute.cs index 895ec9e9b..30e35a713 100644 --- a/extensions/Worker.Extensions.CosmosDB/src/CosmosDBInputAttribute.cs +++ b/extensions/Worker.Extensions.CosmosDB/src/CosmosDBInputAttribute.cs @@ -1,10 +1,12 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. See License.txt in the project root for license information. +using System.Collections.Generic; using Microsoft.Azure.Functions.Worker.Extensions.Abstractions; namespace Microsoft.Azure.Functions.Worker { + [SupportsDeferredBinding] public sealed class CosmosDBInputAttribute : InputBindingAttribute { /// @@ -44,7 +46,7 @@ public CosmosDBInputAttribute(string databaseName, string containerName) /// /// Optional. - /// When specified on an output binding and is true, defines the partition key + /// When specified on an output binding and is true, defines the partition key /// path for the created container. /// When specified on an input binding, specifies the partition key value for the lookup. /// May include binding parameters. @@ -67,5 +69,11 @@ public CosmosDBInputAttribute(string databaseName, string containerName) /// PreferredLocations = "East US,South Central US,North Europe" /// public string? PreferredLocations { get; set; } + + /// + /// Optional. + /// Defines the parameters to be used with the SqlQuery + /// + public IDictionary? SqlQueryParameters { get; set; } } } diff --git a/extensions/Worker.Extensions.CosmosDB/src/CosmosExtensionStartup.cs b/extensions/Worker.Extensions.CosmosDB/src/CosmosExtensionStartup.cs new file mode 100644 index 000000000..d67838cf7 --- /dev/null +++ b/extensions/Worker.Extensions.CosmosDB/src/CosmosExtensionStartup.cs @@ -0,0 +1,34 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Core; +using Microsoft.Extensions.Azure; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +[assembly: WorkerExtensionStartup(typeof(CosmosExtensionStartup))] + +namespace Microsoft.Azure.Functions.Worker +{ + public class CosmosExtensionStartup : WorkerExtensionStartup + { + public override void Configure(IFunctionsWorkerApplicationBuilder applicationBuilder) + { + if (applicationBuilder == null) + { + throw new ArgumentNullException(nameof(applicationBuilder)); + } + + applicationBuilder.Services.AddAzureClientsCore(); // Adds AzureComponentFactory + applicationBuilder.Services.AddOptions(); + applicationBuilder.Services.AddSingleton, CosmosDBBindingOptionsSetup>(); + + applicationBuilder.Services.Configure((workerOption) => + { + workerOption.InputConverters.RegisterAt(0); + }); + } + } +} diff --git a/extensions/Worker.Extensions.CosmosDB/src/Properties/AssemblyInfo.cs b/extensions/Worker.Extensions.CosmosDB/src/Properties/AssemblyInfo.cs index 2d9119cea..fce96c7ce 100644 --- a/extensions/Worker.Extensions.CosmosDB/src/Properties/AssemblyInfo.cs +++ b/extensions/Worker.Extensions.CosmosDB/src/Properties/AssemblyInfo.cs @@ -1,6 +1,9 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. See License.txt in the project root for license information. +using System.Runtime.CompilerServices; using Microsoft.Azure.Functions.Worker.Extensions.Abstractions; -[assembly: ExtensionInformation("Microsoft.Azure.WebJobs.Extensions.CosmosDB", "4.0.0")] +[assembly: ExtensionInformation("Microsoft.Azure.WebJobs.Extensions.CosmosDB", "4.2.0")] +[assembly: InternalsVisibleTo("Microsoft.Azure.Functions.WorkerExtension.Tests, PublicKey=00240000048000009400000006020000002400005253413100040000010001005148be37ac1d9f58bd40a2e472c9d380d635b6048278f7d47480b08c928858f0f7fe17a6e4ce98da0e7a7f0b8c308aecd9e9b02d7e9680a5b5b75ac7773cec096fbbc64aebd429e77cb5f89a569a79b28e9c76426783f624b6b70327eb37341eb498a2c3918af97c4860db6cdca4732787150841e395a29cfacb959c1fd971c1")] +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c547cac37abd99c8db225ef2f6c8a3602f3b3606cc9891605d02baa56104f4cfc0734aa39b93bf7852f7d9266654753cc297e7d2edfe0bac1cdcf9f717241550e0a7b191195b7667bb4f64bcb8e2121380fd1d9d46ad2d92d2d15605093924cceaf74c4861eff62abf69b9291ed0a340e113be11e6a7d3113e92484cf7045cc7")] \ No newline at end of file diff --git a/extensions/Worker.Extensions.CosmosDB/src/Utilities.cs b/extensions/Worker.Extensions.CosmosDB/src/Utilities.cs new file mode 100644 index 000000000..b7794f2a8 --- /dev/null +++ b/extensions/Worker.Extensions.CosmosDB/src/Utilities.cs @@ -0,0 +1,25 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.Azure.Functions.Worker.Extensions.CosmosDB +{ + internal class Utilities + { + internal static IReadOnlyList ParsePreferredLocations(string preferredRegions) + { + if (string.IsNullOrEmpty(preferredRegions)) + { + return Enumerable.Empty().ToList(); + } + + return preferredRegions + .Split(',') + .Select((region) => region.Trim()) + .Where((region) => !string.IsNullOrEmpty(region)) + .ToList(); + } + } +} \ No newline at end of file diff --git a/extensions/Worker.Extensions.CosmosDB/src/Worker.Extensions.CosmosDB.csproj b/extensions/Worker.Extensions.CosmosDB/src/Worker.Extensions.CosmosDB.csproj index 72e58bce2..97a840aff 100644 --- a/extensions/Worker.Extensions.CosmosDB/src/Worker.Extensions.CosmosDB.csproj +++ b/extensions/Worker.Extensions.CosmosDB/src/Worker.Extensions.CosmosDB.csproj @@ -6,7 +6,8 @@ Azure Cosmos DB extensions for .NET isolated functions - 4.0.1 + 4.1.0 + -preview1 false @@ -14,8 +15,19 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/extensions/Worker.Extensions.Storage.Blobs/src/BlobStorageConverter.cs b/extensions/Worker.Extensions.Storage.Blobs/src/BlobStorageConverter.cs index f8d34b4cb..280ccc22e 100644 --- a/extensions/Worker.Extensions.Storage.Blobs/src/BlobStorageConverter.cs +++ b/extensions/Worker.Extensions.Storage.Blobs/src/BlobStorageConverter.cs @@ -12,6 +12,7 @@ using Azure.Storage.Blobs.Specialized; using Microsoft.Azure.Functions.Worker.Converters; using Microsoft.Azure.Functions.Worker.Core; +using Microsoft.Azure.Functions.Worker.Extensions.Storage.Blobs; using Microsoft.Extensions.Options; using Microsoft.Extensions.Logging; @@ -24,7 +25,6 @@ internal class BlobStorageConverter : IInputConverter { private readonly IOptions _workerOptions; private readonly IOptionsSnapshot _blobOptions; - private readonly ILogger _logger; public BlobStorageConverter(IOptions workerOptions, IOptionsSnapshot blobOptions, ILogger logger) diff --git a/extensions/Worker.Extensions.Storage.Blobs/src/Config/BlobStorageBindingOptionsSetup.cs b/extensions/Worker.Extensions.Storage.Blobs/src/Config/BlobStorageBindingOptionsSetup.cs index b4c538042..654653702 100644 --- a/extensions/Worker.Extensions.Storage.Blobs/src/Config/BlobStorageBindingOptionsSetup.cs +++ b/extensions/Worker.Extensions.Storage.Blobs/src/Config/BlobStorageBindingOptionsSetup.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Options; using Microsoft.Azure.Functions.Worker.Extensions; +using Microsoft.Azure.Functions.Worker.Extensions.Storage.Blobs; using System.Globalization; namespace Microsoft.Azure.Functions.Worker diff --git a/extensions/Worker.Extensions.Storage.Blobs/src/Constants.cs b/extensions/Worker.Extensions.Storage.Blobs/src/Constants.cs index 95b378704..5320bff21 100644 --- a/extensions/Worker.Extensions.Storage.Blobs/src/Constants.cs +++ b/extensions/Worker.Extensions.Storage.Blobs/src/Constants.cs @@ -1,7 +1,7 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. See License.txt in the project root for license information. -namespace Microsoft.Azure.Functions.Worker +namespace Microsoft.Azure.Functions.Worker.Extensions.Storage.Blobs { internal static class Constants { diff --git a/samples/WorkerBindingSamples/Book.cs b/samples/WorkerBindingSamples/Blob/Book.cs similarity index 100% rename from samples/WorkerBindingSamples/Book.cs rename to samples/WorkerBindingSamples/Blob/Book.cs diff --git a/samples/WorkerBindingSamples/Cosmos/CosmosInputBindingFunctions.cs b/samples/WorkerBindingSamples/Cosmos/CosmosInputBindingFunctions.cs new file mode 100644 index 000000000..437c08bc7 --- /dev/null +++ b/samples/WorkerBindingSamples/Cosmos/CosmosInputBindingFunctions.cs @@ -0,0 +1,221 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System.Net; +using Microsoft.Azure.Cosmos; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; + +namespace SampleApp +{ + public class CosmosInputBindingFunctions + { + private readonly ILogger _logger; + + public CosmosInputBindingFunctions(ILogger logger) + { + _logger = logger; + } + + [Function(nameof(DocsByUsingCosmosClient))] + public async Task DocsByUsingCosmosClient( + [HttpTrigger(AuthorizationLevel.Function, "get", "post")] HttpRequestData req, + [CosmosDBInput("", "", Connection = "CosmosDBConnection")] CosmosClient client) + { + _logger.LogInformation("C# HTTP trigger function processed a request."); + + var iterator = client.GetContainer("ToDoItems", "Items") + .GetItemQueryIterator("SELECT * FROM c"); + + while (iterator.HasMoreResults) + { + var documents = await iterator.ReadNextAsync(); + foreach (dynamic d in documents) + { + _logger.LogInformation((string)d.description); + } + } + + return req.CreateResponse(HttpStatusCode.OK); + } + + [Function(nameof(DocsByUsingDatabaseClient))] + public async Task DocsByUsingDatabaseClient( + [HttpTrigger(AuthorizationLevel.Function, "get", "post")] HttpRequestData req, + [CosmosDBInput("ToDoItems", "", Connection = "CosmosDBConnection")] Database database) + { + _logger.LogInformation("C# HTTP trigger function processed a request."); + + var iterator = database.GetContainerQueryIterator("SELECT * FROM c"); + + while (iterator.HasMoreResults) + { + var containers = await iterator.ReadNextAsync(); + foreach (dynamic c in containers) + { + _logger.LogInformation((string)c.id); + } + } + + return req.CreateResponse(HttpStatusCode.OK); + } + + [Function(nameof(DocsByUsingContainerClient))] + public async Task DocsByUsingContainerClient( + [HttpTrigger(AuthorizationLevel.Function, "get", "post")] HttpRequestData req, + [CosmosDBInput("ToDoItems", "Items", Connection = "CosmosDBConnection")] Container container) + { + _logger.LogInformation("C# HTTP trigger function processed a request."); + + var iterator = container.GetItemQueryIterator("SELECT * FROM c"); + + while (iterator.HasMoreResults) + { + var documents = await iterator.ReadNextAsync(); + foreach (dynamic d in documents) + { + _logger.LogInformation("Found ToDo item, Description={desc}", (string)d.description); + } + } + + return req.CreateResponse(HttpStatusCode.OK); + } + + [Function(nameof(DocByIdFromQueryString))] + public HttpResponseData DocByIdFromQueryString( + [HttpTrigger(AuthorizationLevel.Function, "get", "post")] HttpRequestData req, + [CosmosDBInput( + databaseName: "ToDoItems", + containerName: "Items", + Connection = "CosmosDBConnection", + Id = "{Query.id}", + PartitionKey = "{Query.partitionKey}")] ToDoItem toDoItem) + { + _logger.LogInformation("C# HTTP trigger function processed a request."); + + if (toDoItem == null) + { + _logger.LogInformation("ToDo item not found"); + } + else + { + _logger.LogInformation("Found ToDo item, Description={desc}", toDoItem.Description); + } + + return req.CreateResponse(HttpStatusCode.OK); + } + + [Function(nameof(DocByIdFromRouteData))] + public HttpResponseData DocByIdFromRouteData( + [HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = "todoitems/{partitionKey}/{id}")] HttpRequestData req, + [CosmosDBInput( + databaseName: "ToDoItems", + containerName: "Items", + Connection = "CosmosDBConnection", + Id = "{id}", + PartitionKey = "{partitionKey}")] ToDoItem toDoItem) + { + _logger.LogInformation("C# HTTP trigger function processed a request."); + + if (toDoItem == null) + { + _logger.LogInformation($"ToDo item not found"); + } + else + { + _logger.LogInformation($"Found ToDo item, Description={toDoItem.Description}"); + } + + return req.CreateResponse(HttpStatusCode.OK); + } + + [Function(nameof(DocByIdFromRouteDataUsingSqlQuery))] + public HttpResponseData DocByIdFromRouteDataUsingSqlQuery( + [HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = "todoitems2/{id}")] HttpRequestData req, + [CosmosDBInput( + databaseName: "ToDoItems", + containerName: "Items", + Connection = "CosmosDBConnection", + SqlQuery = "SELECT * FROM ToDoItems t where t.id = {id}")] + IEnumerable toDoItems) + { + _logger.LogInformation("C# HTTP trigger function processed a request."); + + foreach (ToDoItem toDoItem in toDoItems) + { + _logger.LogInformation(toDoItem.Description); + } + + return req.CreateResponse(HttpStatusCode.OK); + } + + [Function(nameof(DocByIdFromQueryStringUsingSqlQuery))] + public HttpResponseData DocByIdFromQueryStringUsingSqlQuery( + [HttpTrigger(AuthorizationLevel.Function, "get", "post")] HttpRequestData req, + [CosmosDBInput( + databaseName: "ToDoItems", + containerName: "Items", + Connection = "CosmosDBConnection", + SqlQuery = "SELECT * FROM ToDoItems t where t.id = {id}")] + IEnumerable toDoItems) + { + _logger.LogInformation("C# HTTP trigger function processed a request."); + + foreach (ToDoItem toDoItem in toDoItems) + { + _logger.LogInformation(toDoItem.Description); + } + + return req.CreateResponse(HttpStatusCode.OK); + } + + [Function(nameof(DocsBySqlQuery))] + public HttpResponseData DocsBySqlQuery( + [HttpTrigger(AuthorizationLevel.Function, "get", "post")] HttpRequestData req, + [CosmosDBInput( + databaseName: "ToDoItems", + containerName: "Items", + Connection = "CosmosDBConnection", + SqlQuery = "SELECT * FROM ToDoItems t WHERE CONTAINS(t.description, 'cat')")] IEnumerable toDoItems) + { + _logger.LogInformation("C# HTTP trigger function processed a request."); + + foreach (ToDoItem toDoItem in toDoItems) + { + _logger.LogInformation(toDoItem.Description); + } + + return req.CreateResponse(HttpStatusCode.OK); + } + + [Function(nameof(DocByIdFromJSON))] + public void DocByIdFromJSON( + [QueueTrigger("todoqueueforlookup")] ToDoItemLookup toDoItemLookup, + [CosmosDBInput( + databaseName: "ToDoItems", + containerName: "Items", + Connection = "CosmosDBConnection", + Id = "{ToDoItemId}", + PartitionKey = "{ToDoItemPartitionKeyValue}")] ToDoItem toDoItem) + { + _logger.LogInformation($"C# Queue trigger function processed Id={toDoItemLookup?.ToDoItemId} Key={toDoItemLookup?.ToDoItemPartitionKeyValue}"); + + if (toDoItem == null) + { + _logger.LogInformation($"ToDo item not found"); + } + else + { + _logger.LogInformation($"Found ToDo item, Description={toDoItem.Description}"); + } + } + + public class ToDoItemLookup + { + public string? ToDoItemId { get; set; } + + public string? ToDoItemPartitionKeyValue { get; set; } + } + } +} diff --git a/samples/WorkerBindingSamples/Cosmos/CosmosTriggerFunction.cs b/samples/WorkerBindingSamples/Cosmos/CosmosTriggerFunction.cs new file mode 100644 index 000000000..f7e11f6cd --- /dev/null +++ b/samples/WorkerBindingSamples/Cosmos/CosmosTriggerFunction.cs @@ -0,0 +1,33 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using Microsoft.Azure.Functions.Worker; +using Microsoft.Extensions.Logging; + +namespace SampleApp +{ + // We cannot use SDK-type bindings with the Cosmos trigger binding. There is no way for + // the CosmosDB SDK to let us know the ID of the document that triggered the function; + // therefore we cannot create a client that is able to pull the triggering document. + public static class CosmosTriggerFunction + { + [Function(nameof(CosmosTriggerFunction))] + public static void Run([CosmosDBTrigger( + databaseName: "ToDoItems", + containerName:"TriggerItems", + Connection = "CosmosDBConnection", + CreateLeaseContainerIfNotExists = true)] IReadOnlyList todoItems, + FunctionContext context) + { + var logger = context.GetLogger(nameof(CosmosTriggerFunction)); + + if (todoItems is not null && todoItems.Any()) + { + foreach (var doc in todoItems) + { + logger.LogInformation("ToDoItem: {desc}", doc.Description); + } + } + } + } +} diff --git a/samples/WorkerBindingSamples/Cosmos/ToDoItem.cs b/samples/WorkerBindingSamples/Cosmos/ToDoItem.cs new file mode 100644 index 000000000..86027c8f2 --- /dev/null +++ b/samples/WorkerBindingSamples/Cosmos/ToDoItem.cs @@ -0,0 +1,11 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +namespace SampleApp +{ + public class ToDoItem + { + public string? Id { get; set; } + public string? Description { get; set; } + } +} diff --git a/samples/WorkerBindingSamples/WorkerBindingSamples.csproj b/samples/WorkerBindingSamples/WorkerBindingSamples.csproj index 7b54a7565..66fe3d12a 100644 --- a/samples/WorkerBindingSamples/WorkerBindingSamples.csproj +++ b/samples/WorkerBindingSamples/WorkerBindingSamples.csproj @@ -14,6 +14,9 @@ + + + PreserveNewest diff --git a/test/WorkerExtensionTests/Blob/BlobStorageConverterTests.cs b/test/WorkerExtensionTests/Blob/BlobStorageConverterTests.cs index 49ce572d3..903d7a7f1 100644 --- a/test/WorkerExtensionTests/Blob/BlobStorageConverterTests.cs +++ b/test/WorkerExtensionTests/Blob/BlobStorageConverterTests.cs @@ -22,6 +22,8 @@ using Moq; using Xunit; +using Constants = Microsoft.Azure.Functions.Worker.Extensions.Storage.Blobs.Constants; + namespace Microsoft.Azure.Functions.WorkerExtension.Tests { public class BlobStorageConverterTests diff --git a/test/WorkerExtensionTests/Cosmos/CosmosDBConverterTests.cs b/test/WorkerExtensionTests/Cosmos/CosmosDBConverterTests.cs new file mode 100644 index 000000000..e64399b97 --- /dev/null +++ b/test/WorkerExtensionTests/Cosmos/CosmosDBConverterTests.cs @@ -0,0 +1,441 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Google.Protobuf; +using Microsoft.Azure.Cosmos; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Converters; +using Microsoft.Azure.Functions.Worker.Grpc.Messages; +using Microsoft.Azure.Functions.Worker.Tests.Converters; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using Xunit; + +namespace Microsoft.Azure.Functions.WorkerExtension.Tests +{ + public class CosmosDBConverterTests + { + private CosmosDBConverter _cosmosDBConverter; + private Mock _mockCosmosClient; + + public CosmosDBConverterTests() + { + var host = new HostBuilder().ConfigureFunctionsWorkerDefaults((WorkerOptions options) => { }).Build(); + var logger = host.Services.GetService>(); + + _mockCosmosClient = new Mock(); + + var mockCosmosOptions = new Mock(); + mockCosmosOptions + .Setup(m => m.GetClient(It.IsAny(), It.IsAny())) + .Returns(_mockCosmosClient.Object); + + var mockCosmosOptionsSnapshot = new Mock>(); + mockCosmosOptionsSnapshot + .Setup(m => m.Get(It.IsAny())) + .Returns(mockCosmosOptions.Object); + + _cosmosDBConverter = new CosmosDBConverter(mockCosmosOptionsSnapshot.Object, logger); + } + + [Fact] + public async Task ConvertAsync_ValidModelBindingData_CosmosClient_ReturnsSuccess() + { + var grpcModelBindingData = GetTestGrpcModelBindingData(GetTestBinaryData()); + var context = new TestConverterContext(typeof(CosmosClient), grpcModelBindingData); + + _mockCosmosClient.Setup(m => m.Endpoint).Returns(new Uri("https://www.example.com")); + + var conversionResult = await _cosmosDBConverter.ConvertAsync(context); + + var expectedClient = (CosmosClient)conversionResult.Value; + Assert.Equal(ConversionStatus.Succeeded, conversionResult.Status); + Assert.Equal(new Uri("https://www.example.com"), expectedClient.Endpoint); + } + + [Fact] + public async Task ConvertAsync_ValidModelBindingData_DatabaseClient_ReturnsSuccess() + { + var grpcModelBindingData = GetTestGrpcModelBindingData(GetTestBinaryData()); + var context = new TestConverterContext(typeof(Database), grpcModelBindingData); + + var _mockDatabase = new Mock(); + _mockDatabase.Setup(m => m .Id).Returns("testId"); + + _mockCosmosClient + .Setup(m => m.GetDatabase(It.IsAny())) + .Returns(_mockDatabase.Object); + + var conversionResult = await _cosmosDBConverter.ConvertAsync(context); + + var expectedDatabase = (Database)conversionResult.Value; + Assert.Equal(ConversionStatus.Succeeded, conversionResult.Status); + Assert.Equal("testId", expectedDatabase.Id); + } + + [Fact] + public async Task ConvertAsync_ValidModelBindingData_ContainerClient_ReturnsSuccess() + { + var grpcModelBindingData = GetTestGrpcModelBindingData(GetTestBinaryData()); + var context = new TestConverterContext(typeof(Container), grpcModelBindingData); + + var mockContainer = new Mock(); + mockContainer.Setup(m => m .Id).Returns("testId"); + + _mockCosmosClient + .Setup(m => m.GetContainer(It.IsAny(), It.IsAny())) + .Returns(mockContainer.Object); + + var conversionResult = await _cosmosDBConverter.ConvertAsync(context); + + var expectedContainer = (Container)conversionResult.Value; + Assert.Equal(ConversionStatus.Succeeded, conversionResult.Status); + Assert.Equal("testId", expectedContainer.Id); + } + + [Fact] + public async Task ConvertAsync_ValidModelBindingData_SinglePOCO_ReturnsSuccess() + { + var grpcModelBindingData = GetTestGrpcModelBindingData(GetTestBinaryData(id: "1", partitionKey: "1")); + var context = new TestConverterContext(typeof(ToDoItem), grpcModelBindingData); + + var expectedToDoItem = new ToDoItem() { Id = "1", Description = "Take out the rubbish"}; + + var mockResponse = new Mock>(); + mockResponse.Setup(x => x.StatusCode).Returns(System.Net.HttpStatusCode.OK); + mockResponse.Setup(x => x.Resource).Returns(expectedToDoItem); + + var mockContainer = new Mock(); + mockContainer + .Setup(m => m.ReadItemAsync(It.IsAny(), It.IsAny(), null, CancellationToken.None)) + .ReturnsAsync(mockResponse.Object); + + _mockCosmosClient + .Setup(m => m.GetContainer(It.IsAny(), It.IsAny())) + .Returns(mockContainer.Object); + + var conversionResult = await _cosmosDBConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Succeeded, conversionResult.Status); + Assert.Equal(expectedToDoItem, conversionResult.Value); + } + + [Fact] + public async Task ConvertAsync_ValidModelBindingData_SinglePOCO_WithoutId_ReturnsFailed() + { + var grpcModelBindingData = GetTestGrpcModelBindingData(GetTestBinaryData(partitionKey: "1")); + var context = new TestConverterContext(typeof(ToDoItem), grpcModelBindingData); + + var mockContainer = new Mock(); + + _mockCosmosClient + .Setup(m => m.GetContainer(It.IsAny(), It.IsAny())) + .Returns(mockContainer.Object); + + var conversionResult = await _cosmosDBConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Failed, conversionResult.Status); + Assert.Equal("The 'Id' and 'PartitionKey' properties of a CosmosDB single-item input binding cannot be null or empty.", conversionResult.Error.Message); + } + + [Fact] + public async Task ConvertAsync_ValidModelBindingData_SinglePOCO_WithoutPK_ReturnsFailed() + { + var grpcModelBindingData = GetTestGrpcModelBindingData(GetTestBinaryData(id: "1")); + var context = new TestConverterContext(typeof(ToDoItem), grpcModelBindingData); + + var mockContainer = new Mock(); + + _mockCosmosClient + .Setup(m => m.GetContainer(It.IsAny(), It.IsAny())) + .Returns(mockContainer.Object); + + var conversionResult = await _cosmosDBConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Failed, conversionResult.Status); + Assert.Equal("The 'Id' and 'PartitionKey' properties of a CosmosDB single-item input binding cannot be null or empty.", conversionResult.Error.Message); + } + + [Fact] + public async Task ConvertAsync_ValidModelBindingData_IEnumerablePOCO_ReturnsSuccess() + { + var query = "SELECT * FROM TodoItems t WHERE t.id = @id"; + var queryParams = @"{""@id"":""1""}"; + var grpcModelBindingData = GetTestGrpcModelBindingData(GetTestBinaryData(query: query, queryParams: queryParams)); + var context = new TestConverterContext(typeof(IEnumerable), grpcModelBindingData); + + var todo1 = new ToDoItem() { Id = "1", Description = "Take out the rubbish"}; + var todo2 = new ToDoItem() { Id = "2", Description = "Write unit tests for cosmos converter"}; + var expectedList = new List(){ todo1, todo2 }; + + var mockFeedResponse = new Mock>(); + mockFeedResponse.Setup(x => x.StatusCode).Returns(System.Net.HttpStatusCode.OK); + mockFeedResponse.Setup(x => x.Resource).Returns(expectedList); + + var mockFeedIterator = new Mock>(); + mockFeedIterator.SetupSequence(x => x.HasMoreResults).Returns(true).Returns(false); + mockFeedIterator.Setup(x => x.ReadNextAsync(default)).ReturnsAsync(mockFeedResponse.Object); + + var mockContainer = new Mock(); + mockContainer + .Setup(m => m.GetItemQueryIterator(It.IsAny(), default, It.IsAny())) + .Returns(mockFeedIterator.Object); + + _mockCosmosClient + .Setup(m => m.GetContainer(It.IsAny(), It.IsAny())) + .Returns(mockContainer.Object); + + var conversionResult = await _cosmosDBConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Succeeded, conversionResult.Status); + Assert.Equal(expectedList, conversionResult.Value); + } + + [Fact] + public async Task ConvertAsync_Container_NullFeedIterator_ReturnsFailed() + { + var grpcModelBindingData = GetTestGrpcModelBindingData(GetTestBinaryData()); + var context = new TestConverterContext(typeof(IEnumerable), grpcModelBindingData); + + var mockContainer = new Mock(); + mockContainer.Setup(m => m .Id).Returns("testId"); + mockContainer + .Setup(m => m.GetItemQueryIterator(It.IsAny(), null, null)) + .Returns>(null); + + _mockCosmosClient + .Setup(m => m.GetContainer(It.IsAny(), It.IsAny())) + .Returns(mockContainer.Object); + + var conversionResult = await _cosmosDBConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Failed, conversionResult.Status); + Assert.Contains("Unable to retrieve documents for container 'testId'.", conversionResult.Error.Message); + } + + [Fact] + public async Task ConvertAsync_ContainerWithSqlQuery_NullFeedIterator_ReturnsFailed() + { + var query = "SELECT * FROM TodoItems t WHERE t.id = @id"; + var queryParams = @"{""@id"":""1""}"; + var grpcModelBindingData = GetTestGrpcModelBindingData(GetTestBinaryData(query: query, queryParams: queryParams)); + var context = new TestConverterContext(typeof(IEnumerable), grpcModelBindingData); + + var mockContainer = new Mock(); + mockContainer.Setup(m => m .Id).Returns("testId"); + mockContainer + .Setup(m => m.GetItemQueryIterator(It.IsAny(), null, null)) + .Returns((FeedIterator)null); + + _mockCosmosClient + .Setup(m => m.GetContainer(It.IsAny(), It.IsAny())) + .Returns(mockContainer.Object); + + var conversionResult = await _cosmosDBConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Failed, conversionResult.Status); + Assert.Contains($"Unable to retrieve documents for container 'testId'.", conversionResult.Error.Message); + } + + [Fact] + public async Task ConvertAsync_ContentSource_AsObject_ReturnsUnhandled() + { + var context = new TestConverterContext(typeof(CosmosClient), new Object()); + + var conversionResult = await _cosmosDBConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Unhandled, conversionResult.Status); + } + + [Fact] + public async Task ConvertAsync_ModelBindingData_Null_ReturnsUnhandled() + { + var context = new TestConverterContext(typeof(CosmosClient), null); + + var conversionResult = await _cosmosDBConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Unhandled, conversionResult.Status); + } + + [Fact] // Should we fail if the result is ever null? + public async Task ConvertAsync_ResultIsNull_ReturnsUnhandled() + { + var grpcModelBindingData = GetTestGrpcModelBindingData(GetTestBinaryData()); + var context = new TestConverterContext(typeof(Database), grpcModelBindingData); + + _mockCosmosClient + .Setup(m => m.GetDatabase(It.IsAny())) + .Returns(null); + + var conversionResult = await _cosmosDBConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Unhandled, conversionResult.Status); + } + + [Fact] + public async Task ConvertAsync_ThrowsException_ReturnsFailure() + { + var grpcModelBindingData = GetTestGrpcModelBindingData(GetTestBinaryData()); + var context = new TestConverterContext(typeof(Database), grpcModelBindingData); + + _mockCosmosClient + .Setup(m => m.GetDatabase(It.IsAny())) + .Throws(new Exception()); + + var conversionResult = await _cosmosDBConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Failed, conversionResult.Status); + } + + [Fact] + public async Task ConvertAsync_ItemResponse_ResourceIsNull_ThrowsException_ReturnsFailed() + { + var grpcModelBindingData = GetTestGrpcModelBindingData(GetTestBinaryData(id: "1", partitionKey: "1")); + var context = new TestConverterContext(typeof(ToDoItem), grpcModelBindingData); + + var mockResponse = new Mock>(); + mockResponse.Setup(x => x.StatusCode).Returns(System.Net.HttpStatusCode.OK); + mockResponse.Setup(x => x.Resource).Returns((ToDoItem)null); + + var mockContainer = new Mock(); + mockContainer + .Setup(m => m.ReadItemAsync(It.IsAny(), It.IsAny(), null, CancellationToken.None)) + .ReturnsAsync(mockResponse.Object); + + _mockCosmosClient + .Setup(m => m.GetContainer(It.IsAny(), It.IsAny())) + .Returns(mockContainer.Object); + + var conversionResult = await _cosmosDBConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Failed, conversionResult.Status); + Assert.Equal("Unable to retrieve document with ID '1' and PartitionKey '1'", conversionResult.Error.Message); + } + + [Fact] + public async Task ConvertAsync_ModelBindingDataSource_NotCosmosExtension_ReturnsUnhandled() + { + var grpcModelBindingData = GetTestGrpcModelBindingData(GetTestBinaryData(), source: "anotherExtensions"); + var context = new TestConverterContext(typeof(CosmosClient), grpcModelBindingData); + + var conversionResult = await _cosmosDBConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Unhandled, conversionResult.Status); + } + + [Fact] + public async Task ConvertAsync_ModelBindingDataContentType_Unsupported_ReturnsFailed() + { + var grpcModelBindingData = GetTestGrpcModelBindingData(GetTestBinaryData(), contentType: "binary"); + var context = new TestConverterContext(typeof(CosmosClient), grpcModelBindingData); + + var conversionResult = await _cosmosDBConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Failed, conversionResult.Status); + Assert.Equal("Unexpected content-type. Currently only 'application/json' is supported.", conversionResult.Error.Message); + } + + [Fact] + public async Task ConvertAsync_CosmosContainerIsNull_ThrowsException_ReturnsFailure() + { + object grpcModelBindingData = GetTestGrpcModelBindingData(GetTestBinaryData(container: "myContainer")); + var context = new TestConverterContext(typeof(ToDoItem), grpcModelBindingData); + + _mockCosmosClient + .Setup(m => m.GetContainer(It.IsAny(), It.IsAny())) + .Returns(null); + + var conversionResult = await _cosmosDBConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Failed, conversionResult.Status); + Assert.Equal($"Unable to create Cosmos container client for 'myContainer'.", conversionResult.Error.Message); + } + + [Fact] + public async Task ConvertAsync_POCO_ItemResponseNull_ThrowsException_ReturnsFailure() + { + object grpcModelBindingData = GetTestGrpcModelBindingData(GetTestBinaryData(id: "1", partitionKey: "1")); + var context = new TestConverterContext(typeof(ToDoItem), grpcModelBindingData); + + var mockContainer = new Mock(); + mockContainer + .Setup(m => m.ReadItemAsync(It.IsAny(), It.IsAny(), null, CancellationToken.None)) + .Returns(Task.FromResult>(null)); + + _mockCosmosClient + .Setup(m => m.GetContainer(It.IsAny(), It.IsAny())) + .Returns(mockContainer.Object); + + var conversionResult = await _cosmosDBConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Failed, conversionResult.Status); + Assert.Equal("Unable to retrieve document with ID '1' and PartitionKey '1'", conversionResult.Error.Message); + } + + [Fact] + public async Task ConvertAsync_POCO_IdProvided_StatusNot200_ThrowsException_ReturnsFailure() + { + object grpcModelBindingData = GetTestGrpcModelBindingData(GetTestBinaryData(id: "1", partitionKey: "1")); + var context = new TestConverterContext(typeof(ToDoItem), grpcModelBindingData); + + var mockResponse = new Mock>(); + mockResponse.Setup(x => x.StatusCode).Returns(System.Net.HttpStatusCode.InternalServerError); + + var mockContainer = new Mock(); + mockContainer + .Setup(m => m.ReadItemAsync(It.IsAny(), It.IsAny(), null, CancellationToken.None)) + .ReturnsAsync(mockResponse.Object); + + _mockCosmosClient + .Setup(m => m.GetContainer(It.IsAny(), It.IsAny())) + .Returns(mockContainer.Object); + + var conversionResult = await _cosmosDBConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Failed, conversionResult.Status); + Assert.Equal("Unable to retrieve document with ID '1' and PartitionKey '1'", conversionResult.Error.Message); + } + + private BinaryData GetTestBinaryData(string db = "testDb", string container = "testContainer", string connection = "cosmosConnection", string id = "", string partitionKey = "", string query = "", string location = "", string queryParams = "{}") + { + string jsonData = $@"{{ + ""DatabaseName"" : ""{db}"", + ""ContainerName"" : ""{container}"", + ""Connection"" : ""{connection}"", + ""Id"" : ""{id}"", + ""PartitionKey"" : ""{partitionKey}"", + ""SqlQuery"" : ""{query}"", + ""PreferredLocations"" : ""{location}"", + ""SqlQueryParameters"" : {queryParams} + }}"; + + return new BinaryData(jsonData); + } + + private GrpcModelBindingData GetTestGrpcModelBindingData(BinaryData content, string source = "CosmosDB", string contentType = "application/json") + { + var data = new ModelBindingData() + { + Version = "1.0", + Source = source, + Content = ByteString.CopyFrom(content), + ContentType = contentType + }; + + return new GrpcModelBindingData(data); + } + + public class ToDoItem + { + public string Id { get; set; } + public string Description { get; set; } + } + } +} \ No newline at end of file diff --git a/test/WorkerExtensionTests/Cosmos/UtilitiesTests.cs b/test/WorkerExtensionTests/Cosmos/UtilitiesTests.cs new file mode 100644 index 000000000..12ab19867 --- /dev/null +++ b/test/WorkerExtensionTests/Cosmos/UtilitiesTests.cs @@ -0,0 +1,46 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Linq; +using Microsoft.Azure.Functions.Worker.Extensions.CosmosDB; +using Xunit; + +namespace Microsoft.Azure.Functions.WorkerExtension.Tests +{ + public class UtilitiesTests + { + [Theory] + [InlineData("westeurope,eastus")] + [InlineData("westeurope , eastus")] + [InlineData(" westeurope, eastus ")] + public void ParsePreferredLocations_ValidInput_ReturnsList(string input) + { + // Arrange + var expectedList = new List(); + expectedList.Add("westeurope"); + expectedList.Add("eastus"); + + // Act + var result = Utilities.ParsePreferredLocations(input); + + //Assert + Assert.Equal(expectedList, result); + } + + [Theory] + [InlineData("")] + [InlineData(null)] + public void ParsePreferredLocations_InvalidInput_ReturnsEmptyList(string input) + { + // Arrange + var expectedList = Enumerable.Empty().ToList(); + + // Act + var result = Utilities.ParsePreferredLocations(input); + + //Assert + Assert.Equal(expectedList, result); + } + } +} \ No newline at end of file diff --git a/test/WorkerExtensionTests/WorkerExtensionTests.csproj b/test/WorkerExtensionTests/WorkerExtensionTests.csproj index fd04a5bea..8122e45b2 100644 --- a/test/WorkerExtensionTests/WorkerExtensionTests.csproj +++ b/test/WorkerExtensionTests/WorkerExtensionTests.csproj @@ -23,6 +23,7 @@ +