diff --git a/extensions/Worker.Extensions.Storage.Queues/release_notes.md b/extensions/Worker.Extensions.Storage.Queues/release_notes.md index 366a8e097..76fd75a17 100644 --- a/extensions/Worker.Extensions.Storage.Queues/release_notes.md +++ b/extensions/Worker.Extensions.Storage.Queues/release_notes.md @@ -6,4 +6,4 @@ ### Microsoft.Azure.Functions.Worker.Extensions.Storage.Queues -- +- Add ability to bind a queue trigger to QueueMessage and BinaryData (#1470) diff --git a/extensions/Worker.Extensions.Storage.Queues/src/Constants.cs b/extensions/Worker.Extensions.Storage.Queues/src/Constants.cs new file mode 100644 index 000000000..638b27003 --- /dev/null +++ b/extensions/Worker.Extensions.Storage.Queues/src/Constants.cs @@ -0,0 +1,14 @@ +// 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.Storage.Queues +{ + internal static class Constants + { + internal const string QueueExtensionName = "AzureStorageQueues"; + internal const string QueueMessageText = "MessageText"; + + // Media content types + internal const string JsonContentType = "application/json"; + } +} diff --git a/extensions/Worker.Extensions.Storage.Queues/src/JsonConverters/QueueMessageJsonConverter.cs b/extensions/Worker.Extensions.Storage.Queues/src/JsonConverters/QueueMessageJsonConverter.cs new file mode 100644 index 000000000..bcd350eed --- /dev/null +++ b/extensions/Worker.Extensions.Storage.Queues/src/JsonConverters/QueueMessageJsonConverter.cs @@ -0,0 +1,91 @@ +// 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.Text.Json; +using System.Text.Json.Serialization; +using Azure.Storage.Queues.Models; + +namespace Microsoft.Azure.Functions.Worker.Storage.Queues +{ + internal class QueueMessageJsonConverter : JsonConverter + { + public override bool CanConvert(Type objectType) => objectType == typeof(QueueMessage); + + public override QueueMessage? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType is not JsonTokenType.StartObject) + { + throw new JsonException("JSON payload expected to start with StartObject token."); + } + + string messageId = String.Empty; + string popReceipt = String.Empty; + string messageText = String.Empty; + long dequeueCount = 1; + DateTime? nextVisibleOn = null; + DateTime? insertedOn = null; + DateTime? expiresOn = null; + + var startDepth = reader.CurrentDepth; + + while (reader.Read()) + { + if (reader.TokenType is JsonTokenType.EndObject && reader.CurrentDepth == startDepth) + { + return QueuesModelFactory.QueueMessage( + messageId, + popReceipt, + messageText, + dequeueCount, + nextVisibleOn, + insertedOn, + expiresOn + ); + } + + if (reader.TokenType is not JsonTokenType.PropertyName) + { + continue; + } + + var propertyName = reader.GetString(); + reader.Read(); + + switch (propertyName?.ToLowerInvariant()) + { + case "messageid": + messageId = reader.GetString() ?? throw new JsonException("JSON payload must contain a MessageId."); + break; + case "popreceipt": + popReceipt = reader.GetString() ?? throw new JsonException("JSON payload must contain a PopReceipt."); + break; + case "messagetext": + messageText = reader.GetString() ?? throw new JsonException("JSOn payload must contain a MessageText."); + break; + case "dequeuecount": + dequeueCount = reader.GetInt64(); + break; + case "nextvisibleon": + nextVisibleOn = reader.GetDateTime(); + break; + case "insertedon": + insertedOn = reader.GetDateTime(); + break; + case "expireson": + expiresOn = reader.GetDateTime(); + break; + default: + break; + } + } + + throw new JsonException("JSON payload expected to end with EndObject token."); + } + + public override void Write(Utf8JsonWriter writer, QueueMessage value, JsonSerializerOptions options) + { + throw new JsonException($"Serialization is not supported by the {nameof(QueueMessageJsonConverter)}."); + } + } +} diff --git a/extensions/Worker.Extensions.Storage.Queues/src/Properties/AssemblyInfo.cs b/extensions/Worker.Extensions.Storage.Queues/src/Properties/AssemblyInfo.cs index 2caa76004..cd34c0800 100644 --- a/extensions/Worker.Extensions.Storage.Queues/src/Properties/AssemblyInfo.cs +++ b/extensions/Worker.Extensions.Storage.Queues/src/Properties/AssemblyInfo.cs @@ -1,6 +1,8 @@ // 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.Storage.Queues", "5.1.2")] +[assembly: ExtensionInformation("Microsoft.Azure.WebJobs.Extensions.Storage.Queues", "5.1.3")] +[assembly: InternalsVisibleTo("Microsoft.Azure.Functions.Worker.Extensions.Tests, PublicKey=00240000048000009400000006020000002400005253413100040000010001005148be37ac1d9f58bd40a2e472c9d380d635b6048278f7d47480b08c928858f0f7fe17a6e4ce98da0e7a7f0b8c308aecd9e9b02d7e9680a5b5b75ac7773cec096fbbc64aebd429e77cb5f89a569a79b28e9c76426783f624b6b70327eb37341eb498a2c3918af97c4860db6cdca4732787150841e395a29cfacb959c1fd971c1")] diff --git a/extensions/Worker.Extensions.Storage.Queues/src/QueueTriggerAttribute.cs b/extensions/Worker.Extensions.Storage.Queues/src/QueueTriggerAttribute.cs index 7abf908ee..56beae967 100644 --- a/extensions/Worker.Extensions.Storage.Queues/src/QueueTriggerAttribute.cs +++ b/extensions/Worker.Extensions.Storage.Queues/src/QueueTriggerAttribute.cs @@ -1,11 +1,15 @@ // 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; +using Microsoft.Azure.Functions.Worker.Converters; using Microsoft.Azure.Functions.Worker.Extensions.Abstractions; namespace Microsoft.Azure.Functions.Worker { + [InputConverter(typeof(QueueMessageConverter))] + [InputConverter(typeof(QueueMessageBinaryDataConverter))] + [ConverterFallbackBehavior(ConverterFallbackBehavior.Default)] public sealed class QueueTriggerAttribute : TriggerBindingAttribute { private readonly string _queueName; diff --git a/extensions/Worker.Extensions.Storage.Queues/src/TypeConverters/QueueConverterBase.cs b/extensions/Worker.Extensions.Storage.Queues/src/TypeConverters/QueueConverterBase.cs new file mode 100644 index 000000000..b8cb20b49 --- /dev/null +++ b/extensions/Worker.Extensions.Storage.Queues/src/TypeConverters/QueueConverterBase.cs @@ -0,0 +1,77 @@ +// 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 System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Azure.Functions.Worker.Converters; +using Microsoft.Azure.Functions.Worker.Core; +using Microsoft.Azure.Functions.Worker.Extensions; +using Microsoft.Azure.Functions.Worker.Storage.Queues; + +namespace Microsoft.Azure.Functions.Worker +{ + internal abstract class QueueConverterBase : IInputConverter + { + + public QueueConverterBase() + { + } + + public bool CanConvert(ConverterContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (context.TargetType != typeof(T)) + { + return false; + } + + if (context.Source is not ModelBindingData bindingData) + { + return false; + } + + if (bindingData.Source is not Constants.QueueExtensionName) + { + throw new InvalidBindingSourceException(bindingData.Source, Constants.QueueExtensionName); + } + + return true; + } + + public async ValueTask ConvertAsync(ConverterContext context) + { + try + { + if (!CanConvert(context)) + { + return ConversionResult.Unhandled(); + } + + var modelBindingData = (ModelBindingData)context.Source!; + var result = await ConvertCoreAsync(modelBindingData); + return ConversionResult.Success(result); + } + catch (JsonException ex) + { + string msg = String.Format(CultureInfo.CurrentCulture, + @"Binding parameters to complex objects uses JSON serialization. + 1. Bind the parameter type as 'string' instead to get the raw values and avoid JSON deserialization, or + 2. Change the queue payload to be valid json."); + + return ConversionResult.Failed(new InvalidOperationException(msg, ex)); + } + catch (Exception ex) + { + return ConversionResult.Failed(ex); + } + } + + protected abstract ValueTask ConvertCoreAsync(ModelBindingData data); + } +} diff --git a/extensions/Worker.Extensions.Storage.Queues/src/TypeConverters/QueueMessageBinaryDataConverter.cs b/extensions/Worker.Extensions.Storage.Queues/src/TypeConverters/QueueMessageBinaryDataConverter.cs new file mode 100644 index 000000000..4acb0431c --- /dev/null +++ b/extensions/Worker.Extensions.Storage.Queues/src/TypeConverters/QueueMessageBinaryDataConverter.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; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Azure.Functions.Worker.Converters; +using Microsoft.Azure.Functions.Worker.Core; +using Microsoft.Azure.Functions.Worker.Extensions; +using Microsoft.Azure.Functions.Worker.Extensions.Abstractions; +using Microsoft.Azure.Functions.Worker.Storage.Queues; + +namespace Microsoft.Azure.Functions.Worker +{ + /// + /// Converter to bind to type parameters. + /// + [SupportsDeferredBinding] + [SupportedTargetType(typeof(BinaryData))] + internal sealed class QueueMessageBinaryDataConverter : QueueConverterBase + { + public QueueMessageBinaryDataConverter() : base() + { + } + + protected override async ValueTask ConvertCoreAsync(ModelBindingData data) + { + var messageText = await ExtractQueueMessageTextAsStringAsync(data); + return new BinaryData(messageText); + } + + private async Task ExtractQueueMessageTextAsStringAsync(ModelBindingData modelBindingData) + { + if (modelBindingData.ContentType is not Constants.JsonContentType) + { + throw new InvalidContentTypeException(modelBindingData.ContentType, Constants.JsonContentType); + } + + using var contentStream = modelBindingData.Content.ToStream(); + var contentElement = await JsonSerializer.DeserializeAsync(contentStream).ConfigureAwait(false); + + return contentElement.GetProperty(Constants.QueueMessageText).ToString() + ?? throw new InvalidOperationException($"The '{Constants.QueueMessageText}' property is missing or null."); + } + } +} diff --git a/extensions/Worker.Extensions.Storage.Queues/src/TypeConverters/QueueMessageConverter.cs b/extensions/Worker.Extensions.Storage.Queues/src/TypeConverters/QueueMessageConverter.cs new file mode 100644 index 000000000..1053a30ec --- /dev/null +++ b/extensions/Worker.Extensions.Storage.Queues/src/TypeConverters/QueueMessageConverter.cs @@ -0,0 +1,44 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System.Text.Json; +using System.Threading.Tasks; +using Azure.Storage.Queues.Models; +using Microsoft.Azure.Functions.Worker.Converters; +using Microsoft.Azure.Functions.Worker.Core; +using Microsoft.Azure.Functions.Worker.Extensions; +using Microsoft.Azure.Functions.Worker.Extensions.Abstractions; +using Microsoft.Azure.Functions.Worker.Storage.Queues; + +namespace Microsoft.Azure.Functions.Worker +{ + /// + /// Converter to bind to type parameters. + /// + [SupportsDeferredBinding] + [SupportedTargetType(typeof(QueueMessage))] + internal sealed class QueueMessageConverter : QueueConverterBase + { + private readonly JsonSerializerOptions _jsonOptions; + + public QueueMessageConverter() : base() + { + _jsonOptions = new() { Converters = { new QueueMessageJsonConverter() } }; + } + + protected override ValueTask ConvertCoreAsync(ModelBindingData data) + { + return new ValueTask(Task.FromResult(ExtractQueueMessage(data))); + } + + private QueueMessage ExtractQueueMessage(ModelBindingData modelBindingData) + { + if (modelBindingData.ContentType is not Constants.JsonContentType) + { + throw new InvalidContentTypeException(modelBindingData.ContentType, Constants.JsonContentType); + } + + return modelBindingData.Content.ToObjectFromJson(_jsonOptions); + } + } +} diff --git a/extensions/Worker.Extensions.Storage.Queues/src/Worker.Extensions.Storage.Queues.csproj b/extensions/Worker.Extensions.Storage.Queues/src/Worker.Extensions.Storage.Queues.csproj index 33ca629d9..30fff20b9 100644 --- a/extensions/Worker.Extensions.Storage.Queues/src/Worker.Extensions.Storage.Queues.csproj +++ b/extensions/Worker.Extensions.Storage.Queues/src/Worker.Extensions.Storage.Queues.csproj @@ -6,7 +6,8 @@ Azure Queue Storage extensions for .NET isolated functions - 5.1.2 + 5.1.3 + -preview1 false @@ -16,6 +17,15 @@ + + + + + + + + + \ No newline at end of file diff --git a/samples/WorkerBindingSamples/Queue/QueueSamples.cs b/samples/WorkerBindingSamples/Queue/QueueSamples.cs new file mode 100644 index 000000000..c1240c8b3 --- /dev/null +++ b/samples/WorkerBindingSamples/Queue/QueueSamples.cs @@ -0,0 +1,40 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using Azure.Storage.Queues.Models; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Extensions.Logging; + +namespace SampleApp +{ + /// + /// Samples demonstrating binding to and types. + /// + public class QueueSamples + { + private readonly ILogger _logger; + + public QueueSamples(ILogger logger) + { + _logger = logger; + } + + /// + /// This function demonstrates binding to a single . + /// + [Function(nameof(QueueMessageFunction))] + public void QueueMessageFunction([QueueTrigger("input-queue")] QueueMessage message) + { + _logger.LogInformation(message.MessageText); + } + + /// + /// This function demonstrates binding to a single . + /// + [Function(nameof(QueueBinaryDataFunction))] + public void QueueBinaryDataFunction([QueueTrigger("input-queue-binarydata")] BinaryData message) + { + _logger.LogInformation(message.ToString()); + } + } +} \ No newline at end of file diff --git a/test/DotNetWorkerTests/DotNetWorkerTests.csproj b/test/DotNetWorkerTests/DotNetWorkerTests.csproj index 6624a9c84..7331de5af 100644 --- a/test/DotNetWorkerTests/DotNetWorkerTests.csproj +++ b/test/DotNetWorkerTests/DotNetWorkerTests.csproj @@ -24,10 +24,10 @@ - + diff --git a/test/DotNetWorkerTests/GrpcFunctionDefinitionTests.cs b/test/DotNetWorkerTests/GrpcFunctionDefinitionTests.cs index 0c1ebca8f..8314bf07c 100644 --- a/test/DotNetWorkerTests/GrpcFunctionDefinitionTests.cs +++ b/test/DotNetWorkerTests/GrpcFunctionDefinitionTests.cs @@ -4,8 +4,8 @@ using System; using System.Collections.Generic; using System.IO; -using System.Linq; using System.Threading; +using Azure.Storage.Queues.Models; using Microsoft.Azure.Functions.Tests; using Microsoft.Azure.Functions.Worker.Converters; using Microsoft.Azure.Functions.Worker.Core; @@ -146,6 +146,46 @@ public void GrpcFunctionDefinition_BlobInput_Creates() }); } + [Fact] + public void GrpcFunctionDefinition_QueueTrigger_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(MyQueueFunctionClass)}.{nameof(MyQueueFunctionClass.Run)}", + ScriptFile = Path.GetFileName(fullPathToThisAssembly), + Name = "myfunction" + } + }; + + 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, + q => + { + Assert.Equal("message", q.Name); + Assert.Equal(typeof(QueueMessage), q.Type); + Assert.Contains(PropertyBagKeys.ConverterFallbackBehavior, q.Properties.Keys); + Assert.Contains(PropertyBagKeys.BindingAttributeSupportedConverters, q.Properties.Keys); + Assert.Equal("Default", q.Properties[PropertyBagKeys.ConverterFallbackBehavior].ToString()); + Assert.Contains(new Dictionary>().ToString(), q.Properties[PropertyBagKeys.BindingAttributeSupportedConverters].ToString()); + }); + } + private class MyFunctionClass { public HttpResponseData Run(HttpRequestData req) @@ -172,5 +212,12 @@ private class MyBlobFunctionClass } } + private class MyQueueFunctionClass + { + public static void Run([QueueTrigger("input-queue")] QueueMessage message) + { + throw new NotImplementedException(); + } + } } } diff --git a/test/E2ETests/E2EApps/E2EApp/Queue/QueueTestFunctions.cs b/test/E2ETests/E2EApps/E2EApp/Queue/QueueTestFunctions.cs index e59062d73..bc098341a 100644 --- a/test/E2ETests/E2EApps/E2EApp/Queue/QueueTestFunctions.cs +++ b/test/E2ETests/E2EApps/E2EApp/Queue/QueueTestFunctions.cs @@ -1,14 +1,15 @@ // 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.Net; -using System.Text.Json.Serialization; +using Azure.Storage.Queues.Models; using Microsoft.AspNetCore.WebUtilities; -using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; +using Newtonsoft.Json.Linq; namespace Microsoft.Azure.Functions.Worker.E2EApp.Queue { @@ -135,6 +136,28 @@ public class QueueTestFunctions return testData; } + [Function(nameof(QueueMessageQueueTriggerAndOutput))] + [QueueOutput("test-output-dotnet-isolated-queuemessage")] + public string QueueMessageQueueTriggerAndOutput([QueueTrigger("test-input-dotnet-isolated-queuemessage")] QueueMessage message, + FunctionContext context) + { + var logger = context.GetLogger(); + logger.LogInformation($"Message: {message}"); + + return message.Body.ToString(); + } + + [Function(nameof(BinaryDataQueueTriggerAndOutput))] + [QueueOutput("test-output-dotnet-isolated-binarydata")] + public string BinaryDataQueueTriggerAndOutput([QueueTrigger("test-input-dotnet-isolated-binarydata")] BinaryData message, + FunctionContext context) + { + var logger = context.GetLogger(); + logger.LogInformation($"Message: {message.ToString()}"); + + return message.ToString(); + } + public class TestData { public string Id { get; set; } diff --git a/test/E2ETests/E2ETests/Constants.cs b/test/E2ETests/E2ETests/Constants.cs index f9cd316f0..f1ee25351 100644 --- a/test/E2ETests/E2ETests/Constants.cs +++ b/test/E2ETests/E2ETests/Constants.cs @@ -28,6 +28,10 @@ public static class Queue public const string InputBindingNamePOCO = "test-input-dotnet-isolated-poco"; public const string InputBindingNameMetadata = "test-input-dotnet-isolated-metadata"; public const string OutputBindingNameMetadata = "test-output-dotnet-isolated-metadata"; + public const string InputBindingNameQueueMessage = "test-input-dotnet-isolated-queuemessage"; + public const string OutputBindingNameQueueMessage = "test-output-dotnet-isolated-queuemessage"; + public const string InputBindingNameBinaryData = "test-input-dotnet-isolated-binarydata"; + public const string OutputBindingNameBinaryData = "test-output-dotnet-isolated-binarydata"; public const string TestQueueMessage = "Hello, World"; } diff --git a/test/E2ETests/E2ETests/Storage/QueueEndToEndTests.cs b/test/E2ETests/E2ETests/Storage/QueueEndToEndTests.cs index ed81d8cfa..46186d1ce 100644 --- a/test/E2ETests/E2ETests/Storage/QueueEndToEndTests.cs +++ b/test/E2ETests/E2ETests/Storage/QueueEndToEndTests.cs @@ -166,5 +166,41 @@ public async Task QueueOutput_PocoList_Succeeds() IEnumerable queueMessages = await StorageHelpers.ReadMessagesFromQueue(Constants.Queue.OutputBindingNamePOCO); Assert.True(queueMessages.All(msg => msg.Contains(expectedQueueMessage))); } + + [Fact] + public async Task QueueMessageQueueTriggerAndOutput() + { + string expectedQueueMessage = Guid.NewGuid().ToString(); + + //Clear queue + await StorageHelpers.ClearQueue(Constants.Queue.OutputBindingNameQueueMessage); + await StorageHelpers.ClearQueue(Constants.Queue.InputBindingNameQueueMessage); + + //Set up and trigger + await StorageHelpers.CreateQueue(Constants.Queue.OutputBindingNameQueueMessage); + await StorageHelpers.InsertIntoQueue(Constants.Queue.InputBindingNameQueueMessage, expectedQueueMessage); + + //Verify + var queueMessage = await StorageHelpers.ReadFromQueue(Constants.Queue.OutputBindingNameQueueMessage); + Assert.Equal(expectedQueueMessage, queueMessage); + } + + [Fact] + public async Task BinaryDataQueueTriggerAndOutput() + { + string expectedQueueMessage = Guid.NewGuid().ToString(); + + //Clear queue + await StorageHelpers.ClearQueue(Constants.Queue.OutputBindingNameBinaryData); + await StorageHelpers.ClearQueue(Constants.Queue.InputBindingNameBinaryData); + + //Set up and trigger + await StorageHelpers.CreateQueue(Constants.Queue.OutputBindingNameBinaryData); + await StorageHelpers.InsertIntoQueue(Constants.Queue.InputBindingNameBinaryData, expectedQueueMessage); + + //Verify + var queueMessage = await StorageHelpers.ReadFromQueue(Constants.Queue.OutputBindingNameBinaryData); + Assert.Equal(expectedQueueMessage, queueMessage); + } } } diff --git a/test/FunctionMetadataGeneratorTests/FunctionMetadataGeneratorTests.cs b/test/FunctionMetadataGeneratorTests/FunctionMetadataGeneratorTests.cs index 3189d7dea..d7c439af7 100644 --- a/test/FunctionMetadataGeneratorTests/FunctionMetadataGeneratorTests.cs +++ b/test/FunctionMetadataGeneratorTests/FunctionMetadataGeneratorTests.cs @@ -11,6 +11,7 @@ using System.Reflection; using System.Threading.Tasks; using Azure.Storage.Blobs; +using Azure.Storage.Queues.Models; using Microsoft.Azure.Functions.Tests; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; @@ -171,7 +172,7 @@ public void StorageFunctions() AssertDictionary(extensions, new Dictionary { - { "Microsoft.Azure.WebJobs.Extensions.Storage.Queues", "5.1.2" }, + { "Microsoft.Azure.WebJobs.Extensions.Storage.Queues", "5.1.3" }, { "Microsoft.Azure.WebJobs.Extensions.Storage.Blobs", "5.1.3" }, }); @@ -460,6 +461,57 @@ void ValidateTrigger(ExpandoObject b) } } + [Fact] + public void QueueStorageFunctions_SDKTypeBindings() + { + var generator = new FunctionMetadataGenerator(); + var module = ModuleDefinition.ReadModule(_thisAssembly.Location); + var typeDef = TestUtility.GetTypeDefinition(typeof(SDKTypeBindings_Queue)); + var functions = generator.GenerateFunctionMetadata(typeDef); + var extensions = generator.Extensions; + + Assert.Equal(2, functions.Count()); + + AssertDictionary(extensions, new Dictionary + { + { "Microsoft.Azure.WebJobs.Extensions.Storage.Queues", "5.1.3" }, + }); + + var queueMessageTriggerFunction = functions.Single(p => p.Name == nameof(SDKTypeBindings_Queue.QueueMessageTrigger)); + + ValidateFunction(queueMessageTriggerFunction, nameof(SDKTypeBindings_Queue.QueueMessageTrigger), GetEntryPoint(nameof(SDKTypeBindings_Queue), nameof(SDKTypeBindings_Queue.QueueMessageTrigger)), + ValidateQueueMessageTrigger); + + var queueBinaryDataTriggerFunction = functions.Single(p => p.Name == nameof(SDKTypeBindings_Queue.QueueBinaryDataTrigger)); + + ValidateFunction(queueBinaryDataTriggerFunction, nameof(SDKTypeBindings_Queue.QueueBinaryDataTrigger), GetEntryPoint(nameof(SDKTypeBindings_Queue), nameof(SDKTypeBindings_Queue.QueueBinaryDataTrigger)), + ValidateQueueBinaryDataTrigger); + + void ValidateQueueMessageTrigger(ExpandoObject b) + { + AssertExpandoObject(b, new Dictionary + { + { "Name", "message" }, + { "Type", "queueTrigger" }, + { "Direction", "In" }, + { "queueName", "queue" }, + { "Properties", new Dictionary( ) { { "SupportsDeferredBinding" , "True"} } } + }); + } + + void ValidateQueueBinaryDataTrigger(ExpandoObject b) + { + AssertExpandoObject(b, new Dictionary + { + { "Name", "message" }, + { "Type", "queueTrigger" }, + { "Direction", "In" }, + { "queueName", "queue" }, + { "Properties", new Dictionary( ) { { "SupportsDeferredBinding" , "True"} } } + }); + } + } + [Fact] public void MultiOutput_OnReturnType() { @@ -480,7 +532,7 @@ public void MultiOutput_OnReturnType() AssertDictionary(extensions, new Dictionary { - { "Microsoft.Azure.WebJobs.Extensions.Storage.Queues", "5.1.2" }, + { "Microsoft.Azure.WebJobs.Extensions.Storage.Queues", "5.1.3" }, { "Microsoft.Azure.WebJobs.Extensions.Storage.Blobs", "5.1.3" }, }); @@ -546,7 +598,7 @@ public void MultiOutput_OnReturnType_WithHttp() AssertDictionary(extensions, new Dictionary { - { "Microsoft.Azure.WebJobs.Extensions.Storage.Queues", "5.1.2" } + { "Microsoft.Azure.WebJobs.Extensions.Storage.Queues", "5.1.3" } }); void ValidateHttpTrigger(ExpandoObject b) @@ -1026,6 +1078,58 @@ private class SDKTypeBindings_BlobCollection } } + private class SDKTypeBindings_Queue + { + [Function(nameof(QueueMessageTrigger))] + public static void QueueMessageTrigger( + [QueueTrigger("queue")] QueueMessage message) + { + throw new NotImplementedException(); + } + + [Function(nameof(QueueBinaryDataTrigger))] + public static void QueueBinaryDataTrigger( + [QueueTrigger("queue")] BinaryData message) + + { + throw new NotImplementedException(); + } + } + + private class SDKTypeBindings_ServiceBus + { + [Function(nameof(ServiceBusTriggerFunction))] + public static void ServiceBusTriggerFunction( + [ServiceBusTrigger("queue")] ServiceBusReceivedMessage message) + { + throw new NotImplementedException(); + } + + [Function(nameof(ServiceBusBatchTriggerFunction))] + public static void ServiceBusBatchTriggerFunction( + [ServiceBusTrigger("queue", IsBatched = true)] ServiceBusReceivedMessage[] messages) + { + throw new NotImplementedException(); + } + } + + private class SDKTypeBindings_EventHubs + { + [Function(nameof(EventHubTriggerFunction))] + public static void EventHubTriggerFunction( + [EventHubTrigger("hub", IsBatched = false)] EventData @event) + { + throw new NotImplementedException(); + } + + [Function(nameof(EventHubBatchTriggerFunction))] + public static void EventHubBatchTriggerFunction( + [EventHubTrigger("hub")] EventData[] events) + { + throw new NotImplementedException(); + } + } + private class ExternalType_Return { public const string FunctionName = "BasicHttpWithExternalTypeReturn"; diff --git a/test/SdkE2ETests/Contents/WorkerBindingSamplesOutput/functions.metadata b/test/SdkE2ETests/Contents/WorkerBindingSamplesOutput/functions.metadata index abc769bcc..f21092ab1 100644 --- a/test/SdkE2ETests/Contents/WorkerBindingSamplesOutput/functions.metadata +++ b/test/SdkE2ETests/Contents/WorkerBindingSamplesOutput/functions.metadata @@ -386,5 +386,45 @@ "properties": {} } ] + }, + { + "name": "QueueMessageFunction", + "scriptFile": "WorkerBindingSamples.dll", + "entryPoint": "SampleApp.QueueSamples.QueueMessageFunction", + "language": "dotnet-isolated", + "properties": { + "IsCodeless": false + }, + "bindings": [ + { + "name": "message", + "direction": "In", + "type": "queueTrigger", + "queueName": "input-queue", + "properties": { + "supportsDeferredBinding": "True" + } + } + ] + }, + { + "name": "QueueBinaryDataFunction", + "scriptFile": "WorkerBindingSamples.dll", + "entryPoint": "SampleApp.QueueSamples.QueueBinaryDataFunction", + "language": "dotnet-isolated", + "properties": { + "IsCodeless": false + }, + "bindings": [ + { + "name": "message", + "direction": "In", + "type": "queueTrigger", + "queueName": "input-queue-binarydata", + "properties": { + "supportsDeferredBinding": "True" + } + } + ] } ] \ No newline at end of file diff --git a/test/SdkE2ETests/PublishTests.cs b/test/SdkE2ETests/PublishTests.cs index 6eeefa782..3ace06605 100644 --- a/test/SdkE2ETests/PublishTests.cs +++ b/test/SdkE2ETests/PublishTests.cs @@ -67,7 +67,7 @@ private async Task RunPublishTest(string outputDir, string additionalParams = nu "Microsoft.Azure.WebJobs.Extensions.Storage.AzureStorageBlobsWebJobsStartup, Microsoft.Azure.WebJobs.Extensions.Storage.Blobs, Version=5.1.3.0, Culture=neutral, PublicKeyToken=92742159e12e44c8", @"./.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.1.2.0, Culture=neutral, PublicKeyToken=92742159e12e44c8", + "Microsoft.Azure.WebJobs.Extensions.Storage.AzureStorageQueuesWebJobsStartup, Microsoft.Azure.WebJobs.Extensions.Storage.Queues, Version=5.1.3.0, Culture=neutral, PublicKeyToken=92742159e12e44c8", @"./.azurefunctions/Microsoft.Azure.WebJobs.Extensions.Storage.Queues.dll") } }); @@ -77,7 +77,6 @@ private async Task RunPublishTest(string outputDir, string additionalParams = nu TestUtility.ValidateFunctionsMetadata(functionsMetadataPath, "Microsoft.Azure.Functions.SdkE2ETests.Contents.functions.metadata"); } - [Fact] public async Task Publish_SdkTypeBindings() { @@ -127,7 +126,10 @@ private async Task RunPublishTestForSdkTypeBindings(string outputDir, string add @"./.azurefunctions/Microsoft.Azure.WebJobs.Extensions.FunctionMetadataLoader.dll"), new Extension("AzureStorageBlobs", "Microsoft.Azure.WebJobs.Extensions.Storage.AzureStorageBlobsWebJobsStartup, Microsoft.Azure.WebJobs.Extensions.Storage.Blobs, Version=5.1.3.0, Culture=neutral, PublicKeyToken=92742159e12e44c8", - @"./.azurefunctions/Microsoft.Azure.WebJobs.Extensions.Storage.Blobs.dll") + @"./.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.1.3.0, Culture=neutral, PublicKeyToken=92742159e12e44c8", + @"./.azurefunctions/Microsoft.Azure.WebJobs.Extensions.Storage.Queues.dll") } }); Assert.True(JToken.DeepEquals(extensionsJsonContents, expected), $"Actual: {extensionsJsonContents}{Environment.NewLine}Expected: {expected}"); diff --git a/test/Worker.Extensions.Tests/Queue/QueueMessageBinaryDataConverterTests.cs b/test/Worker.Extensions.Tests/Queue/QueueMessageBinaryDataConverterTests.cs new file mode 100644 index 000000000..c92e34f5e --- /dev/null +++ b/test/Worker.Extensions.Tests/Queue/QueueMessageBinaryDataConverterTests.cs @@ -0,0 +1,84 @@ +// 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.Threading.Tasks; +using Microsoft.Azure.Functions.Worker.Converters; +using Microsoft.Azure.Functions.Worker.Tests.Converters; +using Microsoft.Extensions.Hosting; +using Xunit; + +// AzureStorageQueues + +namespace Microsoft.Azure.Functions.Worker.Extensions.Tests.Queue +{ + public class QueueMessageBinaryDataConverterTests + { + public QueueMessageBinaryDataConverterTests() + { + var host = new HostBuilder().ConfigureFunctionsWorkerDefaults((WorkerOptions options) => { }).Build(); + } + + [Fact] + public async Task ConvertAsync_ValidModelBindingData_BinaryData_ReturnsSuccess() + { + var grpcModelBindingData = GrpcTestHelper.GetTestGrpcModelBindingData(QueuesTestHelper.GetTestBinaryData(), "AzureStorageQueues"); + var context = new TestConverterContext(typeof(BinaryData), grpcModelBindingData); + + var queueMessageConverter = new QueueMessageBinaryDataConverter(); + var conversionResult = await queueMessageConverter.ConvertAsync(context); + + var expectedData = conversionResult.Value as BinaryData; + Assert.Equal(ConversionStatus.Succeeded, conversionResult.Status); + Assert.Equal("hello world", expectedData.ToString()); + } + + [Fact] + public async Task ConvertAsync_ContentSource_AsObject_ReturnsUnhandled() + { + var context = new TestConverterContext(typeof(BinaryData), new Object()); + + var queueMessageConverter = new QueueMessageBinaryDataConverter(); + var conversionResult = await queueMessageConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Unhandled, conversionResult.Status); + } + + [Fact] + public async Task ConvertAsync_ModelBindingData_Null_ReturnsUnhandled() + { + var context = new TestConverterContext(typeof(BinaryData), null); + + var queueMessageConverter = new QueueMessageBinaryDataConverter(); + var conversionResult = await queueMessageConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Unhandled, conversionResult.Status); + } + + [Fact] + public async Task ConvertAsync_ModelBindingDataSource_NotQueueStorageExtension_ReturnsFailed() + { + var grpcModelBindingData = GrpcTestHelper.GetTestGrpcModelBindingData(QueuesTestHelper.GetTestBinaryData(), source: "anotherExtensions"); + var context = new TestConverterContext(typeof(BinaryData), grpcModelBindingData); + + var queueMessageConverter = new QueueMessageBinaryDataConverter(); + var conversionResult = await queueMessageConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Failed, conversionResult.Status); + Assert.Equal("Unexpected binding source 'anotherExtensions'. Only 'AzureStorageQueues' is supported.", conversionResult.Error.Message); + } + + [Fact] + public async Task ConvertAsync_ModelBindingDataContentType_Unsupported_ReturnsFailed() + { + var grpcModelBindingData = GrpcTestHelper.GetTestGrpcModelBindingData(QueuesTestHelper.GetTestBinaryData(), "AzureStorageQueues", contentType: "binary"); + var context = new TestConverterContext(typeof(BinaryData), grpcModelBindingData); + + var queueMessageConverter = new QueueMessageBinaryDataConverter(); + var conversionResult = await queueMessageConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Failed, conversionResult.Status); + Assert.Equal("Unexpected content-type 'binary'. Only 'application/json' is supported.", conversionResult.Error.Message); + } + } +} diff --git a/test/Worker.Extensions.Tests/Queue/QueueMessageConverterTests.cs b/test/Worker.Extensions.Tests/Queue/QueueMessageConverterTests.cs new file mode 100644 index 000000000..05c20b8b0 --- /dev/null +++ b/test/Worker.Extensions.Tests/Queue/QueueMessageConverterTests.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.Threading.Tasks; +using Azure.Storage.Queues.Models; +using Microsoft.Azure.Functions.Worker.Converters; +using Microsoft.Azure.Functions.Worker.Tests.Converters; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace Microsoft.Azure.Functions.Worker.Extensions.Tests.Queue +{ + public class QueueMessageConverterTests + { + public QueueMessageConverterTests() + { + var host = new HostBuilder().ConfigureFunctionsWorkerDefaults((WorkerOptions options) => { }).Build(); + } + + [Fact] + public async Task ConvertAsync_ValidModelBindingData_QueueMessage_ReturnsSuccess() + { + var grpcModelBindingData = GrpcTestHelper.GetTestGrpcModelBindingData(QueuesTestHelper.GetTestBinaryData(), "AzureStorageQueues"); + var context = new TestConverterContext(typeof(QueueMessage), grpcModelBindingData); + + var queueMessageConverter = new QueueMessageConverter(); + var conversionResult = await queueMessageConverter.ConvertAsync(context); + + var expectedData = conversionResult.Value as QueueMessage; + Assert.Equal(ConversionStatus.Succeeded, conversionResult.Status); + Assert.Equal("hello world", expectedData.Body.ToString()); + } + + [Fact] + public async Task ConvertAsync_ContentSource_AsObject_ReturnsUnhandled() + { + var context = new TestConverterContext(typeof(QueueMessage), new Object()); + + var queueMessageConverter = new QueueMessageConverter(); + var conversionResult = await queueMessageConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Unhandled, conversionResult.Status); + } + + [Fact] + public async Task ConvertAsync_ModelBindingData_Null_ReturnsUnhandled() + { + var context = new TestConverterContext(typeof(QueueMessage), null); + + var queueMessageConverter = new QueueMessageConverter(); + var conversionResult = await queueMessageConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Unhandled, conversionResult.Status); + } + + [Fact] + public async Task ConvertAsync_ModelBindingDataSource_NotQueueStorageExtension_ReturnsFailed() + { + var grpcModelBindingData = GrpcTestHelper.GetTestGrpcModelBindingData(QueuesTestHelper.GetTestBinaryData(), source: "anotherExtensions"); + var context = new TestConverterContext(typeof(QueueMessage), grpcModelBindingData); + + var queueMessageConverter = new QueueMessageConverter(); + var conversionResult = await queueMessageConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Failed, conversionResult.Status); + Assert.Equal("Unexpected binding source 'anotherExtensions'. Only 'AzureStorageQueues' is supported.", conversionResult.Error.Message); + } + + [Fact] + public async Task ConvertAsync_ModelBindingDataContentType_Unsupported_ReturnsFailed() + { + var grpcModelBindingData = GrpcTestHelper.GetTestGrpcModelBindingData(QueuesTestHelper.GetTestBinaryData(), "AzureStorageQueues", contentType: "binary"); + var context = new TestConverterContext(typeof(QueueMessage), grpcModelBindingData); + + var queueMessageConverter = new QueueMessageConverter(); + var conversionResult = await queueMessageConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Failed, conversionResult.Status); + Assert.Equal("Unexpected content-type 'binary'. Only 'application/json' is supported.", conversionResult.Error.Message); + } + } +} diff --git a/test/Worker.Extensions.Tests/Queue/QueueMessageJsonConverterTests.cs b/test/Worker.Extensions.Tests/Queue/QueueMessageJsonConverterTests.cs new file mode 100644 index 000000000..cfa1ed9f9 --- /dev/null +++ b/test/Worker.Extensions.Tests/Queue/QueueMessageJsonConverterTests.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 System.Text.Json; +using Azure.Storage.Queues.Models; +using Microsoft.Azure.Functions.Worker.Storage.Queues; +using Xunit; + +namespace Microsoft.Azure.Functions.Worker.Extensions.Tests.Queue +{ + public class QueueMessageJsonConverterTests + { + [Fact] + public void QueueMessageJsonConverter_ValidJson_ReturnsQueueMessage() + { + var data = GetTestBinaryData(); + + JsonSerializerOptions options = new() { Converters = { new QueueMessageJsonConverter() } }; + var result = data.ToObjectFromJson(options); + + Assert.Equal(typeof(QueueMessage), result.GetType()); + } + + private BinaryData GetTestBinaryData(string messageId = "fbb84c41-9f1f-4c75-950c-72d0541fb8ae", string message = "hello world") + { + string jsonData = $@"{{ + ""MessageId"" : ""{messageId}"", + ""PopReceipt"" : ""AgAAAAMAAAAAAAAASm\u002B7xBZv2QE="", + ""MessageText"" : ""{message}"", + ""Body"" : {{}}, + ""NextVisibleOn"" : ""2023-04-14T21:19:16+00:00"", + ""InsertedOn"" : ""2023-04-14T21:09:14+00:00"", + ""ExpiresOn"" : ""2023-04-21T21:09:14+00:00"", + ""DequeueCount"" : 1 + }}"; + + return new BinaryData(jsonData); + } + } +} diff --git a/test/Worker.Extensions.Tests/Queue/QueuesTestHelper.cs b/test/Worker.Extensions.Tests/Queue/QueuesTestHelper.cs new file mode 100644 index 000000000..0967a15a9 --- /dev/null +++ b/test/Worker.Extensions.Tests/Queue/QueuesTestHelper.cs @@ -0,0 +1,26 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.Azure.Functions.Worker.Extensions.Tests.Queue +{ + internal static class QueuesTestHelper + { + public static BinaryData GetTestBinaryData(string messageId = "fbb84c41-9f1f-4c75-950c-72d0541fb8ae", string message = "hello world") + { + string jsonData = $@"{{ + ""MessageId"" : ""{messageId}"", + ""PopReceipt"" : ""AgAAAAMAAAAAAAAASm\u002B7xBZv2QE="", + ""MessageText"" : ""{message}"", + ""Body"" : {{}}, + ""NextVisibleOn"" : ""2023-04-14T21:19:16+00:00"", + ""InsertedOn"" : ""2023-04-14T21:09:14+00:00"", + ""ExpiresOn"" : ""2023-04-21T21:09:14+00:00"", + ""DequeueCount"" : 1 + }}"; + + return new BinaryData(jsonData); + } + } +}