diff --git a/extensions/Worker.Extensions.Shared/Configuration/ConfigurationExtensions.cs b/extensions/Worker.Extensions.Shared/Configuration/ConfigurationExtensions.cs index cff348de4..ee4203f0b 100644 --- a/extensions/Worker.Extensions.Shared/Configuration/ConfigurationExtensions.cs +++ b/extensions/Worker.Extensions.Shared/Configuration/ConfigurationExtensions.cs @@ -1,6 +1,8 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// 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.Globalization; using Microsoft.Extensions.Configuration; namespace Microsoft.Azure.Functions.Worker.Extensions @@ -30,6 +32,45 @@ internal static IConfigurationSection GetWebJobsConnectionStringSection(this ICo return section; } + /// + /// Either constructs the serviceUri from the provided accountName + /// or retrieves the serviceUri for the specific resource (i.e. blobServiceUri or queueServiceUri) + /// + /// configuration section for a given connection name + /// The subdomain of the serviceUri (i.e. blob, queue, table) + /// The serviceUri for the specific resource (i.e. blobServiceUri or queueServiceUri) + internal static bool TryGetServiceUriForStorageAccounts(this IConfiguration configuration, string subDomain, out Uri serviceUri) + { + if (subDomain is null) + { + throw new ArgumentNullException(nameof(subDomain)); + } + var serviceUriConfig = string.Format(CultureInfo.InvariantCulture, "{0}ServiceUri", subDomain); + + if (configuration.GetValue("accountName") is { } accountName) + { + serviceUri = FormatServiceUri(accountName, subDomain); + return true; + } + else if (configuration.GetValue($"{subDomain}ServiceUri") is { } uriStr) + { + serviceUri = new Uri(uriStr); + return true; + } + + serviceUri = default(Uri)!; + return false; + } + + /// + /// Generates the serviceUri for a particular storage resource + /// + private static Uri FormatServiceUri(string accountName, string subDomain, string defaultProtocol = "https", string endpointSuffix = "core.windows.net") + { + var uri = string.Format(CultureInfo.InvariantCulture, "{0}://{1}.{2}.{3}", defaultProtocol, accountName, subDomain, endpointSuffix); + return new Uri(uri); + } + /// /// Creates a WebJobs specific prefixed string using a given connection name. /// @@ -58,4 +99,4 @@ private static IConfigurationSection GetConnectionStringOrSetting(this IConfigur return configuration.GetSection(connectionName); } } -} \ No newline at end of file +} diff --git a/extensions/Worker.Extensions.Storage.Blobs/src/Config/BlobStorageBindingOptionsSetup.cs b/extensions/Worker.Extensions.Storage.Blobs/src/Config/BlobStorageBindingOptionsSetup.cs index 654653702..0da09c4e3 100644 --- a/extensions/Worker.Extensions.Storage.Blobs/src/Config/BlobStorageBindingOptionsSetup.cs +++ b/extensions/Worker.Extensions.Storage.Blobs/src/Config/BlobStorageBindingOptionsSetup.cs @@ -1,4 +1,4 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. See License.txt in the project root for license information. using System; @@ -52,7 +52,7 @@ public void Configure(string name, BlobStorageBindingOptions options) } else { - if (TryGetServiceUri(connectionSection, out Uri serviceUri)) + if (connectionSection.TryGetServiceUriForStorageAccounts(BlobServiceUriSubDomain, out Uri serviceUri)) { options.ServiceUri = serviceUri; } @@ -61,39 +61,5 @@ public void Configure(string name, BlobStorageBindingOptions options) options.BlobClientOptions = (BlobClientOptions)_componentFactory.CreateClientOptions(typeof(BlobClientOptions), null, connectionSection); options.Credential = _componentFactory.CreateTokenCredential(connectionSection); } - - /// - /// Either constructs the serviceUri from the provided accountName - /// or retrieves the serviceUri for the specific resource (i.e. blobServiceUri or queueServiceUri) - /// - private bool TryGetServiceUri(IConfiguration configuration, out Uri serviceUri) - { - var serviceUriConfig = string.Format(CultureInfo.InvariantCulture, "{0}ServiceUri", BlobServiceUriSubDomain); - - string accountName; - string uriStr; - if ((accountName = configuration.GetValue("accountName")) is not null) - { - serviceUri = FormatServiceUri(accountName); - return true; - } - else if ((uriStr = configuration.GetValue(serviceUriConfig)) is not null) - { - serviceUri = new Uri(uriStr); - return true; - } - - serviceUri = default(Uri)!; - return false; - } - - /// - /// Generates the serviceUri for a particular storage resource - /// - private Uri FormatServiceUri(string accountName, string defaultProtocol = "https", string endpointSuffix = "core.windows.net") - { - var uri = string.Format(CultureInfo.InvariantCulture, "{0}://{1}.{2}.{3}", defaultProtocol, accountName, BlobServiceUriSubDomain, endpointSuffix); - return new Uri(uri); - } } -} \ No newline at end of file +} diff --git a/extensions/Worker.Extensions.Tables/release_notes.md b/extensions/Worker.Extensions.Tables/release_notes.md index 62b6451d2..63cbbd189 100644 --- a/extensions/Worker.Extensions.Tables/release_notes.md +++ b/extensions/Worker.Extensions.Tables/release_notes.md @@ -6,4 +6,4 @@ ### Microsoft.Azure.Functions.Worker.Extensions.Tables -- +- Add abiility to bind table input to TableClient, TableEntity, and IEnumerable (#1470) \ No newline at end of file diff --git a/extensions/Worker.Extensions.Tables/src/Config/TablesBindingOptions.cs b/extensions/Worker.Extensions.Tables/src/Config/TablesBindingOptions.cs new file mode 100644 index 000000000..bc91b2b8e --- /dev/null +++ b/extensions/Worker.Extensions.Tables/src/Config/TablesBindingOptions.cs @@ -0,0 +1,41 @@ +// 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 Azure.Core; +using Azure.Data.Tables; + +namespace Microsoft.Azure.Functions.Worker.Extensions.Tables.Config +{ + internal class TablesBindingOptions + { + public string? ConnectionString { get; set; } + + public Uri? ServiceUri { get; set; } + + public TokenCredential? Credential { get; set; } + + public TableClientOptions? TableClientOptions { get; set; } + + private TableServiceClient? TableServiceClient; + + internal virtual TableServiceClient CreateClient() + { + if (ConnectionString is null && ServiceUri is null) + { + throw new ArgumentNullException(nameof(ConnectionString) + " " + nameof(ServiceUri)); + } + + if (TableServiceClient is not null) + { + return TableServiceClient; + } + + TableServiceClient = !string.IsNullOrEmpty(ConnectionString) + ? new TableServiceClient(ConnectionString, TableClientOptions) // Connection string based auth; + : new TableServiceClient(ServiceUri, Credential, TableClientOptions); // AAD auth + + return TableServiceClient; + } + } +} diff --git a/extensions/Worker.Extensions.Tables/src/Config/TablesBindingOptionsSetup.cs b/extensions/Worker.Extensions.Tables/src/Config/TablesBindingOptionsSetup.cs new file mode 100644 index 000000000..88d69b761 --- /dev/null +++ b/extensions/Worker.Extensions.Tables/src/Config/TablesBindingOptionsSetup.cs @@ -0,0 +1,59 @@ +using System; +using Azure.Data.Tables; +using Microsoft.Extensions.Azure; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; + +namespace Microsoft.Azure.Functions.Worker.Extensions.Tables.Config +{ + internal class TablesBindingOptionsSetup : IConfigureNamedOptions + { + private readonly IConfiguration _configuration; + private readonly AzureComponentFactory _componentFactory; + + private const string TablesServiceUriSubDomain = "table"; + + public TablesBindingOptionsSetup(IConfiguration configuration, AzureComponentFactory componentFactory) + { + _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); + _componentFactory = componentFactory ?? throw new ArgumentNullException(nameof(componentFactory)); + } + + public void Configure(TablesBindingOptions options) + { + Configure(Options.DefaultName, options); + } + + public void Configure(string name, TablesBindingOptions options) + { + if (string.IsNullOrWhiteSpace(name)) + { + name = Constants.Storage; // default + } + + IConfigurationSection connectionSection = _configuration.GetWebJobsConnectionStringSection(name); + + if (!connectionSection.Exists()) + { + // Not found + throw new InvalidOperationException($"Tables 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 + { + if (connectionSection.TryGetServiceUriForStorageAccounts(TablesServiceUriSubDomain, out Uri serviceUri)) + { + options.ServiceUri = serviceUri; + } + } + + options.TableClientOptions = (TableClientOptions)_componentFactory.CreateClientOptions(typeof(TableClientOptions), null, connectionSection); + options.Credential = _componentFactory.CreateTokenCredential(connectionSection); + } + } +} diff --git a/extensions/Worker.Extensions.Tables/src/Constants.cs b/extensions/Worker.Extensions.Tables/src/Constants.cs new file mode 100644 index 000000000..2f6667178 --- /dev/null +++ b/extensions/Worker.Extensions.Tables/src/Constants.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace Microsoft.Azure.Functions.Worker.Extensions.Tables +{ + internal class Constants + { + internal const string Storage = "Storage"; + internal const string TablesExtensionName = "AzureStorageTables"; + internal const string TableName = "TableName"; + internal const string PartitionKey = "PartitionKey"; + internal const string RowKey = "RowKey"; + internal const string Connection = "Connection"; + internal const string Take = "Take"; + internal const string Filter = "Filter"; + // Media content types + internal const string JsonContentType = "application/json"; + } +} diff --git a/extensions/Worker.Extensions.Tables/src/Nuget.config b/extensions/Worker.Extensions.Tables/src/Nuget.config new file mode 100644 index 000000000..5e150b6ee --- /dev/null +++ b/extensions/Worker.Extensions.Tables/src/Nuget.config @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/extensions/Worker.Extensions.Tables/src/Properties/AssemblyInfo.cs b/extensions/Worker.Extensions.Tables/src/Properties/AssemblyInfo.cs index 5ac8b0c07..540c7067e 100644 --- a/extensions/Worker.Extensions.Tables/src/Properties/AssemblyInfo.cs +++ b/extensions/Worker.Extensions.Tables/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 Microsoft.Azure.Functions.Worker.Extensions.Abstractions; +using System.Runtime.CompilerServices; +using Microsoft.Azure.Functions.Worker.Extensions.Abstractions; -[assembly: ExtensionInformation("Microsoft.Azure.WebJobs.Extensions.Tables", "1.0.0")] +[assembly: ExtensionInformation("Microsoft.Azure.WebJobs.Extensions.Tables", "1.2.0-beta.1")] +[assembly: InternalsVisibleTo("Microsoft.Azure.Functions.WorkerExtension.Tests, PublicKey=00240000048000009400000006020000002400005253413100040000010001005148be37ac1d9f58bd40a2e472c9d380d635b6048278f7d47480b08c928858f0f7fe17a6e4ce98da0e7a7f0b8c308aecd9e9b02d7e9680a5b5b75ac7773cec096fbbc64aebd429e77cb5f89a569a79b28e9c76426783f624b6b70327eb37341eb498a2c3918af97c4860db6cdca4732787150841e395a29cfacb959c1fd971c1")] +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c547cac37abd99c8db225ef2f6c8a3602f3b3606cc9891605d02baa56104f4cfc0734aa39b93bf7852f7d9266654753cc297e7d2edfe0bac1cdcf9f717241550e0a7b191195b7667bb4f64bcb8e2121380fd1d9d46ad2d92d2d15605093924cceaf74c4861eff62abf69b9291ed0a340e113be11e6a7d3113e92484cf7045cc7")] diff --git a/extensions/Worker.Extensions.Tables/src/TableExtensionStartup.cs b/extensions/Worker.Extensions.Tables/src/TableExtensionStartup.cs new file mode 100644 index 000000000..d20fd8d32 --- /dev/null +++ b/extensions/Worker.Extensions.Tables/src/TableExtensionStartup.cs @@ -0,0 +1,36 @@ +// 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.Azure.Functions.Worker.Extensions.Tables.Config; +using Microsoft.Extensions.Azure; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +[assembly: WorkerExtensionStartup(typeof(TableExtensionStartup))] + +namespace Microsoft.Azure.Functions.Worker +{ + /// + /// Table extension startup. + /// + public class TableExtensionStartup : WorkerExtensionStartup + { + /// + /// Configure table extension startup. + /// + 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, TablesBindingOptionsSetup>(); + } + } +} diff --git a/extensions/Worker.Extensions.Tables/src/TableInputAttribute.cs b/extensions/Worker.Extensions.Tables/src/TableInputAttribute.cs index b1d2f6d1b..125f8a5e6 100644 --- a/extensions/Worker.Extensions.Tables/src/TableInputAttribute.cs +++ b/extensions/Worker.Extensions.Tables/src/TableInputAttribute.cs @@ -1,13 +1,19 @@ // 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.Converters; using Microsoft.Azure.Functions.Worker.Extensions.Abstractions; +using Microsoft.Azure.Functions.Worker.Extensions.Tables.TypeConverters; namespace Microsoft.Azure.Functions.Worker { /// /// Attribute used to configure a parameter as the input target for the Azure Storage Tables binding. /// + [AllowConverterFallback(true)] + [InputConverter(typeof(TableClientConverter))] + [InputConverter(typeof(TableEntityConverter))] + [InputConverter(typeof(TableEntityEnumerableConverter))] public class TableInputAttribute : InputBindingAttribute { /// Initializes a new instance of the class. @@ -37,6 +43,17 @@ public TableInputAttribute(string tableName, string partitionKey, string rowKey) RowKey = rowKey; } + /// Initializes a new instance of the class. + /// The name of the table containing the entity. + /// The partition key of the entity. + /// The number of entities to return + public TableInputAttribute(string tableName, string partitionKey, int take) + { + TableName = tableName; + PartitionKey = partitionKey; + Take = take; + } + /// Gets the name of the table to which to bind. /// When binding to a table entity, gets the name of the table containing the entity. public string TableName { get; } diff --git a/extensions/Worker.Extensions.Tables/src/TypeConverters/TableClientConverter.cs b/extensions/Worker.Extensions.Tables/src/TypeConverters/TableClientConverter.cs new file mode 100644 index 000000000..dc0a761c9 --- /dev/null +++ b/extensions/Worker.Extensions.Tables/src/TypeConverters/TableClientConverter.cs @@ -0,0 +1,65 @@ +// 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.Tasks; +using Azure.Data.Tables; +using Microsoft.Azure.Functions.Worker.Converters; +using Microsoft.Azure.Functions.Worker.Extensions.Abstractions; +using Microsoft.Azure.Functions.Worker.Extensions.Tables.Config; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Logging; +using Microsoft.Azure.Functions.Worker.Core; + +namespace Microsoft.Azure.Functions.Worker.Extensions.Tables.TypeConverters +{ + /// + /// Converter to bind Table client parameter. + /// + [SupportsDeferredBinding] + [SupportedConverterType(typeof(TableClient))] + internal class TableClientConverter: TableConverterBase + { + public TableClientConverter(IOptionsSnapshot tableOptions, ILogger logger) + : base(tableOptions, logger) + { + } + + public override ValueTask ConvertAsync(ConverterContext context) + { + if (!CanConvert(context)) + { + return new(ConversionResult.Unhandled()); + } + try + { + var modelBindingData = context?.Source as ModelBindingData; + var tableData = GetBindingDataContent(modelBindingData); + + var result = ConvertModelBindingData(tableData); + + if (result is not null) + { + return new(ConversionResult.Success(result)); + } + } + catch (Exception ex) + { + return new(ConversionResult.Failed(ex)); + } + + return new(ConversionResult.Unhandled()); + } + + private TableClient ConvertModelBindingData(TableData content) + { + if (string.IsNullOrEmpty(content.TableName)) + { + throw new ArgumentNullException(nameof(content.TableName)); + } + + return GetTableClient(content.Connection, content.TableName!); + } + } +} diff --git a/extensions/Worker.Extensions.Tables/src/TypeConverters/TableConverterBase.cs b/extensions/Worker.Extensions.Tables/src/TypeConverters/TableConverterBase.cs new file mode 100644 index 000000000..7308f25bb --- /dev/null +++ b/extensions/Worker.Extensions.Tables/src/TypeConverters/TableConverterBase.cs @@ -0,0 +1,80 @@ +// 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.Tasks; +using Azure.Data.Tables; +using Microsoft.Azure.Functions.Worker.Converters; +using Microsoft.Azure.Functions.Worker.Core; +using Microsoft.Azure.Functions.Worker.Extensions.Tables.Config; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Microsoft.Azure.Functions.Worker.Extensions.Tables.TypeConverters +{ + internal abstract class TableConverterBase : IInputConverter + { + private readonly ILogger> _logger; + protected readonly IOptionsSnapshot _tableOptions; + + public TableConverterBase(IOptionsSnapshot tableOptions, ILogger> logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _tableOptions = tableOptions ?? throw new ArgumentNullException(nameof(tableOptions)); + } + + protected bool CanConvert(ConverterContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (context.TargetType != typeof(T)) + { + return false; + } + + if (!(context.Source is ModelBindingData bindingData)) + { + return false; + } + + if (bindingData.Source is not Constants.TablesExtensionName) + { + return false; + } + + return true; + } + + public abstract ValueTask ConvertAsync(ConverterContext context); + + protected TableData GetBindingDataContent(ModelBindingData? bindingData) + { + return bindingData?.ContentType switch + { + Constants.JsonContentType => bindingData.Content.ToObjectFromJson(), + _ => throw new NotSupportedException($"Unexpected content-type. Currently only '{Constants.JsonContentType}' is supported.") + }; + } + + protected TableClient GetTableClient(string? connection, string tableName) + { + var tableOptions = _tableOptions.Get(connection); + TableServiceClient tableServiceClient = tableOptions.CreateClient(); + return tableServiceClient.GetTableClient(tableName); + } + + protected class TableData + { + public string? TableName { get; set; } + public string? Connection { get; set; } + public string? PartitionKey { get; set; } + public string? RowKey { get; set; } + public int Take { get; set; } + public string? Filter { get; set; } + } + } +} diff --git a/extensions/Worker.Extensions.Tables/src/TypeConverters/TableEntityConverter.cs b/extensions/Worker.Extensions.Tables/src/TypeConverters/TableEntityConverter.cs new file mode 100644 index 000000000..244e6a903 --- /dev/null +++ b/extensions/Worker.Extensions.Tables/src/TypeConverters/TableEntityConverter.cs @@ -0,0 +1,83 @@ +// 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.Tasks; +using Azure.Data.Tables; +using Microsoft.Azure.Functions.Worker.Converters; +using Microsoft.Azure.Functions.Worker.Core; +using Microsoft.Azure.Functions.Worker.Extensions.Abstractions; +using Microsoft.Azure.Functions.Worker.Extensions.Tables.Config; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Azure.Functions.Worker.Extensions.Tables.TypeConverters +{ + /// + /// Converter to bind Table type parameters. + /// + [SupportsDeferredBinding] + [SupportedConverterType(typeof(TableEntity))] + internal class TableEntityConverter : TableConverterBase + { + public TableEntityConverter(IOptionsSnapshot tableOptions, ILogger logger) + : base(tableOptions, logger) + { + } + + public override async ValueTask ConvertAsync(ConverterContext context) + { + if (!CanConvert(context)) + { + return ConversionResult.Unhandled(); + } + + try + { + var modelBindingData = context?.Source as ModelBindingData; + var tableData = GetBindingDataContent(modelBindingData); + + var result = await ConvertModelBindingData(tableData); + + if (result is not null) + { + return ConversionResult.Success(result); + } + + } + catch (Exception ex) + { + return ConversionResult.Failed(ex); + } + + return ConversionResult.Unhandled(); + } + + private async Task ConvertModelBindingData(TableData content) + { + if (string.IsNullOrEmpty(content.TableName)) + { + throw new ArgumentNullException(nameof(content.TableName)); + } + + if (string.IsNullOrEmpty(content.PartitionKey)) + { + throw new ArgumentNullException(nameof(content.PartitionKey)); + } + + if (string.IsNullOrEmpty(content.RowKey)) + { + throw new ArgumentNullException(nameof(content.RowKey)); + } + + return await GetTableEntity(content); + } + + private async Task GetTableEntity(TableData content) + { + var tableClient = GetTableClient(content.Connection, content.TableName!); + return await tableClient.GetEntityAsync(content.PartitionKey, content.RowKey); + } + } +} diff --git a/extensions/Worker.Extensions.Tables/src/TypeConverters/TableEntityEnumerableConverter.cs b/extensions/Worker.Extensions.Tables/src/TypeConverters/TableEntityEnumerableConverter.cs new file mode 100644 index 000000000..0479fdaa1 --- /dev/null +++ b/extensions/Worker.Extensions.Tables/src/TypeConverters/TableEntityEnumerableConverter.cs @@ -0,0 +1,108 @@ +// 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.Tasks; +using Azure.Data.Tables; +using Microsoft.Azure.Functions.Worker.Converters; +using Microsoft.Azure.Functions.Worker.Core; +using Microsoft.Azure.Functions.Worker.Extensions.Abstractions; +using Microsoft.Azure.Functions.Worker.Extensions.Tables.Config; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Azure.Functions.Worker.Extensions.Tables.TypeConverters +{ + /// + /// Converter to bind Table type parameters. + /// + [SupportsDeferredBinding] + [SupportedConverterType(typeof(IEnumerable))] + internal class TableEntityEnumerableConverter : TableConverterBase> + { + public TableEntityEnumerableConverter(IOptionsSnapshot tableOptions, ILogger logger) + : base(tableOptions, logger) + { + } + + public override async ValueTask ConvertAsync(ConverterContext context) + { + if (!CanConvert(context)) + { + return ConversionResult.Unhandled(); + } + try + { + var modelBindingData = context?.Source as ModelBindingData; + var tableData = GetBindingDataContent(modelBindingData); + + var result = await ConvertModelBindingData(tableData); + + if (result is not null) + { + return ConversionResult.Success(result); + } + } + catch (Exception ex) + { + return ConversionResult.Failed(ex); + } + + return ConversionResult.Unhandled(); + } + + private async Task> ConvertModelBindingData(TableData content) + { + if (string.IsNullOrEmpty(content.TableName)) + { + throw new ArgumentNullException(nameof(content.TableName)); + } + + if (content.RowKey != null && (content.Take > 0 || content.Filter != null)) + { + throw new InvalidOperationException($"Row key {content.RowKey} cannot have a value if {content.Take} or {content.Filter} are defined"); + } + + return await GetEnumerableTableEntity(content); + } + + private async Task> GetEnumerableTableEntity(TableData content) + { + var tableClient = GetTableClient(content.Connection, content.TableName!); + string? filter = content.Filter; + + if (!string.IsNullOrEmpty(content.PartitionKey)) + { + var partitionKeyPredicate = TableClient.CreateQueryFilter($"PartitionKey eq {content.PartitionKey}"); + filter = !string.IsNullOrEmpty(content.Filter) ? $"{partitionKeyPredicate} and {content.Filter}" : partitionKeyPredicate; + } + + int? maxPerPage = null; + if (content.Take > 0) + { + maxPerPage = content.Take; + } + + int countRemaining = content.Take; + + var entities = tableClient.QueryAsync( + filter: filter, + maxPerPage: maxPerPage).ConfigureAwait(false); + + List bindingDataContent = new List(); + + await foreach (var entity in entities) + { + countRemaining--; + bindingDataContent.Add(entity); + if (countRemaining == 0) + { + break; + } + } + return bindingDataContent; + } + } +} + diff --git a/extensions/Worker.Extensions.Tables/src/Worker.Extensions.Tables.csproj b/extensions/Worker.Extensions.Tables/src/Worker.Extensions.Tables.csproj index 2868a547c..6ad057dc0 100644 --- a/extensions/Worker.Extensions.Tables/src/Worker.Extensions.Tables.csproj +++ b/extensions/Worker.Extensions.Tables/src/Worker.Extensions.Tables.csproj @@ -6,7 +6,8 @@ Azure Table Storage extensions for .NET isolated functions - 1.0.0 + 1.2.0 + -preview1 @@ -14,6 +15,18 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/samples/WorkerBindingSamples/NuGet.Config b/samples/WorkerBindingSamples/NuGet.Config deleted file mode 100644 index 3f0e00340..000000000 --- a/samples/WorkerBindingSamples/NuGet.Config +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/samples/WorkerBindingSamples/Table/TableInputBindingSamples.cs b/samples/WorkerBindingSamples/Table/TableInputBindingSamples.cs new file mode 100644 index 000000000..cd7cc27f8 --- /dev/null +++ b/samples/WorkerBindingSamples/Table/TableInputBindingSamples.cs @@ -0,0 +1,118 @@ +// 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 Azure.Data.Tables; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; + +namespace WorkerBindingSamples.Table +{ + public class TableInputBindingSamples + { + private readonly ILogger _logger; + + public TableInputBindingSamples(ILogger logger) + { + _logger = logger; + } + + [Function(nameof(TableClientFunction))] + public async Task TableClientFunction( + [HttpTrigger(AuthorizationLevel.Function, "get", "post")] HttpRequestData req, + [TableInput("TableName")] TableClient table) + { + var tableEntity = table.QueryAsync(); + var response = req.CreateResponse(HttpStatusCode.OK); + + await foreach (TableEntity val in tableEntity) + { + val.TryGetValue("Text", out var text); + _logger.LogInformation("Value of text: " + text); + + await response.WriteStringAsync(text?.ToString() ?? ""); + } + + return response; + } + + [Function(nameof(ReadTableDataFunction))] + public async Task ReadTableDataFunction( + [HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = "items/{partitionKey}/{rowKey}")] HttpRequestData req, + [TableInput("TableName", "{partitionKey}", "{rowKey}")] TableEntity table) + + { + table.TryGetValue("Text", out var text); + var response = req.CreateResponse(HttpStatusCode.OK); + await response.WriteStringAsync(text?.ToString() ?? ""); + return response; + } + + [Function(nameof(ReadTableDataFunctionWithFilter))] + public async Task ReadTableDataFunctionWithFilter( + [HttpTrigger(AuthorizationLevel.Function, "get", "post")] HttpRequestData req, + [TableInput("TableName", "My Partition", 2, Filter = "RowKey ne 'value'")] IEnumerable table) + + { + List tableList = new(); + var response = req.CreateResponse(HttpStatusCode.OK); + + foreach (TableEntity tableEntity in table) + { + tableEntity.TryGetValue("Text", out var text); + _logger.LogInformation("Value of text: " + text); + tableList.Add(text?.ToString() ?? ""); + } + + await response.WriteStringAsync(string.Join(",", tableList)); + return response; + } + + [Function(nameof(EnumerableFunction))] + public async Task EnumerableFunction( + [HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = "items/{partitionKey}")] HttpRequestData req, + [TableInput("TableName", "{partitionKey}")] IEnumerable tables) + + { + var response = req.CreateResponse(HttpStatusCode.OK); + List tableList = new(); + + foreach (TableEntity tableEntity in tables) + { + tableEntity.TryGetValue("Text", out var text); + _logger.LogInformation("Value of text: " + text); + tableList.Add((text?.ToString()) ?? ""); + } + + await response.WriteStringAsync(string.Join(",", tableList)); + return response; + } + + [Function(nameof(PocoFunction))] + public async Task PocoFunction( + [HttpTrigger(AuthorizationLevel.Function, "get","post", Route = null)] HttpRequestData req, + [TableInput("TableName")] IEnumerable entities, + FunctionContext executionContext) + { + var response = req.CreateResponse(HttpStatusCode.OK); + List entityList = new(); + + foreach (MyEntity entity in entities) + { + _logger.LogInformation($"Text: {entity.Text}"); + entityList.Add((entity.Text ?? "").ToString()); + } + + await response.WriteStringAsync(string.Join(",", entityList)); + return response; + } + } + + public class MyEntity + { + public string? Text { get; set; } + public string? PartitionKey { get; set; } + public string? RowKey { get; set; } + } +} diff --git a/samples/WorkerBindingSamples/WorkerBindingSamples.csproj b/samples/WorkerBindingSamples/WorkerBindingSamples.csproj index 5e4c33b89..b804f5429 100644 --- a/samples/WorkerBindingSamples/WorkerBindingSamples.csproj +++ b/samples/WorkerBindingSamples/WorkerBindingSamples.csproj @@ -7,11 +7,12 @@ enable - + + diff --git a/test/DotNetWorkerTests/DotNetWorkerTests.csproj b/test/DotNetWorkerTests/DotNetWorkerTests.csproj index 4fb9cfa48..0b7cf4c2a 100644 --- a/test/DotNetWorkerTests/DotNetWorkerTests.csproj +++ b/test/DotNetWorkerTests/DotNetWorkerTests.csproj @@ -26,6 +26,7 @@ + diff --git a/test/DotNetWorkerTests/GrpcFunctionDefinitionTests.cs b/test/DotNetWorkerTests/GrpcFunctionDefinitionTests.cs index 34dc65348..72d56e561 100644 --- a/test/DotNetWorkerTests/GrpcFunctionDefinitionTests.cs +++ b/test/DotNetWorkerTests/GrpcFunctionDefinitionTests.cs @@ -6,6 +6,7 @@ using System.IO; using System.Linq; using System.Threading; +using Azure.Data.Tables; using Microsoft.Azure.Functions.Tests; using Microsoft.Azure.Functions.Worker.Converters; using Microsoft.Azure.Functions.Worker.Core; @@ -146,6 +147,73 @@ public void GrpcFunctionDefinition_BlobInput_Creates() }); } + [Fact] + public void GrpcFunctionDefinition_TableInput_Creates() + { + using var testVariables = new TestScopedEnvironmentVariable("FUNCTIONS_WORKER_DIRECTORY", "."); + + var bindingInfoProvider = new DefaultOutputBindingsInfoProvider(); + var methodInfoLocator = new DefaultMethodInfoLocator(); + + string fullPathToThisAssembly = GetType().Assembly.Location; + var functionLoadRequest = new FunctionLoadRequest + { + FunctionId = "abc", + Metadata = new RpcFunctionMetadata + { + EntryPoint = $"Microsoft.Azure.Functions.Worker.Tests.{nameof(GrpcFunctionDefinitionTests)}+{nameof(MyTableFunctionClass)}.{nameof(MyTableFunctionClass.Run)}", + ScriptFile = Path.GetFileName(fullPathToThisAssembly), + Name = "myfunction" + } + }; + + // We base this on the request exclusively, not the binding attributes. + functionLoadRequest.Metadata.Bindings.Add("req", new BindingInfo { Type = "HttpTrigger", Direction = Direction.In }); + functionLoadRequest.Metadata.Bindings.Add("$return", new BindingInfo { Type = "Http", Direction = Direction.Out }); + + FunctionDefinition definition = functionLoadRequest.ToFunctionDefinition(methodInfoLocator); + + Assert.Equal(functionLoadRequest.FunctionId, definition.Id); + Assert.Equal(functionLoadRequest.Metadata.EntryPoint, definition.EntryPoint); + Assert.Equal(functionLoadRequest.Metadata.Name, definition.Name); + Assert.Equal(fullPathToThisAssembly, definition.PathToAssembly); + + // Parameters + Assert.Collection(definition.Parameters, + p => + { + Assert.Equal("req", p.Name); + Assert.Equal(typeof(HttpRequestData), p.Type); + }, + q => + { + Assert.Equal("tableInput", q.Name); + Assert.Equal(typeof(TableClient), q.Type); + Assert.Contains(PropertyBagKeys.AllowConverterFallback, q.Properties.Keys); + Assert.Contains(PropertyBagKeys.BindingAttributeSupportedConverters, q.Properties.Keys); + Assert.True(true, q.Properties[PropertyBagKeys.AllowConverterFallback].ToString()); + Assert.Contains(new Dictionary>().ToString(), q.Properties[PropertyBagKeys.BindingAttributeSupportedConverters].ToString()); + }); + + // InputBindings + Assert.Collection(definition.InputBindings, + p => + { + Assert.Equal("req", p.Key); + Assert.Equal(BindingDirection.In, p.Value.Direction); + Assert.Equal("HttpTrigger", p.Value.Type); + }); + + // OutputBindings + Assert.Collection(definition.OutputBindings, + p => + { + Assert.Equal("$return", p.Key); + Assert.Equal(BindingDirection.Out, p.Value.Direction); + Assert.Equal("Http", p.Value.Type); + }); + } + private class MyFunctionClass { @@ -173,5 +241,14 @@ public HttpResponseData Run( } } + private class MyTableFunctionClass + { + public HttpResponseData Run( + [HttpTrigger(AuthorizationLevel.Function, "get", "post")] HttpRequestData req, + [TableInput("tableName")] TableClient tableInput) + { + return req.CreateResponse(); + } + } } } diff --git a/test/E2ETests/E2EApps/E2EApp/E2EApp.csproj b/test/E2ETests/E2EApps/E2EApp/E2EApp.csproj index efcb5a6fa..218be1a05 100644 --- a/test/E2ETests/E2EApps/E2EApp/E2EApp.csproj +++ b/test/E2ETests/E2EApps/E2EApp/E2EApp.csproj @@ -25,6 +25,7 @@ + @@ -44,7 +45,7 @@ - + diff --git a/test/E2ETests/E2EApps/E2EApp/Table/TableInputBindingFunctions.cs b/test/E2ETests/E2EApps/E2EApp/Table/TableInputBindingFunctions.cs new file mode 100644 index 000000000..d1b2ac632 --- /dev/null +++ b/test/E2ETests/E2EApps/E2EApp/Table/TableInputBindingFunctions.cs @@ -0,0 +1,109 @@ +// 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.Net; +using System.Threading.Tasks; +using Azure.Data.Tables; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Azure.Functions.Worker.E2EApp.Table +{ + public class TableInputBindingFunction + { + private readonly ILogger _logger; + + public TableInputBindingFunction(ILogger logger) + { + _logger = logger; + } + + [Function(nameof(TableClientFunction))] + public async Task TableClientFunction( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", "post")] HttpRequestData req, + [TableInput("TableName")] TableClient table) + { + var tableEntity = table.QueryAsync(); + var response = req.CreateResponse(HttpStatusCode.OK); + + await foreach (TableEntity val in tableEntity) + { + val.TryGetValue("Text", out var text); + + await response.WriteStringAsync(text?.ToString() ?? ""); + } + + return response; + } + + + [Function(nameof(ReadTableDataFunction))] + public async Task ReadTableDataFunction( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", "post", Route = "ReadTableDataFunction/items/{partitionKey}/{rowKey}")] HttpRequestData req, + [TableInput("TableName", "{partitionKey}", "{rowKey}")] TableEntity table) + + { + table.TryGetValue("Text", out var text); + var response = req.CreateResponse(HttpStatusCode.OK); + await response.WriteStringAsync(text?.ToString() ?? ""); + return response; + } + + [Function(nameof(ReadTableDataFunctionWithFilter))] + public async Task ReadTableDataFunctionWithFilter( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", "post", Route = "ReadTableDataFunctionWithFilter/items/{partition}/{rowKey}")] HttpRequestData req, + [TableInput("TableName", "{partition}", 2, Filter = "RowKey ne '" + "{rowKey}'")] IEnumerable table) + { + List tableList = new(); + var response = req.CreateResponse(HttpStatusCode.OK); + + foreach (TableEntity tableEntity in table) + { + tableEntity.TryGetValue("Text", out var text); + _logger.LogInformation("Value of text: " + text); + tableList.Add(text?.ToString() ?? ""); + } + + await response.WriteStringAsync(string.Join(",", tableList)); + return response; + } + + [Function(nameof(EnumerableFunction))] + public async Task EnumerableFunction( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", "post", Route = "EnumerableFunction/items/{partitionKey}")] HttpRequestData req, + [TableInput("TableName", "{partitionKey}")] IEnumerable tables) + + { + var response = req.CreateResponse(HttpStatusCode.OK); + List tableList = new(); + + foreach (TableEntity tableEntity in tables) + { + tableEntity.TryGetValue("Text", out var text); + _logger.LogInformation("Value of text: " + text); + tableList.Add(text?.ToString() ?? ""); + } + + await response.WriteStringAsync(string.Join(",", tableList)); + return response; + } + + public class MyPoco + { + public string PartitionKey { get; set; } + public string RowKey { get; set; } + public string Text { get; set; } + } + + [Function("DoesNotSupportDeferredBinding")] + public static void TableInput( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", "post")] HttpRequestData req, + [TableInput("MyTable", "MyPartition", "yo")] MyPoco poco, + ILogger log) + { + log.LogInformation($"PK={poco.PartitionKey}, RK={poco.RowKey}, Text={poco.Text}"); + } + } +} + diff --git a/test/E2ETests/E2ETests/Constants.cs b/test/E2ETests/E2ETests/Constants.cs index f9cd316f0..86648e130 100644 --- a/test/E2ETests/E2ETests/Constants.cs +++ b/test/E2ETests/E2ETests/Constants.cs @@ -86,5 +86,13 @@ public static class Cardinality_One_Test // Xunit Fixtures and Collections public const string FunctionAppCollectionName = "FunctionAppCollection"; + + // Tables tests + public static class Tables + { + public const string EmulatorConnectionString = "UseDevelopmentStorage=true"; + public const string TablesConnectionStringSetting = EmulatorConnectionString; + public const string TableName = "TableName"; + } } } diff --git a/test/E2ETests/E2ETests/E2ETests.csproj b/test/E2ETests/E2ETests/E2ETests.csproj index 56fad6d55..f7cbb5240 100644 --- a/test/E2ETests/E2ETests/E2ETests.csproj +++ b/test/E2ETests/E2ETests/E2ETests.csproj @@ -19,6 +19,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/test/E2ETests/E2ETests/Helpers/TableHelpers.cs b/test/E2ETests/E2ETests/Helpers/TableHelpers.cs new file mode 100644 index 000000000..b37055e79 --- /dev/null +++ b/test/E2ETests/E2ETests/Helpers/TableHelpers.cs @@ -0,0 +1,33 @@ +using System.Threading.Tasks; +using Azure.Data.Tables; +using Microsoft.Azure.Functions.Tests.E2ETests; + +namespace Microsoft.Azure.Functions.Worker.E2ETests.Helpers +{ + public static class TableHelpers + { + private static readonly TableClient _tableClient; + static TableHelpers() + { + var tableName = Constants.Tables.TableName; + _tableClient = new TableClient(Constants.Tables.TablesConnectionStringSetting, tableName); + } + + public async static Task CreateTable() + { + await _tableClient.CreateIfNotExistsAsync(); + } + + public async static Task DeleteTable() + { + await _tableClient.DeleteAsync(); + } + + public async static Task CreateTableEntity(string partitionKey, string rowKey, string value) + { + var tableEntity = new TableEntity(partitionKey, rowKey); + tableEntity.Add("Text", value); + await _tableClient.AddEntityAsync(tableEntity); + } + } +} diff --git a/test/E2ETests/E2ETests/Tables/TablesEndToEndTests.cs b/test/E2ETests/E2ETests/Tables/TablesEndToEndTests.cs new file mode 100644 index 000000000..79610cb5c --- /dev/null +++ b/test/E2ETests/E2ETests/Tables/TablesEndToEndTests.cs @@ -0,0 +1,150 @@ +// 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.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.Azure.Functions.Worker.E2ETests.Helpers; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.Azure.Functions.Tests.E2ETests.Tables +{ + [Collection(Constants.FunctionAppCollectionName)] + public class TablesEndToEndTests : IDisposable + { + private readonly IDisposable _disposeLog; + private FunctionAppFixture _fixture; + + public TablesEndToEndTests(FunctionAppFixture fixture, ITestOutputHelper testOutput) + { + _fixture = fixture; + _disposeLog = _fixture.TestLogs.UseTestLogger(testOutput); + } + + [Fact] + public async Task Read_TableClient_Data_Succeeds() + { + const string partitionKey = "Partition"; + const string firstRowKey = "FirstRowKey"; + const string firstValue = "FirstValue"; + + // Create the table if it doesn't exist + await TableHelpers.CreateTable(); + + // Add table entity + await TableHelpers.CreateTableEntity(partitionKey, firstRowKey, firstValue); + + //Trigger + HttpResponseMessage response = await HttpHelpers.InvokeHttpTrigger("TableClientFunction"); + string actualMessage = await response.Content.ReadAsStringAsync(); + + //Verify + HttpStatusCode expectedStatusCode = HttpStatusCode.OK; + + Assert.Equal(expectedStatusCode, response.StatusCode); + Assert.Equal(firstValue, actualMessage); + + // Delete table + await TableHelpers.DeleteTable(); + } + + [Fact] + public async Task Read_TableData_Succeeds() + { + const string partitionKey = "Partition"; + const string firstRowKey = "FirstRowKey"; + const string firstValue = "FirstValue"; + + // Create the table if it doesn't exist + await TableHelpers.CreateTable(); + + // Add table entity + await TableHelpers.CreateTableEntity(partitionKey, firstRowKey, firstValue); + + //Trigger + HttpResponseMessage response = await HttpHelpers.InvokeHttpTrigger($"ReadTableDataFunction/items/{partitionKey}/{firstRowKey}"); + string actualMessage = await response.Content.ReadAsStringAsync(); + + //Verify + HttpStatusCode expectedStatusCode = HttpStatusCode.OK; + + Assert.Equal(expectedStatusCode, response.StatusCode); + Assert.Equal(firstValue, actualMessage); + + // Delete table + await TableHelpers.DeleteTable(); + } + + [Fact] + public async Task Read_TableData_With_Filter_And_Take() + { + const string partitionKey = "Partition"; + const string firstRowKey = "FirstRowKey"; + const string firstValue = "FirstValue"; + const string secondRowKey = "SecondRowKey"; + const string secondValue = "SecondValue"; + const string thirdRowKey = "ThirdRowKey"; + const string thirdValue = "ThirdValue"; + + // Create table + await TableHelpers.CreateTable(); + + // Add table entity + await TableHelpers.CreateTableEntity(partitionKey, firstRowKey, firstValue); + await TableHelpers.CreateTableEntity(partitionKey, secondRowKey, secondValue); + await TableHelpers.CreateTableEntity(partitionKey, thirdRowKey, thirdValue); + + //Trigger + HttpResponseMessage response = await HttpHelpers.InvokeHttpTrigger($"ReadTableDataFunctionWithFilter/items/{partitionKey}/{secondRowKey}"); + string actualMessage = await response.Content.ReadAsStringAsync(); + + //Verify + HttpStatusCode expectedStatusCode = HttpStatusCode.OK; + + Assert.Equal(expectedStatusCode, response.StatusCode); + Assert.Equal($"{firstValue},{thirdValue}", actualMessage); + + // Delete table + await TableHelpers.DeleteTable(); + } + + [Fact] + public async Task EnumerableFunction_Succeeds() + { + const string partitionKey = "Partition"; + const string firstRowKey = "FirstRowKey"; + const string firstValue = "FirstValue"; + const string secondRowKey = "SecondRowKey"; + const string secondValue = "SecondValue"; + + // Create table + await TableHelpers.CreateTable(); + + // Add table entity + await TableHelpers.CreateTableEntity(partitionKey, firstRowKey, firstValue); + await TableHelpers.CreateTableEntity(partitionKey, secondRowKey, secondValue); + + //Trigger + HttpResponseMessage response = await HttpHelpers.InvokeHttpTrigger($"EnumerableFunction/items/{partitionKey}"); + string actualMessage = await response.Content.ReadAsStringAsync(); + + //Verify + HttpStatusCode expectedStatusCode = HttpStatusCode.OK; + + Assert.Equal(expectedStatusCode, response.StatusCode); + Assert.Equal($"{firstValue},{secondValue}", actualMessage); + + // Delete table + await TableHelpers.DeleteTable(); + } + + public void Dispose() + { + // Cleanup + TableHelpers.DeleteTable().GetAwaiter().GetResult(); + _disposeLog?.Dispose(); + } + } +} diff --git a/test/FunctionMetadataGeneratorTests/FunctionMetadataGeneratorTests.cs b/test/FunctionMetadataGeneratorTests/FunctionMetadataGeneratorTests.cs index a81889457..c0ea88c28 100644 --- a/test/FunctionMetadataGeneratorTests/FunctionMetadataGeneratorTests.cs +++ b/test/FunctionMetadataGeneratorTests/FunctionMetadataGeneratorTests.cs @@ -10,6 +10,7 @@ using System.Linq; using System.Reflection; using System.Threading.Tasks; +using Azure.Data.Tables; using Azure.Messaging.ServiceBus; using Azure.Storage.Blobs; using Microsoft.Azure.Functions.Tests; @@ -264,7 +265,7 @@ public void BlobStorageFunctions_SDKTypeBindings() b => ValidateBlobInput(b), b => ValidateBlobOutput(b)); - + AssertDictionary(extensions, new Dictionary { { "Microsoft.Azure.WebJobs.Extensions.Storage.Blobs", "5.1.1" }, @@ -440,6 +441,75 @@ void ValidateBlobOutput(ExpandoObject b) } } + [Fact] + public void TableFunctions_SDKTypeBindings() + { + var generator = new FunctionMetadataGenerator(); + var module = ModuleDefinition.ReadModule(_thisAssembly.Location); + var typeDef = TestUtility.GetTypeDefinition(typeof(SDKTypeBindings_Table)); + var functions = generator.GenerateFunctionMetadata(typeDef); + var extensions = generator.Extensions; + + Assert.Equal(5, functions.Count()); + + var tableClientFunction = functions.Single(p => p.Name == "TableClientFunction"); + + ValidateFunction(tableClientFunction, "TableClientFunction", GetEntryPoint(nameof(SDKTypeBindings_Table), nameof(SDKTypeBindings_Table.TableClientFunction)), + b => ValidateTableInput(b)); + + AssertDictionary(extensions, new Dictionary + { + { "Microsoft.Azure.WebJobs.Extensions.Tables", "1.2.0-beta.1" }, + }); + + var tableEntityFunction = functions.Single(p => p.Name == "TableEntityFunction"); + + ValidateFunction(tableEntityFunction, "TableEntityFunction", GetEntryPoint(nameof(SDKTypeBindings_Table), nameof(SDKTypeBindings_Table.TableEntityFunction)), + b => ValidateTableInput(b)); + + + var enumerableTableEntityFunction = functions.Single(p => p.Name == "EnumerableTableEntityFunction"); + + ValidateFunction(enumerableTableEntityFunction, "EnumerableTableEntityFunction", GetEntryPoint(nameof(SDKTypeBindings_Table), nameof(SDKTypeBindings_Table.EnumerableTableEntityFunction)), + b => ValidateTableInput(b)); + + + var tableUnsupportedTypeFunction = functions.Single(p => p.Name == "TableUnsupportedTypeFunction"); + + ValidateFunction(tableUnsupportedTypeFunction, "TableUnsupportedTypeFunction", GetEntryPoint(nameof(SDKTypeBindings_Table), nameof(SDKTypeBindings_Table.TableUnsupportedTypeFunction)), + b => ValidateTableInputBypassDeferredBinding(b)); + + + var tablePocoFunction = functions.Single(p => p.Name == "TablePocoFunction"); + + ValidateFunction(tablePocoFunction, "TablePocoFunction", GetEntryPoint(nameof(SDKTypeBindings_Table), nameof(SDKTypeBindings_Table.TablePocoFunction)), + b => ValidateTableInputBypassDeferredBinding(b)); + + void ValidateTableInput(ExpandoObject b) + { + AssertExpandoObject(b, new Dictionary + { + { "Name", "tableInput" }, + { "Type", "table" }, + { "Direction", "In" }, + { "tableName", "tableName" }, + { "Properties", new Dictionary( ) { { "SupportsDeferredBinding" , "True"} } } + }); + } + + void ValidateTableInputBypassDeferredBinding(ExpandoObject b) + { + AssertExpandoObject(b, new Dictionary + { + { "Name", "tableInput" }, + { "Type", "table" }, + { "Direction", "In" }, + { "tableName", "tableName" }, + { "Properties", new Dictionary( ) { } } + }); + } + } + [Fact] public void TimerFunction() { @@ -1009,7 +1079,6 @@ public object BlobStringToBlobStringFunction( throw new NotImplementedException(); } - [Function("BlobClientToBlobStringFunction")] [BlobOutput("container1/hello.txt", Connection = "MyOtherConnection")] public object BlobClientToBlobStreamFunction( @@ -1047,6 +1116,46 @@ public object BlobPocoToBlobUnsupportedType( } } + private class SDKTypeBindings_Table + { + [Function("TableClientFunction")] + public object TableClientFunction( + [TableInput("tableName")] TableClient tableInput) + { + throw new NotImplementedException(); + } + + + [Function("TableEntityFunction")] + public object TableEntityFunction( + [TableInput("tableName")] TableEntity tableInput) + { + throw new NotImplementedException(); + } + + + [Function("EnumerableTableEntityFunction")] + public object EnumerableTableEntityFunction( + [TableInput("tableName")] IEnumerable tableInput) + { + throw new NotImplementedException(); + } + + [Function("TableUnsupportedTypeFunction")] + public object TableUnsupportedTypeFunction( + [TableInput("tableName")] BinaryData tableInput) + { + throw new NotImplementedException(); + } + + [Function("TablePocoFunction")] + public object TablePocoFunction( + [TableInput("tableName")] Poco tableInput) + { + throw new NotImplementedException(); + } + } + private class SDKTypeBindings_BlobCollection { [Function("BlobStringToBlobStringArray")] diff --git a/test/FunctionMetadataGeneratorTests/SdkTests.csproj b/test/FunctionMetadataGeneratorTests/SdkTests.csproj index 4fb4cbe92..c0728f5ed 100644 --- a/test/FunctionMetadataGeneratorTests/SdkTests.csproj +++ b/test/FunctionMetadataGeneratorTests/SdkTests.csproj @@ -11,6 +11,7 @@ + @@ -23,7 +24,7 @@ - + @@ -34,6 +35,7 @@ + diff --git a/test/SdkE2ETests/Contents/WorkerBindingSamplesOutput/functions.metadata b/test/SdkE2ETests/Contents/WorkerBindingSamplesOutput/functions.metadata index 7b34cc393..eddb53b95 100644 --- a/test/SdkE2ETests/Contents/WorkerBindingSamplesOutput/functions.metadata +++ b/test/SdkE2ETests/Contents/WorkerBindingSamplesOutput/functions.metadata @@ -1,4 +1,190 @@ [ + { + "name": "TableClientFunction", + "scriptFile": "WorkerBindingSamples.dll", + "entryPoint": "WorkerBindingSamples.Table.TableInputBindingSamples.TableClientFunction", + "language": "dotnet-isolated", + "properties": { + "IsCodeless": false + }, + "bindings": [ + { + "name": "req", + "direction": "In", + "type": "httpTrigger", + "authLevel": "Function", + "methods": [ + "get", + "post" + ], + "properties": {} + }, + { + "name": "table", + "direction": "In", + "type": "table", + "tableName": "TableName", + "properties": { + "supportsDeferredBinding": "True" + } + }, + { + "name": "$return", + "type": "http", + "direction": "Out" + } + ] + }, + { + "name": "ReadTableDataFunction", + "scriptFile": "WorkerBindingSamples.dll", + "entryPoint": "WorkerBindingSamples.Table.TableInputBindingSamples.ReadTableDataFunction", + "language": "dotnet-isolated", + "properties": { + "IsCodeless": false + }, + "bindings": [ + { + "name": "req", + "direction": "In", + "type": "httpTrigger", + "authLevel": "Function", + "methods": [ + "get", + "post" + ], + "route": "items/{partitionKey}/{rowKey}", + "properties": {} + }, + { + "name": "table", + "direction": "In", + "type": "table", + "tableName": "TableName", + "partitionKey": "{partitionKey}", + "rowKey": "{rowKey}", + "properties": { + "supportsDeferredBinding": "True" + } + }, + { + "name": "$return", + "type": "http", + "direction": "Out" + } + ] + }, + { + "name": "ReadTableDataFunctionWithFilter", + "scriptFile": "WorkerBindingSamples.dll", + "entryPoint": "WorkerBindingSamples.Table.TableInputBindingSamples.ReadTableDataFunctionWithFilter", + "language": "dotnet-isolated", + "properties": { + "IsCodeless": false + }, + "bindings": [ + { + "name": "req", + "direction": "In", + "type": "httpTrigger", + "authLevel": "Function", + "methods": [ + "get", + "post" + ], + "properties": {} + }, + { + "name": "table", + "direction": "In", + "type": "table", + "tableName": "TableName", + "partitionKey": "My Partition", + "take": 2, + "filter": "RowKey ne 'value'", + "properties": { + "supportsDeferredBinding": "True" + } + }, + { + "name": "$return", + "type": "http", + "direction": "Out" + } + ] + }, + { + "name": "EnumerableFunction", + "scriptFile": "WorkerBindingSamples.dll", + "entryPoint": "WorkerBindingSamples.Table.TableInputBindingSamples.EnumerableFunction", + "language": "dotnet-isolated", + "properties": { + "IsCodeless": false + }, + "bindings": [ + { + "name": "req", + "direction": "In", + "type": "httpTrigger", + "authLevel": "Function", + "methods": [ + "get", + "post" + ], + "route": "items/{partitionKey}", + "properties": {} + }, + { + "name": "tables", + "direction": "In", + "type": "table", + "tableName": "TableName", + "partitionKey": "{partitionKey}", + "properties": { + "supportsDeferredBinding": "True" + } + }, + { + "name": "$return", + "type": "http", + "direction": "Out" + } + ] + }, + { + "name": "PocoFunction", + "scriptFile": "WorkerBindingSamples.dll", + "entryPoint": "WorkerBindingSamples.Table.TableInputBindingSamples.PocoFunction", + "language": "dotnet-isolated", + "properties": { + "IsCodeless": false + }, + "bindings": [ + { + "name": "req", + "direction": "In", + "type": "httpTrigger", + "authLevel": "Function", + "methods": [ + "get", + "post" + ], + "properties": {} + }, + { + "name": "entities", + "direction": "In", + "type": "table", + "tableName": "TableName", + "properties": {} + }, + { + "name": "$return", + "type": "http", + "direction": "Out" + } + ] + }, { "name": "BlobInputClientFunction", "scriptFile": "WorkerBindingSamples.dll", diff --git a/test/SdkE2ETests/PublishTests.cs b/test/SdkE2ETests/PublishTests.cs index 251b8b3f4..58c37e015 100644 --- a/test/SdkE2ETests/PublishTests.cs +++ b/test/SdkE2ETests/PublishTests.cs @@ -134,7 +134,10 @@ private async Task RunPublishTestForSdkTypeBindings(string outputDir, string add @"./.azurefunctions/Microsoft.Azure.WebJobs.Extensions.Storage.Blobs.dll"), new Extension("AzureStorageQueues", "Microsoft.Azure.WebJobs.Extensions.Storage.AzureStorageQueuesWebJobsStartup, Microsoft.Azure.WebJobs.Extensions.Storage.Queues, Version=5.0.0.0, Culture=neutral, PublicKeyToken=92742159e12e44c8", - @"./.azurefunctions/Microsoft.Azure.WebJobs.Extensions.Storage.Queues.dll") + @"./.azurefunctions/Microsoft.Azure.WebJobs.Extensions.Storage.Queues.dll"), + new Extension("AzureTables", + "Microsoft.Azure.WebJobs.Extensions.Tables.AzureTablesWebJobsStartup, Microsoft.Azure.WebJobs.Extensions.Tables, Version=1.2.0.0, Culture=neutral, PublicKeyToken=92742159e12e44c8", + @"./.azurefunctions/Microsoft.Azure.WebJobs.Extensions.Tables.dll") } }); Assert.True(JToken.DeepEquals(extensionsJsonContents, expected), $"Actual: {extensionsJsonContents}{Environment.NewLine}Expected: {expected}"); diff --git a/test/WorkerExtensionTests/Table/TableClientConverterTests.cs b/test/WorkerExtensionTests/Table/TableClientConverterTests.cs new file mode 100644 index 000000000..c3f48c2fa --- /dev/null +++ b/test/WorkerExtensionTests/Table/TableClientConverterTests.cs @@ -0,0 +1,262 @@ +// 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.Tasks; +using Azure; +using Azure.Data.Tables; +using Google.Protobuf; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Extensions.Tables.Config; +using Microsoft.Azure.Functions.Worker.Extensions.Tables; +using Microsoft.Azure.Functions.Worker.Extensions.Tables.TypeConverters; +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.Table +{ + public class TableClientConverterTests + { + private TableClientConverter _tableConverter; + private Mock _mockTableServiceClient; + + public TableClientConverterTests() + { + var host = new HostBuilder().ConfigureFunctionsWorkerDefaults((WorkerOptions options) => { }).Build(); + var logger = host.Services.GetService>(); + + _mockTableServiceClient = new Mock(); + + var mockTableOptions = new Mock(); + mockTableOptions + .Setup(m => m.CreateClient()) + .Returns(_mockTableServiceClient.Object); + + var mockTablesOptionsSnapshot = new Mock>(); + mockTablesOptionsSnapshot + .Setup(m => m.Get(It.IsAny())) + .Returns(mockTableOptions.Object); + + _tableConverter = new TableClientConverter(mockTablesOptionsSnapshot.Object, logger); + } + + [Fact] + public async Task ConvertAsync_SourceAsObject_ReturnsUnhandled() + { + var context = new TestConverterContext(typeof(string), new object()); + + var conversionResult = await _tableConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Unhandled, conversionResult.Status); + } + + + [Fact] + public async Task ConvertAsync_SourceAsModelBindingData_ReturnsSuccess() + { + object source = GetTestGrpcModelBindingData(GetTableClientBinaryData()); + var result = new Mock(); + var context = new TestConverterContext(typeof(TableClient), source); + + _mockTableServiceClient + .Setup(c => c.GetTableClient(Constants.TableName)) + .Returns((TableClient)result.Object); + + var conversionResult = await _tableConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Succeeded, conversionResult.Status); + + } + + [Fact] + public async Task ConvertAsync_SourceAsCollectionModelBindingData_ReturnsSuccess() + { + object source = GetTestGrpcModelBindingData(GetTableEntityBinaryData()); + var context = new TestConverterContext(typeof(IEnumerable), source); + var mockResponse = new Mock(); + var tableClient = new Mock(); + + tableClient + .Setup(c => c.GetEntityAsync(It.IsAny(), It.IsAny(), null, default)) + .ReturnsAsync(Response.FromValue(new TableEntity(It.IsAny(), It.IsAny()), mockResponse.Object)); + + _mockTableServiceClient + .Setup(c => c.GetTableClient(Constants.TableName)) + .Returns(tableClient.Object); + + var expectedOutput = Page.FromValues(new List{ new TableEntity("partitionKey", "rowKey") }, continuationToken: null, mockResponse.Object); + + tableClient + .Setup(c => c.QueryAsync(It.IsAny(), null, null, default)) + .Returns(AsyncPageable.FromPages(new List> { expectedOutput })); + + var conversionResult = await _tableConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Unhandled, conversionResult.Status); + } + + + [Fact] + public async Task ConvertAsync_SourceAsCollectionModelBindingData_TableEntity_ReturnsSuccess() + { + object source = GetTestGrpcModelBindingData(GetTableEntityBinaryData()); + var context = new TestConverterContext(typeof(TableEntity), source); + var mockResponse = new Mock(); + var tableClient = new Mock(); + + tableClient + .Setup(c => c.GetEntityAsync(It.IsAny(), It.IsAny(), null, default)) + .ReturnsAsync(Response.FromValue(new TableEntity(It.IsAny(), It.IsAny()), mockResponse.Object)); + + _mockTableServiceClient + .Setup(c => c.GetTableClient(Constants.TableName)) + .Returns(tableClient.Object); + + var conversionResult = await _tableConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Unhandled, conversionResult.Status); + } + + [Fact] + public async Task ConvertAsync_SourceAsModelBindingData_ReturnsFailed() + { + object source = GetTestGrpcModelBindingData(GetTableClientBinaryData()); + var result = new Mock(); + var context = new TestConverterContext(typeof(TableClient), source); + + _mockTableServiceClient + .Setup(c => c.GetTableClient(Constants.TableName)) + .Throws(new Exception()); + + var conversionResult = await _tableConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Failed, conversionResult.Status); + } + + [Fact] + public async Task ConvertAsync_WrongModelBindingData_ReturnsFailed() + { + object source = GetTestGrpcModelBindingData(GetWrongBinaryData()); + var result = new Mock(); + var context = new TestConverterContext(typeof(TableClient), source); + + _mockTableServiceClient + .Setup(c => c.GetTableClient(Constants.TableName)) + .Returns(result.Object); + + var conversionResult = await _tableConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Failed, conversionResult.Status); + } + + [Fact] + public async Task ConvertAsync_ContentSource_AsObject_ReturnsUnhandled() + { + var context = new TestConverterContext(typeof(TableClient), new Object()); + + var conversionResult = await _tableConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Unhandled, conversionResult.Status); + } + + [Fact] + public async Task ConvertAsync_ModelBindingData_Null_ReturnsUnhandled() + { + var context = new TestConverterContext(typeof(TableClient), null); + + var conversionResult = await _tableConverter.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(GetTableClientBinaryData()); + var context = new TestConverterContext(typeof(TableClient), grpcModelBindingData); + + _mockTableServiceClient + .Setup(c => c.GetTableClient(Constants.TableName)) + .Returns((TableClient)null); + + var conversionResult = await _tableConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Unhandled, conversionResult.Status); + } + + [Fact] + public async Task ConvertAsync_ModelBindingDataSource_NotCosmosExtension_ReturnsUnhandled() + { + var grpcModelBindingData = GetTestGrpcModelBindingData(GetTableClientBinaryData(), source: "anotherExtensions"); + var context = new TestConverterContext(typeof(TableClient), grpcModelBindingData); + + var conversionResult = await _tableConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Unhandled, conversionResult.Status); + } + + [Fact] + public async Task ConvertAsync_ModelBindingDataContentType_Unsupported_ReturnsFailed() + { + var grpcModelBindingData = GetTestGrpcModelBindingData(GetTableClientBinaryData(), contentType: "binary"); + var context = new TestConverterContext(typeof(TableClient), grpcModelBindingData); + + var conversionResult = await _tableConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Failed, conversionResult.Status); + Assert.Equal("Unexpected content-type. Currently only 'application/json' is supported.", conversionResult.Error.Message); + } + + + private BinaryData GetWrongBinaryData() + { + return new BinaryData("{" + "\"Connection\" : \"Connection\"" + "}"); + } + + private BinaryData GetTableClientBinaryData() + { + return new BinaryData("{" + + "\"TableName\" : \"TableName\"" + + "}"); + } + + private BinaryData GetTableEntityBinaryData() + { + return new BinaryData("{" + + "\"Connection\" : \"Connection\"," + + "\"TableName\" : \"TableName\"," + + "\"PartitionKey\" : \"PartitionKey\"," + + "\"RowKey\" : \"RowKey\"" + + "}"); + } + + private BinaryData GetBadEntityBinaryData() + { + return new BinaryData("{" + + "\"Connection\" : \"Connection\"," + + "\"TableName\" : \"TableName\"," + + "\"PartitionKey\" : \"PartitionKey\"" + + "}"); + } + + + private GrpcModelBindingData GetTestGrpcModelBindingData(BinaryData binaryData, string source = "AzureStorageTables", string contentType = "application/json") + { + return new GrpcModelBindingData(new ModelBindingData() + { + Version = "1.0", + Source = "AzureStorageTables", + Content = ByteString.CopyFrom(binaryData), + ContentType = contentType + }); + } + } +} diff --git a/test/WorkerExtensionTests/Table/TableEntityConverterTests.cs b/test/WorkerExtensionTests/Table/TableEntityConverterTests.cs new file mode 100644 index 000000000..91f20b32a --- /dev/null +++ b/test/WorkerExtensionTests/Table/TableEntityConverterTests.cs @@ -0,0 +1,259 @@ +// 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.Tasks; +using Azure; +using Azure.Data.Tables; +using Google.Protobuf; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Extensions.Tables.Config; +using Microsoft.Azure.Functions.Worker.Extensions.Tables; +using Microsoft.Azure.Functions.Worker.Extensions.Tables.TypeConverters; +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.Table +{ + public class TableEntityConverterTests + { + private TableEntityConverter _tableConverter; + private Mock _mockTableServiceClient; + + public TableEntityConverterTests() + { + var host = new HostBuilder().ConfigureFunctionsWorkerDefaults((WorkerOptions options) => { }).Build(); + var logger = host.Services.GetService>(); + + _mockTableServiceClient = new Mock(); + + var mockTableOptions = new Mock(); + mockTableOptions + .Setup(m => m.CreateClient()) + .Returns(_mockTableServiceClient.Object); + + var mockTablesOptionsSnapshot = new Mock>(); + mockTablesOptionsSnapshot + .Setup(m => m.Get(It.IsAny())) + .Returns(mockTableOptions.Object); + + _tableConverter = new TableEntityConverter(mockTablesOptionsSnapshot.Object, logger); + } + + [Fact] + public async Task ConvertAsync_SourceAsObject_ReturnsUnhandled() + { + var context = new TestConverterContext(typeof(string), new object()); + + var conversionResult = await _tableConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Unhandled, conversionResult.Status); + } + + + [Fact] + public async Task ConvertAsync_SourceAsModelBindingData_ReturnsUnhandled() + { + object source = GetTestGrpcModelBindingData(GetTableClientBinaryData()); + var result = new Mock(); + var context = new TestConverterContext(typeof(TableClient), source); + + _mockTableServiceClient + .Setup(c => c.GetTableClient(Constants.TableName)) + .Returns((TableClient)result.Object); + + var conversionResult = await _tableConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Unhandled, conversionResult.Status); + + } + + [Fact] + public async Task ConvertAsync_SourceAsCollectionModelBindingData_ReturnsSuccess() + { + object source = GetTestGrpcModelBindingData(GetTableEntityBinaryData()); + var context = new TestConverterContext(typeof(IEnumerable), source); + var mockResponse = new Mock(); + var tableClient = new Mock(); + + tableClient + .Setup(c => c.GetEntityAsync(It.IsAny(), It.IsAny(), null, default)) + .ReturnsAsync(Response.FromValue(new TableEntity(It.IsAny(), It.IsAny()), mockResponse.Object)); + + _mockTableServiceClient + .Setup(c => c.GetTableClient(Constants.TableName)) + .Returns(tableClient.Object); + + var expectedOutput = Page.FromValues(new List { new TableEntity("partitionKey", "rowKey") }, continuationToken: null, mockResponse.Object); + + tableClient + .Setup(c => c.QueryAsync(It.IsAny(), null, null, default)) + .Returns(AsyncPageable.FromPages(new List> { expectedOutput })); + + var conversionResult = await _tableConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Unhandled, conversionResult.Status); + } + + + [Fact] + public async Task ConvertAsync_SourceAsCollectionModelBindingData_TableEntity_ReturnsSuccess() + { + object source = GetTestGrpcModelBindingData(GetTableEntityBinaryData()); + var context = new TestConverterContext(typeof(TableEntity), source); + var mockResponse = new Mock(); + var tableClient = new Mock(); + + tableClient + .Setup(c => c.GetEntityAsync(It.IsAny(), It.IsAny(), null, default)) + .ReturnsAsync(Response.FromValue(new TableEntity(It.IsAny(), It.IsAny()), mockResponse.Object)); + + _mockTableServiceClient + .Setup(c => c.GetTableClient(Constants.TableName)) + .Returns(tableClient.Object); + + var conversionResult = await _tableConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Succeeded, conversionResult.Status); + Assert.Equal(typeof(TableEntity), conversionResult.Value.GetType()); + } + + [Fact] + public async Task ConvertAsync_SourceAsCollectionModelBindingData_BadTableEntity_ReturnsSuccess() + { + object source = GetTestGrpcModelBindingData(GetBadEntityBinaryData()); + var context = new TestConverterContext(typeof(TableEntity), source); + var mockResponse = new Mock(); + var tableClient = new Mock(); + + tableClient + .Setup(c => c.GetEntityAsync(It.IsAny(), It.IsAny(), null, default)) + .ReturnsAsync(Response.FromValue(new TableEntity(It.IsAny(), It.IsAny()), mockResponse.Object)); + + _mockTableServiceClient + .Setup(c => c.GetTableClient(Constants.TableName)) + .Returns(tableClient.Object); + + var conversionResult = await _tableConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Failed, conversionResult.Status); + } + + [Fact] + public async Task ConvertAsync_ContentSource_AsObject_ReturnsUnhandled() + { + var context = new TestConverterContext(typeof(TableEntity), new Object()); + + var conversionResult = await _tableConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Unhandled, conversionResult.Status); + } + + [Fact] + public async Task ConvertAsync_ModelBindingData_Null_ReturnsUnhandled() + { + var context = new TestConverterContext(typeof(TableEntity), null); + + var conversionResult = await _tableConverter.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(GetTableEntityBinaryData()); + var context = new TestConverterContext(typeof(TableEntity), grpcModelBindingData); + + var tableClient = new Mock(); + var mockResponse = new Mock(); + + tableClient + .Setup(c => c.GetEntityAsync(It.IsAny(), It.IsAny(), null, default)) + .ReturnsAsync(Response.FromValue((TableEntity)null, mockResponse.Object)); + + _mockTableServiceClient + .Setup(c => c.GetTableClient(Constants.TableName)) + .Returns(tableClient.Object); + + var conversionResult = await _tableConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Unhandled, conversionResult.Status); + } + + [Fact] + public async Task ConvertAsync_ModelBindingDataSource_NotCosmosExtension_ReturnsUnhandled() + { + var grpcModelBindingData = GetTestGrpcModelBindingData(GetTableEntityBinaryData(), source: "anotherExtensions"); + var context = new TestConverterContext(typeof(TableEntity), grpcModelBindingData); + + var conversionResult = await _tableConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Unhandled, conversionResult.Status); + } + + [Fact] + public async Task ConvertAsync_ModelBindingDataContentType_Unsupported_ReturnsFailed() + { + var grpcModelBindingData = GetTestGrpcModelBindingData(GetTableEntityBinaryData(), contentType: "binary"); + var context = new TestConverterContext(typeof(TableEntity), grpcModelBindingData); + + var conversionResult = await _tableConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Failed, conversionResult.Status); + Assert.Equal("Unexpected content-type. Currently only 'application/json' is supported.", conversionResult.Error.Message); + } + + + private BinaryData GetWrongBinaryData() + { + return new BinaryData("{" + "\"Connection\" : \"Connection\"" + "}"); + } + + private BinaryData GetTableClientBinaryData() + { + return new BinaryData("{" + + "\"TableName\" : \"TableName\"" + + "}"); + } + + private BinaryData GetTableEntityBinaryData() + { + return new BinaryData("{" + + "\"Connection\" : \"Connection\"," + + "\"TableName\" : \"TableName\"," + + "\"PartitionKey\" : \"PartitionKey\"," + + "\"RowKey\" : \"RowKey\"" + + "}"); + } + + private BinaryData GetBadEntityBinaryData() + { + return new BinaryData("{" + + "\"Connection\" : \"Connection\"," + + "\"TableName\" : \"TableName\"," + + "\"PartitionKey\" : \"PartitionKey\"" + + "}"); + } + + + private GrpcModelBindingData GetTestGrpcModelBindingData(BinaryData binaryData, string source = "AzureStorageTables", string contentType = "application/json") + { + return new GrpcModelBindingData(new ModelBindingData() + { + Version = "1.0", + Source = source, + Content = ByteString.CopyFrom(binaryData), + ContentType = contentType + }); + } + } +} diff --git a/test/WorkerExtensionTests/Table/TableEntityEnumerableConverterTests.cs b/test/WorkerExtensionTests/Table/TableEntityEnumerableConverterTests.cs new file mode 100644 index 000000000..d179df8c7 --- /dev/null +++ b/test/WorkerExtensionTests/Table/TableEntityEnumerableConverterTests.cs @@ -0,0 +1,230 @@ +// 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.Tasks; +using Azure; +using Azure.Data.Tables; +using Google.Protobuf; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Extensions.Tables.Config; +using Microsoft.Azure.Functions.Worker.Extensions.Tables; +using Microsoft.Azure.Functions.Worker.Extensions.Tables.TypeConverters; +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.Table +{ + public class TableEntityEnumerableConverterTests + { + private TableEntityEnumerableConverter _tableConverter; + private Mock _mockTableServiceClient; + + public TableEntityEnumerableConverterTests() + { + var host = new HostBuilder().ConfigureFunctionsWorkerDefaults((WorkerOptions options) => { }).Build(); + var logger = host.Services.GetService>(); + + _mockTableServiceClient = new Mock(); + + var mockTableOptions = new Mock(); + mockTableOptions + .Setup(m => m.CreateClient()) + .Returns(_mockTableServiceClient.Object); + + var mockTablesOptionsSnapshot = new Mock>(); + mockTablesOptionsSnapshot + .Setup(m => m.Get(It.IsAny())) + .Returns(mockTableOptions.Object); + + _tableConverter = new TableEntityEnumerableConverter(mockTablesOptionsSnapshot.Object, logger); + } + + [Fact] + public async Task ConvertAsync_SourceAsObject_ReturnsUnhandled() + { + var context = new TestConverterContext(typeof(string), new object()); + + var conversionResult = await _tableConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Unhandled, conversionResult.Status); + } + + + [Fact] + public async Task ConvertAsync_SourceAsModelBindingData_ReturnsUnhandled() + { + object source = GetTestGrpcModelBindingData(GetTableClientBinaryData()); + var result = new Mock(); + var context = new TestConverterContext(typeof(TableClient), source); + + _mockTableServiceClient + .Setup(c => c.GetTableClient(Constants.TableName)) + .Returns((TableClient)result.Object); + + var conversionResult = await _tableConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Unhandled, conversionResult.Status); + + } + + [Fact] + public async Task ConvertAsync_SourceAsCollectionModelBindingData_ReturnsSuccess() + { + object source = GetTestGrpcModelBindingData(GetTableEntityBinaryData()); + var context = new TestConverterContext(typeof(IEnumerable), source); + var mockResponse = new Mock(); + var tableClient = new Mock(); + + tableClient + .Setup(c => c.GetEntityAsync(It.IsAny(), It.IsAny(), null, default)) + .ReturnsAsync(Response.FromValue(new TableEntity(It.IsAny(), It.IsAny()), mockResponse.Object)); + + _mockTableServiceClient + .Setup(c => c.GetTableClient(Constants.TableName)) + .Returns(tableClient.Object); + + var expectedOutput = Page.FromValues(new List { new TableEntity("partitionKey", "rowKey") }, continuationToken: null, mockResponse.Object); + + tableClient + .Setup(c => c.QueryAsync(It.IsAny(), null, null, default)) + .Returns(AsyncPageable.FromPages(new List> { expectedOutput })); + + var conversionResult = await _tableConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Succeeded, conversionResult.Status); + } + + + [Fact] + public async Task ConvertAsync_SourceAsCollectionModelBindingData_TableEntity_ReturnsSuccess() + { + object source = GetTestGrpcModelBindingData(GetTableEntityBinaryData()); + var context = new TestConverterContext(typeof(TableEntity), source); + var mockResponse = new Mock(); + var tableClient = new Mock(); + + tableClient + .Setup(c => c.GetEntityAsync(It.IsAny(), It.IsAny(), null, default)) + .ReturnsAsync(Response.FromValue(new TableEntity(It.IsAny(), It.IsAny()), mockResponse.Object)); + + _mockTableServiceClient + .Setup(c => c.GetTableClient(Constants.TableName)) + .Returns(tableClient.Object); + + var conversionResult = await _tableConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Unhandled, conversionResult.Status); + } + + [Fact] + public async Task ConvertAsync_SourceAsCollectionModelBindingData_BadTableEntity_ReturnsSuccess() + { + object source = GetTestGrpcModelBindingData(GetBadEntityBinaryData()); + var context = new TestConverterContext(typeof(TableEntity), source); + var mockResponse = new Mock(); + var tableClient = new Mock(); + + tableClient + .Setup(c => c.GetEntityAsync(It.IsAny(), It.IsAny(), null, default)) + .ReturnsAsync(Response.FromValue(new TableEntity(It.IsAny(), It.IsAny()), mockResponse.Object)); + + _mockTableServiceClient + .Setup(c => c.GetTableClient(Constants.TableName)) + .Returns(tableClient.Object); + + var conversionResult = await _tableConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Unhandled, conversionResult.Status); + } + + [Fact] + public async Task ConvertAsync_ContentSource_AsObject_ReturnsUnhandled() + { + var context = new TestConverterContext(typeof(IEnumerable), new Object()); + + var conversionResult = await _tableConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Unhandled, conversionResult.Status); + } + + [Fact] + public async Task ConvertAsync_ModelBindingData_Null_ReturnsUnhandled() + { + var context = new TestConverterContext(typeof(IEnumerable), null); + + var conversionResult = await _tableConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Unhandled, conversionResult.Status); + } + + [Fact] + public async Task ConvertAsync_ModelBindingDataSource_NotCosmosExtension_ReturnsUnhandled() + { + var grpcModelBindingData = GetTestGrpcModelBindingData(GetTableEntityBinaryData(), source: "anotherExtensions"); + var context = new TestConverterContext(typeof(IEnumerable), grpcModelBindingData); + + var conversionResult = await _tableConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Unhandled, conversionResult.Status); + } + + [Fact] + public async Task ConvertAsync_ModelBindingDataContentType_Unsupported_ReturnsFailed() + { + var grpcModelBindingData = GetTestGrpcModelBindingData(GetTableEntityBinaryData(), contentType: "binary"); + var context = new TestConverterContext(typeof(IEnumerable), grpcModelBindingData); + + var conversionResult = await _tableConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Failed, conversionResult.Status); + Assert.Equal("Unexpected content-type. Currently only 'application/json' is supported.", conversionResult.Error.Message); + } + + private BinaryData GetTableClientBinaryData() + { + return new BinaryData("{" + + "\"TableName\" : \"TableName\"" + + "}"); + } + + private BinaryData GetTableEntityBinaryData() + { + return new BinaryData("{" + + "\"Connection\" : \"Connection\"," + + "\"TableName\" : \"TableName\"," + + "\"PartitionKey\" : \"PartitionKey\"," + + "\"RowKey\" : \"RowKey\"" + + "}"); + } + + private BinaryData GetBadEntityBinaryData() + { + return new BinaryData("{" + + "\"Connection\" : \"Connection\"," + + "\"TableName\" : \"TableName\"," + + "\"PartitionKey\" : \"PartitionKey\"" + + "}"); + } + + + private GrpcModelBindingData GetTestGrpcModelBindingData(BinaryData binaryData, string source = "AzureStorageTables", string contentType = "application/json") + { + return new GrpcModelBindingData(new ModelBindingData() + { + Version = "1.0", + Source = source, + Content = ByteString.CopyFrom(binaryData), + ContentType = contentType + }); + } + } +} diff --git a/test/WorkerExtensionTests/Table/TablesBindingOptionsSetupTest.cs b/test/WorkerExtensionTests/Table/TablesBindingOptionsSetupTest.cs new file mode 100644 index 000000000..c93275fb1 --- /dev/null +++ b/test/WorkerExtensionTests/Table/TablesBindingOptionsSetupTest.cs @@ -0,0 +1,93 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Azure.Core; +using Azure.Data.Tables; +using Microsoft.Azure.Functions.Worker.Extensions.Tables.Config; +using Microsoft.Extensions.Azure; +using Microsoft.Extensions.Configuration; +using Moq; +using Xunit; + +namespace Microsoft.Azure.Functions.WorkerExtension.Tests.Table +{ + public class TablesBindingOptionsSetupTest + { + private Mock configuration; + private Mock componentFactory; + public TablesBindingOptionsSetupTest() + { + configuration = new Mock(); + componentFactory = new Mock(); + } + + [Fact] + public void Configure_With_Default_Name() + { + var configSection = new Mock(); + + configSection.Setup(c => c.Key).Returns("key"); + configSection.Setup(c => c.Value).Returns("connectionString"); + configuration.Setup(c => c.GetSection("AzureWebJobsStorage")).Returns(configSection.Object); + + var tableBindingOptions = new Mock(); + var tokenCredential = new Mock(); + + componentFactory.Setup(c => c.CreateClientOptions(typeof(TableClientOptions), null, configSection.Object)).Returns(null); + componentFactory.Setup(c => c.CreateTokenCredential(configSection.Object)).Returns(tokenCredential.Object); + + var tablesBindingOptionsSetup = new Mock(configuration.Object, componentFactory.Object); + tablesBindingOptionsSetup.Object.Configure(tableBindingOptions.Object); + + Assert.Equal("connectionString", tableBindingOptions.Object.ConnectionString); + Assert.NotNull(tableBindingOptions.Object.Credential); + } + + [Fact] + public void Configure_With_Given_Name() + { + var configSection = new Mock(); + configSection.Setup(c => c.Key).Returns("key"); + configSection.Setup(c => c.Value).Returns("connectionString"); + configuration.Setup(c => c.GetSection("AzureWebJobsCustom")).Returns(configSection.Object); + + var tableBindingOptions = new Mock(); + var tokenCredential = new Mock(); + + componentFactory.Setup(c => c.CreateClientOptions(typeof(TableClientOptions), null, configSection.Object)).Returns(null); + componentFactory.Setup(c => c.CreateTokenCredential(configSection.Object)).Returns(tokenCredential.Object); + + var tablesBindingOptionsSetup = new Mock(configuration.Object, componentFactory.Object); + tablesBindingOptionsSetup.Object.Configure("Custom",tableBindingOptions.Object); + + Assert.Equal("connectionString", tableBindingOptions.Object.ConnectionString); + Assert.NotNull(tableBindingOptions.Object.Credential); + } + + [Fact] + public void Configure_With_ServiceUri() + { + var configSection = new Mock(); + configSection.Setup(c => c.Key).Returns("key"); + configSection.Setup(c => c.Value).Returns(""); + + var inMemorySettings = new Dictionary { + {"AzureWebJobsStorage:accountName", "test"}, + }; + + IConfiguration configurationReal = new ConfigurationBuilder() + .AddInMemoryCollection(inMemorySettings) + .Build(); + + var tableBindingOptions = new Mock(); + var tokenCredential = new Mock(); + componentFactory.Setup(c => c.CreateClientOptions(typeof(TableClientOptions), null, configSection.Object)).Returns(null); + componentFactory.Setup(c => c.CreateTokenCredential(configSection.Object)).Returns(tokenCredential.Object); + + var tablesBindingOptionsSetup = new Mock(configurationReal, componentFactory.Object); + tablesBindingOptionsSetup.Object.Configure(tableBindingOptions.Object); + + Assert.Null(tableBindingOptions.Object.ConnectionString); + Assert.Equal("https://test.table.core.windows.net/", tableBindingOptions.Object.ServiceUri.ToString()); + } + } +} diff --git a/test/WorkerExtensionTests/WorkerExtensionTests.csproj b/test/WorkerExtensionTests/WorkerExtensionTests.csproj index f394c115b..25339322c 100644 --- a/test/WorkerExtensionTests/WorkerExtensionTests.csproj +++ b/test/WorkerExtensionTests/WorkerExtensionTests.csproj @@ -25,6 +25,7 @@ +