From 257add7f6f3ee047028e00dec4993ce76c90e767 Mon Sep 17 00:00:00 2001 From: Lilian Kasem Date: Wed, 22 Feb 2023 15:43:05 -0800 Subject: [PATCH 01/13] Add E2E tests for blob SDK type bindings (#1360) --- .../E2EApp/Blob/BlobInputBindingFunctions.cs | 134 ++++++++ .../E2EApps/E2EApp/Blob/BlobTestFunctions.cs | 57 ---- .../Blob/BlobTriggerBindingFunctions.cs | 90 ++++++ test/E2ETests/E2EApps/E2EApp/Blob/Book.cs | 11 + test/E2ETests/E2EApps/E2EApp/E2EApp.csproj | 4 +- test/E2ETests/E2ETests/Constants.cs | 18 +- .../{ => Cosmos}/CosmosDBEndToEndTests.cs | 4 +- .../E2ETests/Helpers/StorageHelpers.cs | 6 + .../E2ETests/Storage/BlobEndToEndTests.cs | 292 ++++++++++++++++++ .../QueueEndToEndTests.cs} | 73 +---- 10 files changed, 557 insertions(+), 132 deletions(-) create mode 100644 test/E2ETests/E2EApps/E2EApp/Blob/BlobInputBindingFunctions.cs delete mode 100644 test/E2ETests/E2EApps/E2EApp/Blob/BlobTestFunctions.cs create mode 100644 test/E2ETests/E2EApps/E2EApp/Blob/BlobTriggerBindingFunctions.cs create mode 100644 test/E2ETests/E2EApps/E2EApp/Blob/Book.cs rename test/E2ETests/E2ETests/{ => Cosmos}/CosmosDBEndToEndTests.cs (93%) create mode 100644 test/E2ETests/E2ETests/Storage/BlobEndToEndTests.cs rename test/E2ETests/E2ETests/{StorageEndToEndTests.cs => Storage/QueueEndToEndTests.cs} (76%) diff --git a/test/E2ETests/E2EApps/E2EApp/Blob/BlobInputBindingFunctions.cs b/test/E2ETests/E2EApps/E2EApp/Blob/BlobInputBindingFunctions.cs new file mode 100644 index 000000000..4e6798c90 --- /dev/null +++ b/test/E2ETests/E2EApps/E2EApp/Blob/BlobInputBindingFunctions.cs @@ -0,0 +1,134 @@ +// 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.IO; +using System.Net; +using System.Text; +using System.Threading.Tasks; +using Azure.Storage.Blobs; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Azure.Functions.Worker.E2EApp.Blob +{ + public class BlobInputBindingFunctions + { + private readonly ILogger _logger; + + public BlobInputBindingFunctions(ILogger logger) + { + _logger = logger; + } + + [Function(nameof(BlobInputClientTest))] + public async Task BlobInputClientTest( + [HttpTrigger(AuthorizationLevel.Function, "get", "post")] HttpRequestData req, + [BlobInput("test-input-dotnet-isolated/testFile.txt")] BlobClient client) + { + var downloadResult = await client.DownloadContentAsync(); + var response = req.CreateResponse(HttpStatusCode.OK); + await response.Body.WriteAsync(downloadResult.Value.Content); + return response; + } + + [Function(nameof(BlobInputContainerClientTest))] + public async Task BlobInputContainerClientTest( + [HttpTrigger(AuthorizationLevel.Function, "get", "post")] HttpRequestData req, + [BlobInput("test-input-dotnet-isolated/testFile.txt")] BlobContainerClient client) + { + var blobClient = client.GetBlobClient("testFile.txt"); + var downloadResult = await blobClient.DownloadContentAsync(); + var response = req.CreateResponse(HttpStatusCode.OK); + await response.Body.WriteAsync(downloadResult.Value.Content); + return response; + } + + [Function(nameof(BlobInputStreamTest))] + public async Task BlobInputStreamTest( + [HttpTrigger(AuthorizationLevel.Function, "get", "post")] HttpRequestData req, + [BlobInput("test-input-dotnet-isolated/testFile.txt")] Stream stream) + { + using var blobStreamReader = new StreamReader(stream); + var response = req.CreateResponse(HttpStatusCode.OK); + await response.WriteStringAsync(blobStreamReader.ReadToEnd()); + return response; + } + + [Function(nameof(BlobInputByteTest))] + public async Task BlobInputByteTest( + [HttpTrigger(AuthorizationLevel.Function, "get", "post")] HttpRequestData req, + [BlobInput("test-input-dotnet-isolated/testFile.txt")] Byte[] data) + { + var response = req.CreateResponse(HttpStatusCode.OK); + await response.WriteStringAsync(Encoding.Default.GetString(data)); + return response; + } + + [Function(nameof(BlobInputStringTest))] + public async Task BlobInputStringTest( + [HttpTrigger(AuthorizationLevel.Function, "get", "post")] HttpRequestData req, + [BlobInput("test-input-dotnet-isolated/testFile.txt")] string data) + { + var response = req.CreateResponse(HttpStatusCode.OK); + await response.WriteStringAsync(data); + return response; + } + + [Function(nameof(BlobInputPocoTest))] + public async Task BlobInputPocoTest( + [HttpTrigger(AuthorizationLevel.Function, "get", "post")] HttpRequestData req, + [BlobInput("test-input-dotnet-isolated/testFile.txt")] Book data) + { + var response = req.CreateResponse(HttpStatusCode.OK); + await response.WriteStringAsync(data.Name); + return response; + } + + [Function(nameof(BlobInputCollectionTest))] + public async Task BlobInputCollectionTest( + [HttpTrigger(AuthorizationLevel.Function, "get", "post")] HttpRequestData req, + [BlobInput("test-input-dotnet-isolated", IsBatched = true)] IEnumerable blobs) + { + List blobList = new(); + + foreach (BlobClient blob in blobs) + { + _logger.LogInformation("Blob name: {blobName}, Container name: {containerName}", blob.Name, blob.BlobContainerName); + blobList.Add(blob.Name); + } + + var response = req.CreateResponse(HttpStatusCode.OK); + await response.WriteStringAsync(blobList.ToString()); + return response; + } + + [Function(nameof(BlobInputStringArrayTest))] + public async Task BlobInputStringArrayTest( + [HttpTrigger(AuthorizationLevel.Function, "get", "post")] HttpRequestData req, + [BlobInput("test-input-dotnet-isolated", IsBatched = true)] string[] blobContent) + { + var response = req.CreateResponse(HttpStatusCode.OK); + await response.WriteStringAsync(blobContent.ToString()); + return response; + } + + [Function(nameof(BlobInputPocoArrayTest))] + public async Task BlobInputPocoArrayTest( + [HttpTrigger(AuthorizationLevel.Function, "get", "post")] HttpRequestData req, + [BlobInput("test-input-dotnet-isolated", IsBatched = true)] Book[] books) + { + List bookNames = new(); + + foreach (var item in books) + { + bookNames.Add(item.Name); + } + + var response = req.CreateResponse(HttpStatusCode.OK); + await response.WriteStringAsync(bookNames.ToString()); + return response; + } + } +} \ No newline at end of file diff --git a/test/E2ETests/E2EApps/E2EApp/Blob/BlobTestFunctions.cs b/test/E2ETests/E2EApps/E2EApp/Blob/BlobTestFunctions.cs deleted file mode 100644 index b375e044a..000000000 --- a/test/E2ETests/E2EApps/E2EApp/Blob/BlobTestFunctions.cs +++ /dev/null @@ -1,57 +0,0 @@ -// 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.Serialization; -using Microsoft.Azure.Functions.Worker; -using Microsoft.Extensions.Logging; - -namespace Microsoft.Azure.Functions.Worker.E2EApp.Blob -{ - public class BlobTestFunctions - { - private readonly ILogger _logger; - - public BlobTestFunctions(ILogger logger) - { - _logger = logger; - } - - [Function(nameof(BlobTriggerToBlobTest))] - [BlobOutput("test-output-dotnet-isolated/{name}")] - public byte[] BlobTriggerToBlobTest( - [BlobTrigger("test-triggerinput-dotnet-isolated/{name}")] byte[] triggerBlob, string name, - [BlobInput("test-input-dotnet-isolated/{name}")] byte[] inputBlob, - FunctionContext context) - { - _logger.LogInformation("Trigger:\n Name: " + name + "\n Size: " + triggerBlob.Length + " Bytes"); - _logger.LogInformation("Input:\n Name: " + name + "\n Size: " + inputBlob.Length + " Bytes"); - return inputBlob; - } - - [Function(nameof(BlobTriggerPocoTest))] - [BlobOutput("test-outputpoco-dotnet-isolated/{name}")] - public TestBlobData BlobTriggerPocoTest( - [BlobTrigger("test-triggerinputpoco-dotnet-isolated/{name}")] TestBlobData triggerBlob, string name, - FunctionContext context) - { - _logger.LogInformation(".NET Blob trigger function processed a blob.\n Name: " + name + "\n Content: " + triggerBlob.BlobText); - return triggerBlob; - } - - [Function(nameof(BlobTriggerStringTest))] - [BlobOutput("test-outputstring-dotnet-isolated/{name}")] - public string BlobTriggerStringTest( - [BlobTrigger("test-triggerinputstring-dotnet-isolated/{name}")] string triggerBlobText, string name, - FunctionContext context) - { - _logger.LogInformation(".NET Blob trigger function processed a blob.\n Name: " + name + "\n Content: " + triggerBlobText); - return triggerBlobText; - } - - public class TestBlobData - { - [JsonPropertyName("text")] - public string BlobText { get; set; } - } - } -} diff --git a/test/E2ETests/E2EApps/E2EApp/Blob/BlobTriggerBindingFunctions.cs b/test/E2ETests/E2EApps/E2EApp/Blob/BlobTriggerBindingFunctions.cs new file mode 100644 index 000000000..ecce43f1a --- /dev/null +++ b/test/E2ETests/E2EApps/E2EApp/Blob/BlobTriggerBindingFunctions.cs @@ -0,0 +1,90 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System.IO; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using Azure.Storage.Blobs; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Azure.Functions.Worker.E2EApp.Blob +{ + public class BlobTriggerBindingFunctions + { + private readonly ILogger _logger; + + public BlobTriggerBindingFunctions(ILogger logger) + { + _logger = logger; + } + + [Function(nameof(BlobTriggerToBlobTest))] + [BlobOutput("test-output-dotnet-isolated/{name}")] + public byte[] BlobTriggerToBlobTest( + [BlobTrigger("test-trigger-dotnet-isolated/{name}")] byte[] triggerBlob, string name, + [BlobInput("test-input-dotnet-isolated/{name}")] byte[] inputBlob, + FunctionContext context) + { + _logger.LogInformation("Trigger:\n Name: " + name + "\n Size: " + triggerBlob.Length + " Bytes"); + _logger.LogInformation("Input:\n Name: " + name + "\n Size: " + inputBlob.Length + " Bytes"); + return inputBlob; + } + + [Function(nameof(BlobTriggerPocoTest))] + [BlobOutput("test-output-poco-dotnet-isolated/{name}")] + public TestBlobData BlobTriggerPocoTest( + [BlobTrigger("test-trigger-poco-dotnet-isolated/{name}")] TestBlobData triggerBlob, string name, + FunctionContext context) + { + _logger.LogInformation(".NET Blob trigger function processed a blob.\n Name: " + name + "\n Content: " + triggerBlob.BlobText); + return triggerBlob; + } + + [Function(nameof(BlobTriggerStringTest))] + [BlobOutput("test-output-string-dotnet-isolated/{name}")] + public string BlobTriggerStringTest( + [BlobTrigger("test-trigger-string-dotnet-isolated/{name}")] string triggerBlobText, string name, + FunctionContext context) + { + _logger.LogInformation(".NET Blob trigger function processed a blob.\n Name: " + name + "\n Content: " + triggerBlobText); + return triggerBlobText; + } + + [Function(nameof(BlobTriggerStreamTest))] + public async Task BlobTriggerStreamTest( + [BlobTrigger("test-trigger-stream-dotnet-isolated/{name}")] Stream stream, string name, + FunctionContext context) + { + using var blobStreamReader = new StreamReader(stream); + string content = await blobStreamReader.ReadToEndAsync(); + _logger.LogInformation("StreamTriggerOutput: {c}", content); + } + + [Function(nameof(BlobTriggerBlobClientTest))] + public async Task BlobTriggerBlobClientTest( + [BlobTrigger("test-trigger-blobclient-dotnet-isolated/{name}")] BlobClient client, string name, + FunctionContext context) + { + var downloadResult = await client.DownloadContentAsync(); + string content = downloadResult.Value.Content.ToString(); + _logger.LogInformation("BlobClientTriggerOutput: {c}", content); + } + + [Function(nameof(BlobTriggerBlobContainerClientTest))] + public async Task BlobTriggerBlobContainerClientTest( + [BlobTrigger("test-trigger-containerclient-dotnet-isolated/{name}")] BlobContainerClient client, string name, + FunctionContext context) + { + var blobClient = client.GetBlobClient(name); + var downloadResult = await blobClient.DownloadContentAsync(); + string content = downloadResult.Value.Content.ToString(); + _logger.LogInformation("BlobContainerTriggerOutput: {c}", content); + } + + public class TestBlobData + { + [JsonPropertyName("text")] + public string BlobText { get; set; } + } + } +} diff --git a/test/E2ETests/E2EApps/E2EApp/Blob/Book.cs b/test/E2ETests/E2EApps/E2EApp/Blob/Book.cs new file mode 100644 index 000000000..2cea88356 --- /dev/null +++ b/test/E2ETests/E2EApps/E2EApp/Blob/Book.cs @@ -0,0 +1,11 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +namespace Microsoft.Azure.Functions.Worker.E2EApp.Blob +{ + public class Book + { + public string Id { get; set; } + public string Name { get; set; } + } +} \ No newline at end of file diff --git a/test/E2ETests/E2EApps/E2EApp/E2EApp.csproj b/test/E2ETests/E2EApps/E2EApp/E2EApp.csproj index f6fd3de04..2c799e2ef 100644 --- a/test/E2ETests/E2EApps/E2EApp/E2EApp.csproj +++ b/test/E2ETests/E2EApps/E2EApp/E2EApp.csproj @@ -42,8 +42,8 @@ - - + + diff --git a/test/E2ETests/E2ETests/Constants.cs b/test/E2ETests/E2ETests/Constants.cs index 70256b28a..f9cd316f0 100644 --- a/test/E2ETests/E2ETests/Constants.cs +++ b/test/E2ETests/E2ETests/Constants.cs @@ -34,15 +34,19 @@ public static class Queue //Blob tests public static class Blob { - public const string TriggerInputBindingContainer = "test-triggerinput-dotnet-isolated"; + public const string TriggerInputBindingContainer = "test-trigger-dotnet-isolated"; public const string InputBindingContainer = "test-input-dotnet-isolated"; public const string OutputBindingContainer = "test-output-dotnet-isolated"; - - public const string TriggerPocoContainer = "test-triggerinputpoco-dotnet-isolated"; - public const string OutputPocoContainer = "test-outputpoco-dotnet-isolated"; - - public const string TriggerStringContainer = "test-triggerinputstring-dotnet-isolated"; - public const string OutputStringContainer = "test-outputstring-dotnet-isolated"; + public const string TriggerPocoContainer = "test-trigger-poco-dotnet-isolated"; + public const string OutputPocoContainer = "test-output-poco-dotnet-isolated"; + public const string TriggerStringContainer = "test-trigger-string-dotnet-isolated"; + public const string OutputStringContainer = "test-output-string-dotnet-isolated"; + public const string TriggerStreamContainer = "test-trigger-stream-dotnet-isolated"; + public const string OutputStreamContainer = "test-output-stream-dotnet-isolated"; + public const string TriggerBlobClientContainer = "test-trigger-blobclient-dotnet-isolated"; + public const string OutputBlobClientContainer = "test-output-blobclient-dotnet-isolated"; + public const string TriggerBlobContainerClientContainer = "test-trigger-containerclient-dotnet-isolated"; + public const string OutputBlobContainerClientContainer = "test-output-containerclient-dotnet-isolated"; } // CosmosDB tests diff --git a/test/E2ETests/E2ETests/CosmosDBEndToEndTests.cs b/test/E2ETests/E2ETests/Cosmos/CosmosDBEndToEndTests.cs similarity index 93% rename from test/E2ETests/E2ETests/CosmosDBEndToEndTests.cs rename to test/E2ETests/E2ETests/Cosmos/CosmosDBEndToEndTests.cs index e44d215eb..71fb75934 100644 --- a/test/E2ETests/E2ETests/CosmosDBEndToEndTests.cs +++ b/test/E2ETests/E2ETests/Cosmos/CosmosDBEndToEndTests.cs @@ -6,7 +6,7 @@ using Xunit; using Xunit.Abstractions; -namespace Microsoft.Azure.Functions.Tests.E2ETests +namespace Microsoft.Azure.Functions.Tests.E2ETests.Cosmos { [Collection(Constants.FunctionAppCollectionName)] public class CosmosDBEndToEndTests : IDisposable @@ -26,7 +26,7 @@ public async Task CosmosDBTriggerAndOutput_Succeeds() string expectedDocId = Guid.NewGuid().ToString(); try { - //Trigger + //Trigger await CosmosDBHelpers.CreateDocument(expectedDocId); //Read diff --git a/test/E2ETests/E2ETests/Helpers/StorageHelpers.cs b/test/E2ETests/E2ETests/Helpers/StorageHelpers.cs index 191717b48..f710ebcd7 100644 --- a/test/E2ETests/E2ETests/Helpers/StorageHelpers.cs +++ b/test/E2ETests/E2ETests/Helpers/StorageHelpers.cs @@ -108,6 +108,12 @@ public async static Task ClearBlobContainers() await ClearBlobContainer(Constants.Blob.OutputPocoContainer); await ClearBlobContainer(Constants.Blob.TriggerStringContainer); await ClearBlobContainer(Constants.Blob.OutputStringContainer); + await ClearBlobContainer(Constants.Blob.TriggerStreamContainer); + await ClearBlobContainer(Constants.Blob.OutputStreamContainer); + await ClearBlobContainer(Constants.Blob.TriggerBlobClientContainer); + await ClearBlobContainer(Constants.Blob.OutputBlobClientContainer); + await ClearBlobContainer(Constants.Blob.TriggerBlobContainerClientContainer); + await ClearBlobContainer(Constants.Blob.OutputBlobContainerClientContainer); } public static Task UploadFileToContainer(string containerName, string fileName) diff --git a/test/E2ETests/E2ETests/Storage/BlobEndToEndTests.cs b/test/E2ETests/E2ETests/Storage/BlobEndToEndTests.cs new file mode 100644 index 000000000..a3f41d63c --- /dev/null +++ b/test/E2ETests/E2ETests/Storage/BlobEndToEndTests.cs @@ -0,0 +1,292 @@ +// 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.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.Azure.Functions.Tests.E2ETests.Storage +{ + [Collection(Constants.FunctionAppCollectionName)] + public class BlobEndToEndTests : IDisposable + { + private readonly IDisposable _disposeLog; + private FunctionAppFixture _fixture; + + public BlobEndToEndTests(FunctionAppFixture fixture, ITestOutputHelper testOutput) + { + _fixture = fixture; + _disposeLog = _fixture.TestLogs.UseTestLogger(testOutput); + } + + [Fact] + public async Task BlobTriggerToBlob_Succeeds() + { + string fileName = Guid.NewGuid().ToString(); + + //Cleanup + await StorageHelpers.ClearBlobContainers(); + + //Setup + await StorageHelpers.UploadFileToContainer(Constants.Blob.InputBindingContainer, fileName); + + //Trigger + await StorageHelpers.UploadFileToContainer(Constants.Blob.TriggerInputBindingContainer, fileName); + + //Verify + string result = await StorageHelpers.DownloadFileFromContainer(Constants.Blob.OutputBindingContainer, fileName); + + Assert.Equal("Hello World", result); + } + + [Fact] + public async Task BlobTrigger_Poco_Succeeds() + { + string fileName = Guid.NewGuid().ToString(); + + //Cleanup + await StorageHelpers.ClearBlobContainers(); + + //Trigger + var json = JsonSerializer.Serialize(new { text = "Hello World" }); + await StorageHelpers.UploadFileToContainer(Constants.Blob.TriggerPocoContainer, fileName, json); + + //Verify + string result = await StorageHelpers.DownloadFileFromContainer(Constants.Blob.OutputPocoContainer, fileName); + + Assert.Equal(json, result); + } + + [Fact] + public async Task BlobTrigger_String_Succeeds() + { + string fileName = Guid.NewGuid().ToString(); + + //Cleanup + await StorageHelpers.ClearBlobContainers(); + + //Trigger + await StorageHelpers.UploadFileToContainer(Constants.Blob.TriggerStringContainer, fileName); + + //Verify + string result = await StorageHelpers.DownloadFileFromContainer(Constants.Blob.OutputStringContainer, fileName); + + Assert.Equal("Hello World", result); + } + + [Fact] + public async Task BlobTrigger_Stream_Succeeds() + { + string key = "StreamTriggerOutput: "; + string fileName = Guid.NewGuid().ToString(); + + //Cleanup + await StorageHelpers.ClearBlobContainers(); + + //Trigger + await StorageHelpers.UploadFileToContainer(Constants.Blob.TriggerStreamContainer, fileName); + + //Verify + IEnumerable logs = null; + await TestUtility.RetryAsync(() => + { + logs = _fixture.TestLogs.CoreToolsLogs.Where(p => p.Contains(key)); + return Task.FromResult(logs.Count() >= 1); + }); + + var lastLog = logs.Last(); + int subStringStart = lastLog.LastIndexOf(key) + key.Length; + var result = lastLog[subStringStart..]; + + Assert.Equal("Hello World", result); + } + + [Fact] + public async Task BlobTrigger_BlobClient_Succeeds() + { + string key = "BlobClientTriggerOutput: "; + string fileName = Guid.NewGuid().ToString(); + + //Cleanup + await StorageHelpers.ClearBlobContainers(); + + //Trigger + await StorageHelpers.UploadFileToContainer(Constants.Blob.TriggerBlobClientContainer, fileName); + + //Verify + IEnumerable logs = null; + await TestUtility.RetryAsync(() => + { + logs = _fixture.TestLogs.CoreToolsLogs.Where(p => p.Contains(key)); + return Task.FromResult(logs.Count() >= 1); + }); + + var lastLog = logs.Last(); + int subStringStart = lastLog.LastIndexOf(key) + key.Length; + var result = lastLog[subStringStart..]; + + Assert.Equal("Hello World", result); + } + + [Fact] + public async Task BlobTrigger_BlobContainerClient_Succeeds() + { + string key = "BlobContainerTriggerOutput: "; + string fileName = Guid.NewGuid().ToString(); + + //Cleanup + await StorageHelpers.ClearBlobContainers(); + + //Trigger + await StorageHelpers.UploadFileToContainer(Constants.Blob.TriggerBlobContainerClientContainer, fileName); + + //Verify + IEnumerable logs = null; + await TestUtility.RetryAsync(() => + { + logs = _fixture.TestLogs.CoreToolsLogs.Where(p => p.Contains(key)); + return Task.FromResult(logs.Count() >= 1); + }); + + var lastLog = logs.Last(); + int subStringStart = lastLog.LastIndexOf(key) + key.Length; + var result = lastLog[subStringStart..]; + + Assert.Equal("Hello World", result); + } + + [Theory] + [InlineData("BlobInputClientTest")] + [InlineData("BlobInputContainerClientTest")] + [InlineData("BlobInputStreamTest")] + [InlineData("BlobInputByteTest")] + [InlineData("BlobInputStringTest")] + public async Task BlobInput_SingleCardinality_Succeeds(string functionName) + { + string expectedMessage = "Hello World"; + + //Cleanup + await StorageHelpers.ClearBlobContainers(); + + //Setup + await StorageHelpers.UploadFileToContainer(Constants.Blob.InputBindingContainer, "testFile", expectedMessage); + + //Trigger + HttpResponseMessage response = await HttpHelpers.InvokeHttpTrigger(functionName); + string actualMessage = await response.Content.ReadAsStringAsync(); + + //Verify + HttpStatusCode expectedStatusCode = HttpStatusCode.OK; + + Assert.Equal(expectedStatusCode, response.StatusCode); + Assert.Contains(expectedMessage, actualMessage); + } + + [Fact] + public async Task BlobInput_Poco_Succeeds() + { + //Cleanup + await StorageHelpers.ClearBlobContainers(); + + //Setup + var json = JsonSerializer.Serialize(new { id = "1", name = "To Kill a Mockingbird" }); + await StorageHelpers.UploadFileToContainer(Constants.Blob.InputBindingContainer, "testFile", json); + + //Trigger + HttpResponseMessage response = await HttpHelpers.InvokeHttpTrigger("BlobInputPocoTest"); + string actualMessage = await response.Content.ReadAsStringAsync(); + + //Verify + string expectedMessage = "To Kill a Mockingbird"; + HttpStatusCode expectedStatusCode = HttpStatusCode.OK; + + Assert.Equal(expectedStatusCode, response.StatusCode); + Assert.Contains(expectedMessage, actualMessage); + } + + [Fact(Skip = "Collection support released in host version 4.16+")] + public async Task BlobInput_BlobClientCollection_Succeeds() + { + string expectedMessage = "testFile1, testFile2, testFile3"; + HttpStatusCode expectedStatusCode = HttpStatusCode.OK; + + //Cleanup + await StorageHelpers.ClearBlobContainers(); + + //Setup + await StorageHelpers.UploadFileToContainer(Constants.Blob.InputBindingContainer, "testFile1"); + await StorageHelpers.UploadFileToContainer(Constants.Blob.InputBindingContainer, "testFile2"); + await StorageHelpers.UploadFileToContainer(Constants.Blob.InputBindingContainer, "testFile3"); + + //Trigger + HttpResponseMessage response = await HttpHelpers.InvokeHttpTrigger("BlobInputCollectionTest"); + string actualMessage = await response.Content.ReadAsStringAsync(); + + //Verify + Assert.Equal(expectedStatusCode, response.StatusCode); + Assert.Contains(expectedMessage, actualMessage); + } + + [Fact(Skip = "Collection support released in host version 4.16+")] + public async Task BlobInput_StringCollection_Succeeds() + { + string expectedMessage = "ABC, DEF, GHI"; + HttpStatusCode expectedStatusCode = HttpStatusCode.OK; + + //Cleanup + await StorageHelpers.ClearBlobContainers(); + + //Setup + await StorageHelpers.UploadFileToContainer(Constants.Blob.InputBindingContainer, "testFile1", "ABC"); + await StorageHelpers.UploadFileToContainer(Constants.Blob.InputBindingContainer, "testFile2", "DEF"); + await StorageHelpers.UploadFileToContainer(Constants.Blob.InputBindingContainer, "testFile3", "GHI"); + + //Trigger + HttpResponseMessage response = await HttpHelpers.InvokeHttpTrigger("BlobInputCollectionTest"); + string actualMessage = await response.Content.ReadAsStringAsync(); + + //Verify + Assert.Equal(expectedStatusCode, response.StatusCode); + Assert.Contains(expectedMessage, actualMessage); + } + + [Fact(Skip = "Collection support released in host version 4.16+")] + public async Task BlobInput_PocoCollection_Succeeds() + { + string book1 = $@"{{ ""id"": ""1"", ""name"": ""To Kill a Mockingbird""}}"; + string book2 = $@"{{ ""id"": ""2"", ""name"": ""Of Mice and Men""}}"; + string book3 = $@"{{ ""id"": ""3"", ""name"": ""The Wind in the Willows""}}"; + + string expectedMessage = "To Kill a Mockingbird, Of Mice and Men, The Wind in the Willows"; + HttpStatusCode expectedStatusCode = HttpStatusCode.OK; + + + //Cleanup + await StorageHelpers.ClearBlobContainers(); + + //Setup + await StorageHelpers.UploadFileToContainer(Constants.Blob.InputBindingContainer, "book1", book1); + await StorageHelpers.UploadFileToContainer(Constants.Blob.InputBindingContainer, "book2", book2); + await StorageHelpers.UploadFileToContainer(Constants.Blob.InputBindingContainer, "book3", book3); + + //Trigger + HttpResponseMessage response = await HttpHelpers.InvokeHttpTrigger("BlobInputPocoArrayTest"); + string actualMessage = await response.Content.ReadAsStringAsync(); + + //Verify + Assert.Equal(expectedStatusCode, response.StatusCode); + Assert.Contains(expectedMessage, actualMessage); + } + + public void Dispose() + { + _disposeLog?.Dispose(); + } + } +} diff --git a/test/E2ETests/E2ETests/StorageEndToEndTests.cs b/test/E2ETests/E2ETests/Storage/QueueEndToEndTests.cs similarity index 76% rename from test/E2ETests/E2ETests/StorageEndToEndTests.cs rename to test/E2ETests/E2ETests/Storage/QueueEndToEndTests.cs index 8271aad32..ed81d8cfa 100644 --- a/test/E2ETests/E2ETests/StorageEndToEndTests.cs +++ b/test/E2ETests/E2ETests/Storage/QueueEndToEndTests.cs @@ -9,14 +9,14 @@ using System.Threading.Tasks; using Xunit; -namespace Microsoft.Azure.Functions.Tests.E2ETests +namespace Microsoft.Azure.Functions.Tests.E2ETests.Storage { [Collection(Constants.FunctionAppCollectionName)] - public class StorageEndToEndTests + public class QueueEndToEndTests { private FunctionAppFixture _fixture; - public StorageEndToEndTests(FunctionAppFixture fixture) + public QueueEndToEndTests(FunctionAppFixture fixture) { _fixture = fixture; } @@ -29,7 +29,7 @@ public async Task QueueTriggerAndOutput_Succeeds() await StorageHelpers.ClearQueue(Constants.Queue.OutputBindingName); await StorageHelpers.ClearQueue(Constants.Queue.InputBindingName); - //Set up and trigger + //Set up and trigger await StorageHelpers.CreateQueue(Constants.Queue.OutputBindingName); await StorageHelpers.InsertIntoQueue(Constants.Queue.InputBindingName, expectedQueueMessage); @@ -46,7 +46,7 @@ public async Task QueueTriggerAndArrayOutput_Succeeds() await StorageHelpers.ClearQueue(Constants.Queue.InputArrayBindingName); await StorageHelpers.ClearQueue(Constants.Queue.OutputArrayBindingName); - //Set up and trigger + //Set up and trigger await StorageHelpers.CreateQueue(Constants.Queue.OutputArrayBindingName); await StorageHelpers.InsertIntoQueue(Constants.Queue.InputArrayBindingName, expectedQueueMessage); @@ -72,7 +72,7 @@ public async Task QueueTriggerAndListOutput_Succeeds() await StorageHelpers.ClearQueue(Constants.Queue.InputListBindingName); await StorageHelpers.ClearQueue(Constants.Queue.OutputListBindingName); - //Set up and trigger + //Set up and trigger await StorageHelpers.CreateQueue(Constants.Queue.OutputListBindingName); await StorageHelpers.InsertIntoQueue(Constants.Queue.InputListBindingName, expectedQueueMessage); @@ -98,7 +98,7 @@ public async Task QueueTriggerAndBindingDataOutput_Succeeds() await StorageHelpers.ClearQueue(Constants.Queue.InputBindingDataName); await StorageHelpers.ClearQueue(Constants.Queue.OutputBindingDataName); - //Set up and trigger + //Set up and trigger await StorageHelpers.CreateQueue(Constants.Queue.OutputBindingDataName); await StorageHelpers.InsertIntoQueue(Constants.Queue.InputBindingDataName, expectedQueueMessage); @@ -122,7 +122,7 @@ public async Task QueueTrigger_BindToTriggerMetadata_Succeeds() await StorageHelpers.ClearQueue(Constants.Queue.OutputBindingNameMetadata); await StorageHelpers.ClearQueue(Constants.Queue.InputBindingNameMetadata); - //Set up and trigger + //Set up and trigger await StorageHelpers.CreateQueue(Constants.Queue.OutputBindingNameMetadata); string expectedQueueMessage = await StorageHelpers.InsertIntoQueue(Constants.Queue.InputBindingNameMetadata, inputQueueMessage); @@ -139,7 +139,7 @@ public async Task QueueTrigger_QueueOutput_Poco_Succeeds() await StorageHelpers.ClearQueue(Constants.Queue.OutputBindingNamePOCO); await StorageHelpers.ClearQueue(Constants.Queue.InputBindingNamePOCO); - //Set up and trigger + //Set up and trigger await StorageHelpers.CreateQueue(Constants.Queue.OutputBindingNamePOCO); string json = JsonSerializer.Serialize(new { id = expectedQueueMessage }); @@ -166,60 +166,5 @@ 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 BlobTriggerToBlob_Succeeds() - { - string fileName = Guid.NewGuid().ToString(); - - //cleanup - await StorageHelpers.ClearBlobContainers(); - - //Setup - await StorageHelpers.UploadFileToContainer(Constants.Blob.InputBindingContainer, fileName); - - //Trigger - await StorageHelpers.UploadFileToContainer(Constants.Blob.TriggerInputBindingContainer, fileName); - - //Verify - string result = await StorageHelpers.DownloadFileFromContainer(Constants.Blob.OutputBindingContainer, fileName); - - Assert.Equal("Hello World", result); - } - - [Fact] - public async Task BlobTriggerPoco_Succeeds() - { - string fileName = Guid.NewGuid().ToString(); - - //cleanup - await StorageHelpers.ClearBlobContainers(); - - //Trigger - var json = JsonSerializer.Serialize(new { text = "Hello World" }); - await StorageHelpers.UploadFileToContainer(Constants.Blob.TriggerPocoContainer, fileName, json); - - //Verify - string result = await StorageHelpers.DownloadFileFromContainer(Constants.Blob.OutputPocoContainer, fileName); - - Assert.Equal(json, result); - } - - [Fact] - public async Task BlobTriggerString_Succeeds() - { - string fileName = Guid.NewGuid().ToString(); - - //cleanup - await StorageHelpers.ClearBlobContainers(); - - //Trigger - await StorageHelpers.UploadFileToContainer(Constants.Blob.TriggerStringContainer, fileName); - - //Verify - string result = await StorageHelpers.DownloadFileFromContainer(Constants.Blob.OutputStringContainer, fileName); - - Assert.Equal("Hello World", result); - } } } From 3f8465116da459935f281fab39d2853328a3296b Mon Sep 17 00:00:00 2001 From: Lilian Kasem Date: Wed, 22 Feb 2023 16:40:38 -0800 Subject: [PATCH 02/13] Add analyzer for SupportsDeferredBindingAttribute (#1367) --- DotNetWorker.sln | 2 +- sdk/Sdk.Analyzers/Constants.cs | 5 +- .../DeferredBindingAttributeNotSupported.cs | 58 ++++++++ sdk/Sdk.Analyzers/DiagnosticDescriptors.cs | 11 +- .../AttributeDataExtensions.cs | 19 ++- .../MethodSymbolExtensions.cs | 0 .../ParameterSymbolExtensions.cs | 0 .../{ => Extensions}/TypeSymbolExtensions.cs | 0 .../WebJobsAttributesNotSupported.cs | 4 +- sdk/release_notes.md | 2 + .../AsyncVoidAnalyzerTests.cs | 12 +- ...ferredBindingAttributeNotSupportedTests.cs | 134 ++++++++++++++++++ .../Sdk.Analyzers.Tests.csproj | 6 +- .../WebJobsAttributesNotSupportedTests.cs | 14 +- 14 files changed, 241 insertions(+), 26 deletions(-) create mode 100644 sdk/Sdk.Analyzers/DeferredBindingAttributeNotSupported.cs rename sdk/Sdk.Analyzers/{ => Extensions}/AttributeDataExtensions.cs (64%) rename sdk/Sdk.Analyzers/{ => Extensions}/MethodSymbolExtensions.cs (100%) rename sdk/Sdk.Analyzers/{ => Extensions}/ParameterSymbolExtensions.cs (100%) rename sdk/Sdk.Analyzers/{ => Extensions}/TypeSymbolExtensions.cs (100%) rename test/Sdk.Analyzers.Tests/{Sdk.Analyzers.Tests => }/AsyncVoidAnalyzerTests.cs (96%) create mode 100644 test/Sdk.Analyzers.Tests/DeferredBindingAttributeNotSupportedTests.cs rename test/Sdk.Analyzers.Tests/{Sdk.Analyzers.Tests => }/Sdk.Analyzers.Tests.csproj (78%) rename test/Sdk.Analyzers.Tests/{Sdk.Analyzers.Tests => }/WebJobsAttributesNotSupportedTests.cs (95%) diff --git a/DotNetWorker.sln b/DotNetWorker.sln index d57e0c7fe..397438389 100644 --- a/DotNetWorker.sln +++ b/DotNetWorker.sln @@ -70,7 +70,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Worker.Extensions.Kafka", " EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sdk.Analyzers", "sdk\Sdk.Analyzers\Sdk.Analyzers.csproj", "{055D602D-D2B3-416B-AC59-1972D832032A}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sdk.Analyzers.Tests", "test\Sdk.Analyzers.Tests\Sdk.Analyzers.Tests\Sdk.Analyzers.Tests.csproj", "{A75EA1E1-2801-460C-87C0-DE6A82D30851}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sdk.Analyzers.Tests", "test\Sdk.Analyzers.Tests\Sdk.Analyzers.Tests.csproj", "{A75EA1E1-2801-460C-87C0-DE6A82D30851}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{083592CA-7DAB-44CE-8979-44FAFA46AEC3}" EndProject diff --git a/sdk/Sdk.Analyzers/Constants.cs b/sdk/Sdk.Analyzers/Constants.cs index b9ecc9db6..b526a6363 100644 --- a/sdk/Sdk.Analyzers/Constants.cs +++ b/sdk/Sdk.Analyzers/Constants.cs @@ -9,7 +9,10 @@ internal static class Types { public const string WorkerFunctionAttribute = "Microsoft.Azure.Functions.Worker.FunctionAttribute"; public const string WebJobsBindingAttribute = "Microsoft.Azure.WebJobs.Description.BindingAttribute"; - + public const string SupportsDeferredBindingAttribute = "Microsoft.Azure.Functions.Worker.Extensions.Abstractions.SupportsDeferredBindingAttribute"; + public const string InputBindingAttribute = "Microsoft.Azure.Functions.Worker.Extensions.Abstractions.InputBindingAttribute"; + public const string TriggerBindingAttribute = "Microsoft.Azure.Functions.Worker.Extensions.Abstractions.TriggerBindingAttribute"; + // System types internal const string TaskType = "System.Threading.Tasks.Task"; } diff --git a/sdk/Sdk.Analyzers/DeferredBindingAttributeNotSupported.cs b/sdk/Sdk.Analyzers/DeferredBindingAttributeNotSupported.cs new file mode 100644 index 000000000..459e1b2ce --- /dev/null +++ b/sdk/Sdk.Analyzers/DeferredBindingAttributeNotSupported.cs @@ -0,0 +1,58 @@ +// 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.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Microsoft.Azure.Functions.Worker.Sdk.Analyzers +{ + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public class DeferredBindingAttributeNotSupported : DiagnosticAnalyzer + { + public override ImmutableArray SupportedDiagnostics { get { return ImmutableArray.Create(DiagnosticDescriptors.DeferredBindingAttributeNotSupported); } } + + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze); + context.RegisterSymbolAction(AnalyzeMethod, SymbolKind.NamedType); + } + + private static void AnalyzeMethod(SymbolAnalysisContext symbolAnalysisContext) + { + var symbol = (INamedTypeSymbol)symbolAnalysisContext.Symbol; + + var attributes = symbol.GetAttributes(); + + if (attributes.IsEmpty) + { + return; + } + + foreach (var attribute in attributes) + { + if (attribute.IsSupportsDeferredBindingAttribute() && !IsInputOrTriggerBinding(symbol)) + { + var location = Location.Create(attribute.ApplicationSyntaxReference.SyntaxTree, attribute.ApplicationSyntaxReference.Span); + var diagnostic = Diagnostic.Create(DiagnosticDescriptors.DeferredBindingAttributeNotSupported, location, attribute.AttributeClass.Name); + symbolAnalysisContext.ReportDiagnostic(diagnostic); + } + } + } + + private static bool IsInputOrTriggerBinding(INamedTypeSymbol symbol) + { + var baseType = symbol.BaseType?.ToDisplayString(); + + if (string.Equals(baseType,Constants.Types.InputBindingAttribute, StringComparison.Ordinal) + || string.Equals(baseType,Constants.Types.TriggerBindingAttribute, StringComparison.Ordinal)) + { + return true; + } + + return false; + } + } +} diff --git a/sdk/Sdk.Analyzers/DiagnosticDescriptors.cs b/sdk/Sdk.Analyzers/DiagnosticDescriptors.cs index 613dfc39c..2a094e367 100644 --- a/sdk/Sdk.Analyzers/DiagnosticDescriptors.cs +++ b/sdk/Sdk.Analyzers/DiagnosticDescriptors.cs @@ -1,9 +1,6 @@ // 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.Text; using Microsoft.CodeAnalysis; namespace Microsoft.Azure.Functions.Worker.Sdk.Analyzers @@ -16,13 +13,17 @@ private static DiagnosticDescriptor Create(string id, string title,string messag return new DiagnosticDescriptor(id, title, messageFormat, category, severity, isEnabledByDefault: true, helpLinkUri: helpLink); } - public static DiagnosticDescriptor WebJobsAttributesAreNotSuppoted { get; } + public static DiagnosticDescriptor WebJobsAttributesAreNotSupported { get; } = Create(id: "AZFW0001", title: "Invalid binding attributes", messageFormat: "The attribute '{0}' is a WebJobs attribute and not supported in the .NET Worker (Isolated Process).", category: Constants.DiagnosticsCategories.Usage, severity: DiagnosticSeverity.Error); - + public static DiagnosticDescriptor AsyncVoidReturnType { get; } = Create(id: "AZFW0002", title: "Avoid async void methods", messageFormat: "Do not use void as the return type for async methods. Use Task instead.", category: Constants.DiagnosticsCategories.Usage, severity: DiagnosticSeverity.Error); + public static DiagnosticDescriptor DeferredBindingAttributeNotSupported{ get; } + = Create(id: "AZFW0003", title: "Invalid class attribute", messageFormat: "The attribute '{0}' can only be used on trigger and input binding attributes.", + category: Constants.DiagnosticsCategories.Usage, severity: DiagnosticSeverity.Error); + } } diff --git a/sdk/Sdk.Analyzers/AttributeDataExtensions.cs b/sdk/Sdk.Analyzers/Extensions/AttributeDataExtensions.cs similarity index 64% rename from sdk/Sdk.Analyzers/AttributeDataExtensions.cs rename to sdk/Sdk.Analyzers/Extensions/AttributeDataExtensions.cs index ebec79ad5..26663c774 100644 --- a/sdk/Sdk.Analyzers/AttributeDataExtensions.cs +++ b/sdk/Sdk.Analyzers/Extensions/AttributeDataExtensions.cs @@ -22,7 +22,7 @@ public static bool IsWebJobAttribute(this AttributeData attributeData) { return false; } - + foreach (var attribute in attributeAttributes) { if (string.Equals(attribute.AttributeClass?.ToDisplayString(), Constants.Types.WebJobsBindingAttribute, @@ -34,4 +34,21 @@ public static bool IsWebJobAttribute(this AttributeData attributeData) return false; } + + /// + /// Checks if an attribute is the SupportsDeferredBinding attribute. + /// + /// The attribute to check. + /// A boolean value indicating whether the attribute is a SupportsDeferredBinding attribute. + public static bool IsSupportsDeferredBindingAttribute(this AttributeData attributeData) + { + if (string.Equals(attributeData.AttributeClass?.ToDisplayString(), + Constants.Types.SupportsDeferredBindingAttribute, + StringComparison.Ordinal)) + { + return true; + } + + return false; + } } diff --git a/sdk/Sdk.Analyzers/MethodSymbolExtensions.cs b/sdk/Sdk.Analyzers/Extensions/MethodSymbolExtensions.cs similarity index 100% rename from sdk/Sdk.Analyzers/MethodSymbolExtensions.cs rename to sdk/Sdk.Analyzers/Extensions/MethodSymbolExtensions.cs diff --git a/sdk/Sdk.Analyzers/ParameterSymbolExtensions.cs b/sdk/Sdk.Analyzers/Extensions/ParameterSymbolExtensions.cs similarity index 100% rename from sdk/Sdk.Analyzers/ParameterSymbolExtensions.cs rename to sdk/Sdk.Analyzers/Extensions/ParameterSymbolExtensions.cs diff --git a/sdk/Sdk.Analyzers/TypeSymbolExtensions.cs b/sdk/Sdk.Analyzers/Extensions/TypeSymbolExtensions.cs similarity index 100% rename from sdk/Sdk.Analyzers/TypeSymbolExtensions.cs rename to sdk/Sdk.Analyzers/Extensions/TypeSymbolExtensions.cs diff --git a/sdk/Sdk.Analyzers/WebJobsAttributesNotSupported.cs b/sdk/Sdk.Analyzers/WebJobsAttributesNotSupported.cs index 5b74f5706..0c1485ffa 100644 --- a/sdk/Sdk.Analyzers/WebJobsAttributesNotSupported.cs +++ b/sdk/Sdk.Analyzers/WebJobsAttributesNotSupported.cs @@ -10,7 +10,7 @@ namespace Microsoft.Azure.Functions.Worker.Sdk.Analyzers [DiagnosticAnalyzer(LanguageNames.CSharp)] public class WebJobsAttributesNotSupported : DiagnosticAnalyzer { - public override ImmutableArray SupportedDiagnostics { get { return ImmutableArray.Create(DiagnosticDescriptors.WebJobsAttributesAreNotSuppoted); } } + public override ImmutableArray SupportedDiagnostics { get { return ImmutableArray.Create(DiagnosticDescriptors.WebJobsAttributesAreNotSupported); } } public override void Initialize(AnalysisContext context) { @@ -34,7 +34,7 @@ public override void Initialize(AnalysisContext context) foreach (var attribute in webjobsAttributes) { var location = Location.Create(attribute.ApplicationSyntaxReference.SyntaxTree, attribute.ApplicationSyntaxReference.Span); - c.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.WebJobsAttributesAreNotSuppoted, location, attribute.AttributeClass.Name)); + c.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.WebJobsAttributesAreNotSupported, location, attribute.AttributeClass.Name)); } } }, SymbolKind.Method); diff --git a/sdk/release_notes.md b/sdk/release_notes.md index cb7322a01..a7e030f0c 100644 --- a/sdk/release_notes.md +++ b/sdk/release_notes.md @@ -2,3 +2,5 @@ + +- Add analyzer for SupportsDeferredBindingAttribute #1367 diff --git a/test/Sdk.Analyzers.Tests/Sdk.Analyzers.Tests/AsyncVoidAnalyzerTests.cs b/test/Sdk.Analyzers.Tests/AsyncVoidAnalyzerTests.cs similarity index 96% rename from test/Sdk.Analyzers.Tests/Sdk.Analyzers.Tests/AsyncVoidAnalyzerTests.cs rename to test/Sdk.Analyzers.Tests/AsyncVoidAnalyzerTests.cs index b7b2393a7..0e3a8ae7a 100644 --- a/test/Sdk.Analyzers.Tests/Sdk.Analyzers.Tests/AsyncVoidAnalyzerTests.cs +++ b/test/Sdk.Analyzers.Tests/AsyncVoidAnalyzerTests.cs @@ -1,5 +1,5 @@ using Xunit; -using AnalizerTest = Microsoft.CodeAnalysis.CSharp.Testing.CSharpAnalyzerTest; +using AnalyzerTest = Microsoft.CodeAnalysis.CSharp.Testing.CSharpAnalyzerTest; using AnalyzerVerifier = Microsoft.CodeAnalysis.CSharp.Testing.XUnit.AnalyzerVerifier; using CodeFixTest = Microsoft.CodeAnalysis.CSharp.Testing.CSharpCodeFixTest; using CodeFixVerifier = Microsoft.CodeAnalysis.CSharp.Testing.CSharpCodeFixVerifier; @@ -35,7 +35,7 @@ public static async void Run([QueueTrigger(""myqueue-items"")] string myQueueIte } } }"; - var test = new AnalizerTest + var test = new AnalyzerTest { ReferenceAssemblies = LoadRequiredDependencyAssemblies(), TestCode = inputCode @@ -72,7 +72,7 @@ public static async Task Run([QueueTrigger(""myqueue-items"")] string myQueueIte } } }"; - var test = new AnalizerTest + var test = new AnalyzerTest { ReferenceAssemblies = LoadRequiredDependencyAssemblies(), TestCode = inputCode @@ -80,7 +80,7 @@ public static async Task Run([QueueTrigger(""myqueue-items"")] string myQueueIte await test.RunAsync(); } - + [Fact] public async Task AnalyzerDoesNotReportForNonAsyncCode() { @@ -100,7 +100,7 @@ public static void Run([QueueTrigger(""myqueue-items"")] string myQueueItem, Fun } } }"; - var test = new AnalizerTest + var test = new AnalyzerTest { ReferenceAssemblies = LoadRequiredDependencyAssemblies(), TestCode = inputCode @@ -165,7 +165,7 @@ public static async Task Run([QueueTrigger(""myqueue-items"")] string myQueueIte test.ExpectedDiagnostics.AddRange(new[] { expectedDiagnosticResult }); await test.RunAsync(CancellationToken.None); } - + private static ReferenceAssemblies LoadRequiredDependencyAssemblies() { var referenceAssemblies = ReferenceAssemblies.Net.Net50.WithPackages(ImmutableArray.Create( diff --git a/test/Sdk.Analyzers.Tests/DeferredBindingAttributeNotSupportedTests.cs b/test/Sdk.Analyzers.Tests/DeferredBindingAttributeNotSupportedTests.cs new file mode 100644 index 000000000..dc4908f3e --- /dev/null +++ b/test/Sdk.Analyzers.Tests/DeferredBindingAttributeNotSupportedTests.cs @@ -0,0 +1,134 @@ +using Xunit; +using AnalyzerTest = Microsoft.CodeAnalysis.CSharp.Testing.CSharpAnalyzerTest; +using Verify = Microsoft.CodeAnalysis.CSharp.Testing.XUnit.AnalyzerVerifier; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.Testing; +using System.Collections.Immutable; + +namespace Sdk.Analyzers.Tests +{ + public class DeferredBindingAttributeNotSupportedTests + { + [Fact] + public async Task TriggerBindingClass_SupportsDeferredBindingAttribute_Diagnostics_NotExpected() + { + string testCode = @" + using Microsoft.Azure.Functions.Worker.Extensions.Abstractions; + + namespace TestBindings + { + [SupportsDeferredBinding] + public sealed class BlobTriggerAttribute : TriggerBindingAttribute + { + } + }"; + + var test = new AnalyzerTest + { + ReferenceAssemblies = ReferenceAssemblies.Net.Net50.WithPackages(ImmutableArray.Create( + new PackageIdentity("Microsoft.Azure.Functions.Worker", "1.12.1-preview1"), + new PackageIdentity("Microsoft.Azure.Functions.Worker.Sdk", "1.9.0-preview1"), + new PackageIdentity("Microsoft.Azure.Functions.Worker.Extensions.Abstractions", "1.2.0-preview1"))), + + TestCode = testCode + }; + + // test.ExpectedDiagnostics is an empty collection. + + await test.RunAsync(); + } + + [Fact] + public async Task InputBindingClass_SupportsDeferredBindingAttribute_Diagnostics_NotExpected() + { + string testCode = @" + using Microsoft.Azure.Functions.Worker.Extensions.Abstractions; + + namespace TestBindings + { + [SupportsDeferredBinding] + public sealed class BlobInputAttribute : InputBindingAttribute + { + } + }"; + + var test = new AnalyzerTest + { + ReferenceAssemblies = ReferenceAssemblies.Net.Net50.WithPackages(ImmutableArray.Create( + new PackageIdentity("Microsoft.Azure.Functions.Worker", "1.12.1-preview1"), + new PackageIdentity("Microsoft.Azure.Functions.Worker.Sdk", "1.9.0-preview1"), + new PackageIdentity("Microsoft.Azure.Functions.Worker.Extensions.Abstractions", "1.2.0-preview1"))), + + TestCode = testCode + }; + + // test.ExpectedDiagnostics is an empty collection. + + await test.RunAsync(); + } + + [Fact] + public async Task OutputBindingClass_SupportsDeferredBindingAttribute_Diagnostic_Expected() + { + string testCode = @" + using Microsoft.Azure.Functions.Worker.Extensions.Abstractions; + + namespace TestBindings + { + [SupportsDeferredBinding] + public sealed class BlobOutputAttribute : OutputBindingAttribute + { + } + }"; + + var test = new AnalyzerTest + { + ReferenceAssemblies = ReferenceAssemblies.Net.Net50.WithPackages(ImmutableArray.Create( + new PackageIdentity("Microsoft.Azure.Functions.Worker", "1.12.1-preview1"), + new PackageIdentity("Microsoft.Azure.Functions.Worker.Sdk", "1.9.0-preview1"), + new PackageIdentity("Microsoft.Azure.Functions.Worker.Extensions.Abstractions", "1.2.0-preview1"))), + + TestCode = testCode + }; + + test.ExpectedDiagnostics.Add(Verify.Diagnostic() + .WithSeverity(Microsoft.CodeAnalysis.DiagnosticSeverity.Error) + .WithSpan(6, 22, 6, 45) + .WithArguments("SupportsDeferredBindingAttribute")); + + await test.RunAsync(); + } + + [Fact] + public async Task ClassWithoutBase_SupportsDeferredBindingAttribute_Diagnostic_Expected() + { + string testCode = @" + using Microsoft.Azure.Functions.Worker.Extensions.Abstractions; + + namespace TestBindings + { + [SupportsDeferredBinding] + public sealed class JustAnotherClass + { + } + }"; + + var test = new AnalyzerTest + { + ReferenceAssemblies = ReferenceAssemblies.Net.Net50.WithPackages(ImmutableArray.Create( + new PackageIdentity("Microsoft.Azure.Functions.Worker", "1.12.1-preview1"), + new PackageIdentity("Microsoft.Azure.Functions.Worker.Sdk", "1.9.0-preview1"), + new PackageIdentity("Microsoft.Azure.Functions.Worker.Extensions.Abstractions", "1.2.0-preview1"))), + + TestCode = testCode + }; + + test.ExpectedDiagnostics.Add(Verify.Diagnostic() + .WithSeverity(Microsoft.CodeAnalysis.DiagnosticSeverity.Error) + .WithSpan(6, 22, 6, 45) + .WithArguments("SupportsDeferredBindingAttribute")); + + await test.RunAsync(); + } + } +} diff --git a/test/Sdk.Analyzers.Tests/Sdk.Analyzers.Tests/Sdk.Analyzers.Tests.csproj b/test/Sdk.Analyzers.Tests/Sdk.Analyzers.Tests.csproj similarity index 78% rename from test/Sdk.Analyzers.Tests/Sdk.Analyzers.Tests/Sdk.Analyzers.Tests.csproj rename to test/Sdk.Analyzers.Tests/Sdk.Analyzers.Tests.csproj index a0f2feec2..b48449823 100644 --- a/test/Sdk.Analyzers.Tests/Sdk.Analyzers.Tests/Sdk.Analyzers.Tests.csproj +++ b/test/Sdk.Analyzers.Tests/Sdk.Analyzers.Tests.csproj @@ -22,9 +22,9 @@ - - - + + + diff --git a/test/Sdk.Analyzers.Tests/Sdk.Analyzers.Tests/WebJobsAttributesNotSupportedTests.cs b/test/Sdk.Analyzers.Tests/WebJobsAttributesNotSupportedTests.cs similarity index 95% rename from test/Sdk.Analyzers.Tests/Sdk.Analyzers.Tests/WebJobsAttributesNotSupportedTests.cs rename to test/Sdk.Analyzers.Tests/WebJobsAttributesNotSupportedTests.cs index 99d83d9cb..040a29c91 100644 --- a/test/Sdk.Analyzers.Tests/Sdk.Analyzers.Tests/WebJobsAttributesNotSupportedTests.cs +++ b/test/Sdk.Analyzers.Tests/WebJobsAttributesNotSupportedTests.cs @@ -1,5 +1,5 @@ using Xunit; -using AnalizerTest = Microsoft.CodeAnalysis.CSharp.Testing.CSharpAnalyzerTest; +using AnalyzerTest = Microsoft.CodeAnalysis.CSharp.Testing.CSharpAnalyzerTest; using Verify = Microsoft.CodeAnalysis.CSharp.Testing.XUnit.AnalyzerVerifier; using System.Threading.Tasks; using Microsoft.CodeAnalysis.Testing; @@ -28,7 +28,7 @@ public static void Run([HttpTrigger(AuthorizationLevel.Anonymous, ""get"")] Http } } }"; - var test = new AnalizerTest + var test = new AnalyzerTest { // TODO: This needs to pull from a local source ReferenceAssemblies = ReferenceAssemblies.Net.Net50.WithPackages(ImmutableArray.Create( @@ -43,7 +43,7 @@ public static void Run([HttpTrigger(AuthorizationLevel.Anonymous, ""get"")] Http test.ExpectedDiagnostics.Add(Verify.Diagnostic().WithSeverity(Microsoft.CodeAnalysis.DiagnosticSeverity.Error) .WithSpan(12, 105, 12, 122).WithArguments("TimerTriggerAttribute")); - + await test.RunAsync(); } @@ -67,7 +67,7 @@ public static string Run([TimerTrigger(""0 */1 * * * *"")] MyInfo myTimer) public record MyInfo(bool IsPastDue); }"; - var test = new AnalizerTest + var test = new AnalyzerTest { ReferenceAssemblies = ReferenceAssemblies.Net.Net50.WithPackages(ImmutableArray.Create( new PackageIdentity("Microsoft.Azure.Functions.Worker", "1.10.0"), @@ -98,14 +98,14 @@ public static class Function1 { [Function(""Function1"")] [return: Microsoft.Azure.WebJobs.Queue(""dest-q"")] - public static string Run([TimerTrigger(""0 */1 * * * *"")] object myTimer, + public static string Run([TimerTrigger(""0 */1 * * * *"")] object myTimer, [Blob(""samples-workitems/{queueTrigger}"", FileAccess.Read)] Stream myBlob) { return ""Azure""; } } }"; - var test = new AnalizerTest + var test = new AnalyzerTest { ReferenceAssemblies = ReferenceAssemblies.Net.Net50.WithPackages(ImmutableArray.Create( new PackageIdentity("Microsoft.Azure.Functions.Worker", "1.10.0"), @@ -142,7 +142,7 @@ public void Run([TimerTrigger(""0 */5 * * * *"")] object myTimer) } } }"; - var test = new AnalizerTest + var test = new AnalyzerTest { // TODO: This needs to pull from a local source ReferenceAssemblies = ReferenceAssemblies.Net.Net50.WithPackages(ImmutableArray.Create( From 6b1189a61cdd8a9ba0730ba070dc972531a1cd55 Mon Sep 17 00:00:00 2001 From: Lilian Kasem Date: Thu, 23 Feb 2023 13:47:23 -0800 Subject: [PATCH 03/13] Update SupportsDeferredBinding diagnostic code & update docs (#1377) --- docs/analyzer-rules/AZFW0009.md | 26 ++++++++++++++++++++++ sdk/Sdk.Analyzers/DiagnosticDescriptors.cs | 2 +- 2 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 docs/analyzer-rules/AZFW0009.md diff --git a/docs/analyzer-rules/AZFW0009.md b/docs/analyzer-rules/AZFW0009.md new file mode 100644 index 000000000..4faac9a1b --- /dev/null +++ b/docs/analyzer-rules/AZFW0009.md @@ -0,0 +1,26 @@ +# AZFW0009: Invalid use of SupportsDeferredBinding attribute + +| | Value | +|-|-| +| **Rule ID** |AZFW0009| +| **Category** |[Usage]| +| **Severity** |Error| + +## Cause + +This rule is triggered when the `SupportsDeferredBinding` attribute is used on any class other +than an input (`InputBindingAttribute`) or trigger (`TriggerBindingAttribute`) binding based class. + +## Rule description + +The `SupportsDeferredBinding` attribute is used to determine if a binding supports deferred binding. +Currently, this feature is only supported for input and trigger bindings. Output bindings are not supported +and this attribute should not be used on any other class type. + +## How to fix violations + +Remove the use of the `SupportsDeferredBinding` attribute from your class. + +## When to suppress warnings + +This rule should not be suppressed because this error may prevent your Azure Functions from running. diff --git a/sdk/Sdk.Analyzers/DiagnosticDescriptors.cs b/sdk/Sdk.Analyzers/DiagnosticDescriptors.cs index 2a094e367..70f156f7c 100644 --- a/sdk/Sdk.Analyzers/DiagnosticDescriptors.cs +++ b/sdk/Sdk.Analyzers/DiagnosticDescriptors.cs @@ -22,7 +22,7 @@ private static DiagnosticDescriptor Create(string id, string title,string messag category: Constants.DiagnosticsCategories.Usage, severity: DiagnosticSeverity.Error); public static DiagnosticDescriptor DeferredBindingAttributeNotSupported{ get; } - = Create(id: "AZFW0003", title: "Invalid class attribute", messageFormat: "The attribute '{0}' can only be used on trigger and input binding attributes.", + = Create(id: "AZFW0009", title: "Invalid class attribute", messageFormat: "The attribute '{0}' can only be used on trigger and input binding attributes.", category: Constants.DiagnosticsCategories.Usage, severity: DiagnosticSeverity.Error); } From d16ab2d3748713abadee688b4267a857a3edb97b Mon Sep 17 00:00:00 2001 From: Lilian Kasem Date: Thu, 23 Feb 2023 13:49:52 -0800 Subject: [PATCH 04/13] Revert "Remove types, tests & logic related to the SDK-binding feature (#1374)" This reverts commit eac5b19a7a3e7adc437a903aa3d15177a123af72. --- .../src/SupportsDeferredBindingAttribute.cs | 16 ++ .../src/BlobInputAttribute.cs | 1 + .../src/BlobStorageConverter.cs | 236 ++++++++++++++++++ .../src/BlobTriggerAttribute.cs | 1 + .../src/StorageExtensionStartup.cs | 5 + .../CollectionModelBindingData.cs | 16 ++ src/DotNetWorker.Core/ModelBindingData.cs | 33 +++ .../Features/GrpcFunctionBindingsFeature.cs | 2 + .../GrpcCollectionModelBindingData.cs | 19 ++ src/DotNetWorker.Grpc/GrpcModelBindingData.cs | 26 ++ .../GrpcFunctionBindingsFeatureTests.cs | 151 +++++++++++ .../FunctionMetadataGeneratorTests.cs | 67 ++++- test/SdkE2ETests/Contents/functions.metadata | 10 +- 13 files changed, 576 insertions(+), 7 deletions(-) create mode 100644 extensions/Worker.Extensions.Abstractions/src/SupportsDeferredBindingAttribute.cs create mode 100644 extensions/Worker.Extensions.Storage.Blobs/src/BlobStorageConverter.cs create mode 100644 src/DotNetWorker.Core/CollectionModelBindingData.cs create mode 100644 src/DotNetWorker.Core/ModelBindingData.cs create mode 100644 src/DotNetWorker.Grpc/GrpcCollectionModelBindingData.cs create mode 100644 src/DotNetWorker.Grpc/GrpcModelBindingData.cs diff --git a/extensions/Worker.Extensions.Abstractions/src/SupportsDeferredBindingAttribute.cs b/extensions/Worker.Extensions.Abstractions/src/SupportsDeferredBindingAttribute.cs new file mode 100644 index 000000000..b88551c43 --- /dev/null +++ b/extensions/Worker.Extensions.Abstractions/src/SupportsDeferredBindingAttribute.cs @@ -0,0 +1,16 @@ +// 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.Abstractions +{ + /// + /// Specifies if a binding attribute supports deferred binding when generating function metadata. + /// This is to be used on input, output or trigger attributes that support deferred binding. + /// + [AttributeUsage(AttributeTargets.Class)] + public class SupportsDeferredBindingAttribute : Attribute + { + } +} diff --git a/extensions/Worker.Extensions.Storage.Blobs/src/BlobInputAttribute.cs b/extensions/Worker.Extensions.Storage.Blobs/src/BlobInputAttribute.cs index a2856cad2..78ab31abc 100644 --- a/extensions/Worker.Extensions.Storage.Blobs/src/BlobInputAttribute.cs +++ b/extensions/Worker.Extensions.Storage.Blobs/src/BlobInputAttribute.cs @@ -5,6 +5,7 @@ namespace Microsoft.Azure.Functions.Worker { + [SupportsDeferredBinding] public sealed class BlobInputAttribute : InputBindingAttribute { private readonly string _blobPath; diff --git a/extensions/Worker.Extensions.Storage.Blobs/src/BlobStorageConverter.cs b/extensions/Worker.Extensions.Storage.Blobs/src/BlobStorageConverter.cs new file mode 100644 index 000000000..3e0a6c96f --- /dev/null +++ b/extensions/Worker.Extensions.Storage.Blobs/src/BlobStorageConverter.cs @@ -0,0 +1,236 @@ +// 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.IO; +using System.Linq; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Azure.Storage.Blobs; +using Azure.Storage.Blobs.Specialized; +using Microsoft.Azure.Functions.Worker.Converters; +using Microsoft.Azure.Functions.Worker.Core; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Azure.Functions.Worker +{ + /// + /// Converter to bind Blob Storage type parameters. + /// + internal class BlobStorageConverter : IInputConverter + { + private readonly IOptions _workerOptions; + private readonly IOptionsSnapshot _blobOptions; + + private readonly ILogger _logger; + + public BlobStorageConverter(IOptions workerOptions, IOptionsSnapshot blobOptions, ILogger logger) + { + _workerOptions = workerOptions ?? throw new ArgumentNullException(nameof(workerOptions)); + _blobOptions = blobOptions ?? throw new ArgumentNullException(nameof(blobOptions)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async ValueTask ConvertAsync(ConverterContext context) + { + return context?.Source switch + { + ModelBindingData binding => await ConvertFromBindingDataAsync(context, binding), + CollectionModelBindingData binding => await ConvertFromCollectionBindingDataAsync(context, binding), + _ => ConversionResult.Unhandled(), + }; + } + + private async ValueTask ConvertFromBindingDataAsync(ConverterContext context, ModelBindingData modelBindingData) + { + if (!IsBlobExtension(modelBindingData)) + { + return ConversionResult.Unhandled(); + } + + try + { + Dictionary content = GetBindingDataContent(modelBindingData); + var result = await ConvertModelBindingDataAsync(content, context.TargetType, modelBindingData); + + if (result is not null) + { + return ConversionResult.Success(result); + } + } + catch (Exception ex) + { + return ConversionResult.Failed(ex); + } + + return ConversionResult.Unhandled(); + } + + private async ValueTask ConvertFromCollectionBindingDataAsync(ConverterContext context, CollectionModelBindingData collectionModelBindingData) + { + var blobCollection = new List(collectionModelBindingData.ModelBindingDataArray.Length); + Type elementType = context.TargetType.IsArray ? context.TargetType.GetElementType() : context.TargetType.GenericTypeArguments[0]; + + try + { + foreach (ModelBindingData modelBindingData in collectionModelBindingData.ModelBindingDataArray) + { + if (!IsBlobExtension(modelBindingData)) + { + return ConversionResult.Unhandled(); + } + + Dictionary content = GetBindingDataContent(modelBindingData); + var element = await ConvertModelBindingDataAsync(content, elementType, modelBindingData); + + if (element is not null) + { + blobCollection.Add(element); + } + } + + var methodName = context.TargetType.IsArray ? nameof(CloneToArray) : nameof(CloneToList); + var result = ToTargetTypeCollection(blobCollection, methodName, elementType); + + return ConversionResult.Success(result); + } + catch (Exception ex) + { + return ConversionResult.Failed(ex); + } + } + + private bool IsBlobExtension(ModelBindingData bindingData) + { + if (bindingData?.Source is not Constants.BlobExtensionName) + { + _logger.LogTrace("Source '{source}' is not supported by {converter}", bindingData?.Source, nameof(BlobStorageConverter)); + return false; + } + + return true; + } + + private Dictionary GetBindingDataContent(ModelBindingData bindingData) + { + return bindingData?.ContentType switch + { + Constants.JsonContentType => new Dictionary(bindingData?.Content?.ToObjectFromJson>(), StringComparer.OrdinalIgnoreCase), + _ => throw new NotSupportedException($"Unexpected content-type. Currently only {Constants.JsonContentType} is supported.") + }; + } + + private async Task ConvertModelBindingDataAsync(IDictionary content, Type targetType, ModelBindingData bindingData) + { + content.TryGetValue(Constants.Connection, out var connectionName); + content.TryGetValue(Constants.ContainerName, out var containerName); + content.TryGetValue(Constants.BlobName, out var blobName); + + if (string.IsNullOrEmpty(connectionName) || string.IsNullOrEmpty(containerName)) + { + throw new ArgumentNullException("'Connection' and 'ContainerName' cannot be null or empty"); + } + + return await ToTargetTypeAsync(targetType, connectionName, containerName, blobName); + } + + private async Task ToTargetTypeAsync(Type targetType, string connectionName, string containerName, string blobName) => targetType switch + { + Type _ when targetType == typeof(String) => await GetBlobStringAsync(connectionName, containerName, blobName), + Type _ when targetType == typeof(Stream) => await GetBlobStreamAsync(connectionName, containerName, blobName), + Type _ when targetType == typeof(Byte[]) => await GetBlobBinaryDataAsync(connectionName, containerName, blobName), + Type _ when targetType == typeof(BlobBaseClient) => CreateBlobClient(connectionName, containerName, blobName), + Type _ when targetType == typeof(BlobClient) => CreateBlobClient(connectionName, containerName, blobName), + Type _ when targetType == typeof(BlockBlobClient) => CreateBlobClient(connectionName, containerName, blobName), + Type _ when targetType == typeof(PageBlobClient) => CreateBlobClient(connectionName, containerName, blobName), + Type _ when targetType == typeof(AppendBlobClient) => CreateBlobClient(connectionName, containerName, blobName), + Type _ when targetType == typeof(BlobContainerClient) => CreateBlobContainerClient(connectionName, containerName), + _ => await DeserializeToTargetObjectAsync(targetType, connectionName, containerName, blobName) + }; + + private async Task DeserializeToTargetObjectAsync(Type targetType, string connectionName, string containerName, string blobName) + { + var content = await GetBlobStreamAsync(connectionName, containerName, blobName); + return _workerOptions?.Value?.Serializer?.Deserialize(content, targetType, CancellationToken.None); + } + + private object? ToTargetTypeCollection(IEnumerable blobCollection, string methodName, Type type) + { + blobCollection = blobCollection.Select(b => Convert.ChangeType(b, type)); + MethodInfo method = typeof(BlobStorageConverter).GetMethod(methodName, BindingFlags.Static | BindingFlags.NonPublic); + MethodInfo genericMethod = method.MakeGenericMethod(type); + + return genericMethod.Invoke(null, new[] { blobCollection.ToList() }); + } + + private static T[] CloneToArray(IList source) + { + return source.Cast().ToArray(); + } + + private static IEnumerable CloneToList(IList source) + { + return source.Cast(); + } + + private async Task GetBlobStringAsync(string connectionName, string containerName, string blobName) + { + var client = CreateBlobClient(connectionName, containerName, blobName); + return await GetBlobContentStringAsync(client); + } + + private async Task GetBlobContentStringAsync(BlobClient client) + { + var download = await client.DownloadContentAsync(); + return download.Value.Content.ToString(); + } + + private async Task GetBlobBinaryDataAsync(string connectionName, string containerName, string blobName) + { + using MemoryStream stream = new(); + var client = CreateBlobClient(connectionName, containerName, blobName); + await client.DownloadToAsync(stream); + return stream.ToArray(); + } + + private async Task GetBlobStreamAsync(string connectionName, string containerName, string blobName) + { + var client = CreateBlobClient(connectionName, containerName, blobName); + var download = await client.DownloadStreamingAsync(); + return download.Value.Content; + } + + private BlobContainerClient CreateBlobContainerClient(string connectionName, string containerName) + { + var blobStorageOptions = _blobOptions.Get(connectionName); + BlobServiceClient blobServiceClient = blobStorageOptions.CreateClient(); + BlobContainerClient container = blobServiceClient.GetBlobContainerClient(containerName); + return container; + } + + private T CreateBlobClient(string connectionName, string containerName, string blobName) where T : BlobBaseClient + { + if (string.IsNullOrEmpty(blobName)) + { + throw new ArgumentNullException(nameof(blobName)); + } + + BlobContainerClient container = CreateBlobContainerClient(connectionName, containerName); + + Type targetType = typeof(T); + BlobBaseClient blobClient = targetType switch + { + Type _ when targetType == typeof(BlobClient) => container.GetBlobClient(blobName), + Type _ when targetType == typeof(BlockBlobClient) => container.GetBlockBlobClient(blobName), + Type _ when targetType == typeof(PageBlobClient) => container.GetPageBlobClient(blobName), + Type _ when targetType == typeof(AppendBlobClient) => container.GetAppendBlobClient(blobName), + _ => container.GetBlobBaseClient(blobName) + }; + + return (T)blobClient; + } + } +} \ No newline at end of file diff --git a/extensions/Worker.Extensions.Storage.Blobs/src/BlobTriggerAttribute.cs b/extensions/Worker.Extensions.Storage.Blobs/src/BlobTriggerAttribute.cs index 3a546c6be..59ac9fbc8 100644 --- a/extensions/Worker.Extensions.Storage.Blobs/src/BlobTriggerAttribute.cs +++ b/extensions/Worker.Extensions.Storage.Blobs/src/BlobTriggerAttribute.cs @@ -5,6 +5,7 @@ namespace Microsoft.Azure.Functions.Worker { + [SupportsDeferredBinding] public sealed class BlobTriggerAttribute : TriggerBindingAttribute { private readonly string _blobPath; diff --git a/extensions/Worker.Extensions.Storage.Blobs/src/StorageExtensionStartup.cs b/extensions/Worker.Extensions.Storage.Blobs/src/StorageExtensionStartup.cs index 9f8b10bb1..a7bee100d 100644 --- a/extensions/Worker.Extensions.Storage.Blobs/src/StorageExtensionStartup.cs +++ b/extensions/Worker.Extensions.Storage.Blobs/src/StorageExtensionStartup.cs @@ -24,6 +24,11 @@ public override void Configure(IFunctionsWorkerApplicationBuilder applicationBui applicationBuilder.Services.AddAzureClientsCore(); // Adds AzureComponentFactory applicationBuilder.Services.AddOptions(); applicationBuilder.Services.AddSingleton, BlobStorageBindingOptionsSetup>(); + + applicationBuilder.Services.Configure((workerOption) => + { + workerOption.InputConverters.RegisterAt(0); + }); } } } diff --git a/src/DotNetWorker.Core/CollectionModelBindingData.cs b/src/DotNetWorker.Core/CollectionModelBindingData.cs new file mode 100644 index 000000000..a22400c96 --- /dev/null +++ b/src/DotNetWorker.Core/CollectionModelBindingData.cs @@ -0,0 +1,16 @@ +// 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.Core +{ + /// + /// A representation of Collection of Microsoft.Azure.WebJobs.ParameterBindingData + /// + public abstract class CollectionModelBindingData + { + /// + /// Gets the array of ModelBindingData + /// + public abstract ModelBindingData[] ModelBindingDataArray { get; } + } +} diff --git a/src/DotNetWorker.Core/ModelBindingData.cs b/src/DotNetWorker.Core/ModelBindingData.cs new file mode 100644 index 000000000..8e4580f77 --- /dev/null +++ b/src/DotNetWorker.Core/ModelBindingData.cs @@ -0,0 +1,33 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.Azure.Functions.Worker.Core +{ + /// + /// A representation of a Microsoft.Azure.WebJobs.ParameterBindingData + /// + public abstract class ModelBindingData + { + /// + /// Gets the version of the binding data content + /// + public abstract string Version { get; } + + /// + /// Gets the extension source of the binding data i.e CosmosDB, AzureStorageBlobs + /// + public abstract string Source { get; } + + /// + /// Gets the binding data content + /// + public abstract BinaryData Content { get; } + + /// + /// Gets the content type of the binding data content i.e. "application/json" + /// + public abstract string ContentType { get; } + } +} diff --git a/src/DotNetWorker.Grpc/Features/GrpcFunctionBindingsFeature.cs b/src/DotNetWorker.Grpc/Features/GrpcFunctionBindingsFeature.cs index ec4e7bcee..e45f02acf 100644 --- a/src/DotNetWorker.Grpc/Features/GrpcFunctionBindingsFeature.cs +++ b/src/DotNetWorker.Grpc/Features/GrpcFunctionBindingsFeature.cs @@ -124,6 +124,8 @@ public void SetOutputBinding(string name, object value) TypedData.DataOneofCase.CollectionString => typedData.CollectionString.String, TypedData.DataOneofCase.CollectionDouble => typedData.CollectionDouble.Double, TypedData.DataOneofCase.CollectionSint64 => typedData.CollectionSint64.Sint64, + TypedData.DataOneofCase.ModelBindingData => new GrpcModelBindingData(typedData.ModelBindingData), + TypedData.DataOneofCase.CollectionModelBindingData => new GrpcCollectionModelBindingData(typedData.CollectionModelBindingData), _ => throw new NotSupportedException($"{typedData.DataCase} is not supported."), }; } diff --git a/src/DotNetWorker.Grpc/GrpcCollectionModelBindingData.cs b/src/DotNetWorker.Grpc/GrpcCollectionModelBindingData.cs new file mode 100644 index 000000000..d3e0726fa --- /dev/null +++ b/src/DotNetWorker.Grpc/GrpcCollectionModelBindingData.cs @@ -0,0 +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 System; +using System.Linq; + +namespace Microsoft.Azure.Functions.Worker.Grpc.Messages +{ + internal partial class GrpcCollectionModelBindingData : Microsoft.Azure.Functions.Worker.Core.CollectionModelBindingData + { + public GrpcCollectionModelBindingData(CollectionModelBindingData modelBindingDataArray) + { + ModelBindingDataArray = modelBindingDataArray.ModelBindingData + .Select(p => new GrpcModelBindingData(p)).ToArray(); + } + + public override Core.ModelBindingData[] ModelBindingDataArray { get; } + } +} diff --git a/src/DotNetWorker.Grpc/GrpcModelBindingData.cs b/src/DotNetWorker.Grpc/GrpcModelBindingData.cs new file mode 100644 index 000000000..66d5de896 --- /dev/null +++ b/src/DotNetWorker.Grpc/GrpcModelBindingData.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.Grpc.Messages +{ + internal partial class GrpcModelBindingData : Microsoft.Azure.Functions.Worker.Core.ModelBindingData + { + private readonly ModelBindingData _modelBindingData; + + public GrpcModelBindingData(ModelBindingData modelBindingData) + { + _modelBindingData = modelBindingData; + Content = BinaryData.FromBytes(_modelBindingData.Content.ToByteArray()); + } + + public override string Version => _modelBindingData.Version; + + public override string Source => _modelBindingData.Source; + + public override BinaryData Content { get; } + + public override string ContentType => _modelBindingData.ContentType; + } +} diff --git a/test/DotNetWorkerTests/GrpcFunctionBindingsFeatureTests.cs b/test/DotNetWorkerTests/GrpcFunctionBindingsFeatureTests.cs index d449987ff..3fb8d6445 100644 --- a/test/DotNetWorkerTests/GrpcFunctionBindingsFeatureTests.cs +++ b/test/DotNetWorkerTests/GrpcFunctionBindingsFeatureTests.cs @@ -31,5 +31,156 @@ public GrpcFunctionBindingsFeatureTests() .Setup(m => m.Create()) .Returns(new InvocationFeatures(Enumerable.Empty())); } + + [Fact] + public void BindFunctionTriggerAsync_Populates_ModelBindingData() + { + // Arrange + var invocationId = "5fb3a9b4-0b38-450a-9d46-35946e7edea7"; + var request = TestUtility.CreateInvocationRequest(invocationId); + request.TriggerMetadata.Add("myBlob", new TypedData() { ModelBindingData = new ModelBindingData() { + Version = "1.1.1", + Source = "blob", + Content = ByteString.CopyFromUtf8("stringText")} }); + + IInvocationFeatures invocationFeatures = _mockInvocationFeaturesFactory.Object.Create(); + var context = _mockApplication.Object.CreateContext(invocationFeatures, new CancellationTokenSource().Token); + + // Act + var functionBindingsFeature = new GrpcFunctionBindingsFeature(context, request, _mockOutputBindingsInfoProvider.Object); + + // Assert + Assert.Single(functionBindingsFeature.TriggerMetadata); + + functionBindingsFeature.TriggerMetadata.TryGetValue("myBlob", out object bindingData); + Assert.True(bindingData.GetType() == typeof(GrpcModelBindingData)); + var grpcModelBindingData = (GrpcModelBindingData)bindingData; + + Assert.True(grpcModelBindingData.Version == "1.1.1"); + Assert.True(grpcModelBindingData.Content.GetType() == typeof(BinaryData)); + } + + [Fact] + public void BindFunctionInputAsync_Populates_ModelBindingData() + { + // Arrange + var invocationId = "5fb3a9b4-0b38-450a-9d46-35946e7edea7"; + var request = TestUtility.CreateInvocationRequest(invocationId); + request.InputData.Insert(0, new ParameterBinding() + { + Name = "myBlob", + Data = new TypedData() + { + ModelBindingData = new ModelBindingData() + { + Version = "1.1.1", + Source = "blob", + Content = ByteString.CopyFromUtf8("stringText") + } + } + }); + + IInvocationFeatures invocationFeatures = _mockInvocationFeaturesFactory.Object.Create(); + var context = _mockApplication.Object.CreateContext(invocationFeatures, new CancellationTokenSource().Token); + + // Act + var functionBindingsFeature = new GrpcFunctionBindingsFeature(context, request, _mockOutputBindingsInfoProvider.Object); + + // Assert + Assert.Single(functionBindingsFeature.InputData); + + functionBindingsFeature.InputData.TryGetValue("myBlob", out object bindingData); + Assert.True(bindingData.GetType() == typeof(GrpcModelBindingData)); + var grpcModelBindingData = (GrpcModelBindingData)bindingData; + + Assert.True(grpcModelBindingData.Version == "1.1.1"); + Assert.True(grpcModelBindingData.Content.GetType() == typeof(BinaryData)); + } + + [Fact] + public void BindFunctionTriggerAsync_Populates_CollectionModelBindingData() + { + // Arrange + var invocationId = "5fb3a9b4-0b38-450a-9d46-35946e7edea7"; + var request = TestUtility.CreateInvocationRequest(invocationId); + + var modelBindingData = new ModelBindingData() + { + Version = "1.1.1", + Source = "blob", + Content = ByteString.CopyFromUtf8("stringText") + }; + + var collectionModelBindingData = new CollectionModelBindingData(); + collectionModelBindingData.ModelBindingData.Add(modelBindingData); + + request.TriggerMetadata.Add("myBlob", new TypedData() + { + CollectionModelBindingData = collectionModelBindingData + }); + + IInvocationFeatures invocationFeatures = _mockInvocationFeaturesFactory.Object.Create(); + var context = _mockApplication.Object.CreateContext(invocationFeatures, new CancellationTokenSource().Token); + + // Act + var functionBindingsFeature = new GrpcFunctionBindingsFeature(context, request, _mockOutputBindingsInfoProvider.Object); + + // Assert + Assert.Single(functionBindingsFeature.TriggerMetadata); + + functionBindingsFeature.TriggerMetadata.TryGetValue("myBlob", out object bindingData); + Assert.True(bindingData.GetType() == typeof(GrpcCollectionModelBindingData)); + var grpcCollectionModelBindingData = (GrpcCollectionModelBindingData)bindingData; + + Assert.True(grpcCollectionModelBindingData.ModelBindingDataArray.Count() == 1); + var grpcModelBindingData = grpcCollectionModelBindingData.ModelBindingDataArray[0]; + Assert.True(grpcModelBindingData.Version == "1.1.1"); + Assert.True(grpcModelBindingData.Content.GetType() == typeof(BinaryData)); + } + + [Fact] + public void BindFunctionInputAsync_Populates_CollectionModelBindingData() + { + // Arrange + var invocationId = "5fb3a9b4-0b38-450a-9d46-35946e7edea7"; + var request = TestUtility.CreateInvocationRequest(invocationId); + + var modelBindingData = new ModelBindingData() + { + Version = "1.1.1", + Source = "blob", + Content = ByteString.CopyFromUtf8("stringText") + }; + + var collectionModelBindingData = new CollectionModelBindingData(); + collectionModelBindingData.ModelBindingData.Add(modelBindingData); + + request.InputData.Insert(0, new ParameterBinding() + { + Name = "myBlob", + Data = new TypedData() + { + CollectionModelBindingData = collectionModelBindingData + } + }); + + IInvocationFeatures invocationFeatures = _mockInvocationFeaturesFactory.Object.Create(); + var context = _mockApplication.Object.CreateContext(invocationFeatures, new CancellationTokenSource().Token); + + // Act + var functionBindingsFeature = new GrpcFunctionBindingsFeature(context, request, _mockOutputBindingsInfoProvider.Object); + + // Assert + Assert.Single(functionBindingsFeature.InputData); + + functionBindingsFeature.InputData.TryGetValue("myBlob", out object bindingData); + Assert.True(bindingData.GetType() == typeof(GrpcCollectionModelBindingData)); + var grpcCollectionModelBindingData = (GrpcCollectionModelBindingData)bindingData; + + Assert.True(grpcCollectionModelBindingData.ModelBindingDataArray.Count() == 1); + var grpcModelBindingData = grpcCollectionModelBindingData.ModelBindingDataArray[0]; + Assert.True(grpcModelBindingData.Version == "1.1.1"); + Assert.True(grpcModelBindingData.Content.GetType() == typeof(BinaryData)); + } } } diff --git a/test/FunctionMetadataGeneratorTests/FunctionMetadataGeneratorTests.cs b/test/FunctionMetadataGeneratorTests/FunctionMetadataGeneratorTests.cs index d5d0c3bb3..9832840d5 100644 --- a/test/FunctionMetadataGeneratorTests/FunctionMetadataGeneratorTests.cs +++ b/test/FunctionMetadataGeneratorTests/FunctionMetadataGeneratorTests.cs @@ -210,7 +210,7 @@ void ValidateBlobInput(ExpandoObject b) { "Direction", "In" }, { "blobPath", "container2" }, { "Cardinality", "Many" }, - { "Properties", new Dictionary() } + { "Properties", new Dictionary( ) { { "SupportsDeferredBinding" , "True"} } } }); } @@ -225,9 +225,8 @@ void ValidateBlobTrigger(ExpandoObject b) { "Name", "blob" }, { "Type", "blobTrigger" }, { "Direction", "In" }, - { "DataType", "String"}, { "path", "container2/%file%" }, - { "Properties", new Dictionary() } + { "Properties", new Dictionary( ) { { "SupportsDeferredBinding" , "True"} } } }); } @@ -244,6 +243,68 @@ void ValidateQueueOutput(ExpandoObject b) } } + [Fact] + public void StorageFunction_SDKTypeBindings() + { + var generator = new FunctionMetadataGenerator(); + var module = ModuleDefinition.ReadModule(_thisAssembly.Location); + var typeDef = TestUtility.GetTypeDefinition(typeof(SDKTypeBindings)); + var functions = generator.GenerateFunctionMetadata(typeDef); + var extensions = generator.Extensions; + + Assert.Single(functions); + + var blobToBlob = functions.Single(p => p.Name == "BlobToBlobFunction"); + + ValidateFunction(blobToBlob, "BlobToBlobFunction", GetEntryPoint(nameof(SDKTypeBindings), nameof(SDKTypeBindings.BlobToBlob)), + b => ValidateBlobTrigger(b), + b => ValidateBlobInput(b), + b => ValidateBlobOutput(b)); + + AssertDictionary(extensions, new Dictionary + { + { "Microsoft.Azure.WebJobs.Extensions.Storage.Blobs", "5.1.0-beta.1" }, + }); + + void ValidateBlobTrigger(ExpandoObject b) + { + AssertExpandoObject(b, new Dictionary + { + { "Name", "blob" }, + { "Type", "blobTrigger" }, + { "Direction", "In" }, + { "path", "container2/%file%" }, + { "Properties", new Dictionary( ) { { "SupportsDeferredBinding" , "True"} } } + }); + } + + void ValidateBlobInput(ExpandoObject b) + { + AssertExpandoObject(b, new Dictionary + { + { "Name", "blobinput" }, + { "Type", "blob" }, + { "Direction", "In" }, + { "blobPath", "container2/%file%" }, + { "Cardinality", "One" }, + { "Properties", new Dictionary( ) { { "SupportsDeferredBinding" , "True"} } } + }); + } + + void ValidateBlobOutput(ExpandoObject b) + { + AssertExpandoObject(b, new Dictionary + { + { "Name", "$return" }, + { "Type", "blob" }, + { "Direction", "Out" }, + { "blobPath", "container1/hello.txt" }, + { "Connection", "MyOtherConnection" }, + { "Properties", new Dictionary() } + }); + } + } + [Fact] public void TimerFunction() { diff --git a/test/SdkE2ETests/Contents/functions.metadata b/test/SdkE2ETests/Contents/functions.metadata index 60218adc9..fb401f51f 100644 --- a/test/SdkE2ETests/Contents/functions.metadata +++ b/test/SdkE2ETests/Contents/functions.metadata @@ -50,11 +50,12 @@ "name": "myBlob", "direction": "In", "type": "blob", - "dataType": "String", "blobPath": "test-samples/sample1.txt", "connection": "AzureWebJobsStorage", "cardinality": "One", - "properties": {} + "properties": { + "supportsDeferredBinding": "True" + } }, { "name": "Book", @@ -188,11 +189,12 @@ "name": "myBlob", "direction": "In", "type": "blob", - "dataType": "String", "blobPath": "test-samples/sample1.txt", "connection": "AzureWebJobsStorage", "cardinality": "One", - "properties": {} + "properties": { + "supportsDeferredBinding": "True" + } } ], "retry": { From cdcbc9dbc2fa3e49954cd9ab335da90f9bfd5209 Mon Sep 17 00:00:00 2001 From: Surgupta Date: Thu, 23 Feb 2023 15:00:47 -0800 Subject: [PATCH 05/13] Added unit tests for BlobStorageConverter (#1370) * Adding test project * Added tests --- DotNetWorker.sln | 7 + .../src/BlobStorageConverter.cs | 32 +- .../src/Properties/AssemblyInfo.cs | 4 + .../Properties/AssemblyInfo.cs | 1 + src/ci.yml | 1 + .../Properties/AssemblyInfo.cs | 3 + .../Blob/BlobStorageConverterTests.cs | 411 ++++++++++++++++++ .../WorkerExtensionTests.csproj | 29 ++ 8 files changed, 472 insertions(+), 16 deletions(-) create mode 100644 test/WorkerExtensionTests/Blob/BlobStorageConverterTests.cs create mode 100644 test/WorkerExtensionTests/WorkerExtensionTests.csproj diff --git a/DotNetWorker.sln b/DotNetWorker.sln index 397438389..59776b780 100644 --- a/DotNetWorker.sln +++ b/DotNetWorker.sln @@ -119,6 +119,8 @@ EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Worker.Extensions.Shared", "extensions\Worker.Extensions.Shared\Worker.Extensions.Shared.csproj", "{277D77B9-8915-41E3-8763-0B66328ADDDA}" Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WorkerBindingSamples", "samples\WorkerBindingSamples\WorkerBindingSamples.csproj", "{901DA3C3-3ABA-4859-89D3-63343ED2A0AC}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WorkerExtensionTests", "test\WorkerExtensionTests\WorkerExtensionTests.csproj", "{17BDCE12-6964-4B87-B2AC-68CE270A3E9A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -293,6 +295,10 @@ Global {901DA3C3-3ABA-4859-89D3-63343ED2A0AC}.Debug|Any CPU.Build.0 = Debug|Any CPU {901DA3C3-3ABA-4859-89D3-63343ED2A0AC}.Release|Any CPU.ActiveCfg = Release|Any CPU {901DA3C3-3ABA-4859-89D3-63343ED2A0AC}.Release|Any CPU.Build.0 = Release|Any CPU + {17BDCE12-6964-4B87-B2AC-68CE270A3E9A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {17BDCE12-6964-4B87-B2AC-68CE270A3E9A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {17BDCE12-6964-4B87-B2AC-68CE270A3E9A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {17BDCE12-6964-4B87-B2AC-68CE270A3E9A}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -344,6 +350,7 @@ Global {004DEF24-7EBB-499D-BD1C-E940AC4E122D} = {A7B4FF1E-3DF7-4F28-9333-D0961CDDF702} {277D77B9-8915-41E3-8763-0B66328ADDDA} = {A7B4FF1E-3DF7-4F28-9333-D0961CDDF702} {901DA3C3-3ABA-4859-89D3-63343ED2A0AC} = {9D6603BD-7EA2-4D11-A69C-0D9E01317FD6} + {17BDCE12-6964-4B87-B2AC-68CE270A3E9A} = {FD7243E4-BF18-43F8-8744-BA1D17ACF378} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {497D2ED4-A13E-4BCA-8D29-F30CA7D0EA4A} diff --git a/extensions/Worker.Extensions.Storage.Blobs/src/BlobStorageConverter.cs b/extensions/Worker.Extensions.Storage.Blobs/src/BlobStorageConverter.cs index 3e0a6c96f..f8d34b4cb 100644 --- a/extensions/Worker.Extensions.Storage.Blobs/src/BlobStorageConverter.cs +++ b/extensions/Worker.Extensions.Storage.Blobs/src/BlobStorageConverter.cs @@ -44,7 +44,7 @@ public async ValueTask ConvertAsync(ConverterContext context) }; } - private async ValueTask ConvertFromBindingDataAsync(ConverterContext context, ModelBindingData modelBindingData) + internal virtual async ValueTask ConvertFromBindingDataAsync(ConverterContext context, ModelBindingData modelBindingData) { if (!IsBlobExtension(modelBindingData)) { @@ -69,7 +69,7 @@ private async ValueTask ConvertFromBindingDataAsync(ConverterC return ConversionResult.Unhandled(); } - private async ValueTask ConvertFromCollectionBindingDataAsync(ConverterContext context, CollectionModelBindingData collectionModelBindingData) + internal virtual async ValueTask ConvertFromCollectionBindingDataAsync(ConverterContext context, CollectionModelBindingData collectionModelBindingData) { var blobCollection = new List(collectionModelBindingData.ModelBindingDataArray.Length); Type elementType = context.TargetType.IsArray ? context.TargetType.GetElementType() : context.TargetType.GenericTypeArguments[0]; @@ -103,7 +103,7 @@ private async ValueTask ConvertFromCollectionBindingDataAsync( } } - private bool IsBlobExtension(ModelBindingData bindingData) + internal bool IsBlobExtension(ModelBindingData bindingData) { if (bindingData?.Source is not Constants.BlobExtensionName) { @@ -114,7 +114,7 @@ private bool IsBlobExtension(ModelBindingData bindingData) return true; } - private Dictionary GetBindingDataContent(ModelBindingData bindingData) + internal Dictionary GetBindingDataContent(ModelBindingData bindingData) { return bindingData?.ContentType switch { @@ -123,7 +123,7 @@ private Dictionary GetBindingDataContent(ModelBindingData bindin }; } - private async Task ConvertModelBindingDataAsync(IDictionary content, Type targetType, ModelBindingData bindingData) + internal virtual async Task ConvertModelBindingDataAsync(IDictionary content, Type targetType, ModelBindingData bindingData) { content.TryGetValue(Constants.Connection, out var connectionName); content.TryGetValue(Constants.ContainerName, out var containerName); @@ -137,7 +137,7 @@ private Dictionary GetBindingDataContent(ModelBindingData bindin return await ToTargetTypeAsync(targetType, connectionName, containerName, blobName); } - private async Task ToTargetTypeAsync(Type targetType, string connectionName, string containerName, string blobName) => targetType switch + internal virtual async Task ToTargetTypeAsync(Type targetType, string connectionName, string containerName, string blobName) => targetType switch { Type _ when targetType == typeof(String) => await GetBlobStringAsync(connectionName, containerName, blobName), Type _ when targetType == typeof(Stream) => await GetBlobStreamAsync(connectionName, containerName, blobName), @@ -151,13 +151,13 @@ private Dictionary GetBindingDataContent(ModelBindingData bindin _ => await DeserializeToTargetObjectAsync(targetType, connectionName, containerName, blobName) }; - private async Task DeserializeToTargetObjectAsync(Type targetType, string connectionName, string containerName, string blobName) + internal async Task DeserializeToTargetObjectAsync(Type targetType, string connectionName, string containerName, string blobName) { var content = await GetBlobStreamAsync(connectionName, containerName, blobName); return _workerOptions?.Value?.Serializer?.Deserialize(content, targetType, CancellationToken.None); } - private object? ToTargetTypeCollection(IEnumerable blobCollection, string methodName, Type type) + internal object? ToTargetTypeCollection(IEnumerable blobCollection, string methodName, Type type) { blobCollection = blobCollection.Select(b => Convert.ChangeType(b, type)); MethodInfo method = typeof(BlobStorageConverter).GetMethod(methodName, BindingFlags.Static | BindingFlags.NonPublic); @@ -166,17 +166,17 @@ private Dictionary GetBindingDataContent(ModelBindingData bindin return genericMethod.Invoke(null, new[] { blobCollection.ToList() }); } - private static T[] CloneToArray(IList source) + internal static T[] CloneToArray(IList source) { return source.Cast().ToArray(); } - private static IEnumerable CloneToList(IList source) + internal static IEnumerable CloneToList(IList source) { return source.Cast(); } - private async Task GetBlobStringAsync(string connectionName, string containerName, string blobName) + internal virtual async Task GetBlobStringAsync(string connectionName, string containerName, string blobName) { var client = CreateBlobClient(connectionName, containerName, blobName); return await GetBlobContentStringAsync(client); @@ -188,7 +188,7 @@ private async Task GetBlobContentStringAsync(BlobClient client) return download.Value.Content.ToString(); } - private async Task GetBlobBinaryDataAsync(string connectionName, string containerName, string blobName) + internal virtual async Task GetBlobBinaryDataAsync(string connectionName, string containerName, string blobName) { using MemoryStream stream = new(); var client = CreateBlobClient(connectionName, containerName, blobName); @@ -196,14 +196,14 @@ private async Task GetBlobBinaryDataAsync(string connectionName, string return stream.ToArray(); } - private async Task GetBlobStreamAsync(string connectionName, string containerName, string blobName) + internal virtual async Task GetBlobStreamAsync(string connectionName, string containerName, string blobName) { var client = CreateBlobClient(connectionName, containerName, blobName); var download = await client.DownloadStreamingAsync(); return download.Value.Content; } - private BlobContainerClient CreateBlobContainerClient(string connectionName, string containerName) + internal virtual BlobContainerClient CreateBlobContainerClient(string connectionName, string containerName) { var blobStorageOptions = _blobOptions.Get(connectionName); BlobServiceClient blobServiceClient = blobStorageOptions.CreateClient(); @@ -211,7 +211,7 @@ private BlobContainerClient CreateBlobContainerClient(string connectionName, str return container; } - private T CreateBlobClient(string connectionName, string containerName, string blobName) where T : BlobBaseClient + internal virtual T CreateBlobClient(string connectionName, string containerName, string blobName) where T : BlobBaseClient { if (string.IsNullOrEmpty(blobName)) { @@ -233,4 +233,4 @@ private T CreateBlobClient(string connectionName, string containerName, strin return (T)blobClient; } } -} \ No newline at end of file +} diff --git a/extensions/Worker.Extensions.Storage.Blobs/src/Properties/AssemblyInfo.cs b/extensions/Worker.Extensions.Storage.Blobs/src/Properties/AssemblyInfo.cs index 798f93575..73c8e5720 100644 --- a/extensions/Worker.Extensions.Storage.Blobs/src/Properties/AssemblyInfo.cs +++ b/extensions/Worker.Extensions.Storage.Blobs/src/Properties/AssemblyInfo.cs @@ -1,6 +1,10 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. See License.txt in the project root for license information. +using System.Runtime.CompilerServices; using Microsoft.Azure.Functions.Worker.Extensions.Abstractions; [assembly: ExtensionInformation("Microsoft.Azure.WebJobs.Extensions.Storage.Blobs", "5.1.0-beta.1")] +[assembly: InternalsVisibleTo("Microsoft.Azure.Functions.WorkerExtension.Tests, PublicKey=00240000048000009400000006020000002400005253413100040000010001005148be37ac1d9f58bd40a2e472c9d380d635b6048278f7d47480b08c928858f0f7fe17a6e4ce98da0e7a7f0b8c308aecd9e9b02d7e9680a5b5b75ac7773cec096fbbc64aebd429e77cb5f89a569a79b28e9c76426783f624b6b70327eb37341eb498a2c3918af97c4860db6cdca4732787150841e395a29cfacb959c1fd971c1")] +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c547cac37abd99c8db225ef2f6c8a3602f3b3606cc9891605d02baa56104f4cfc0734aa39b93bf7852f7d9266654753cc297e7d2edfe0bac1cdcf9f717241550e0a7b191195b7667bb4f64bcb8e2121380fd1d9d46ad2d92d2d15605093924cceaf74c4861eff62abf69b9291ed0a340e113be11e6a7d3113e92484cf7045cc7")] + diff --git a/src/DotNetWorker.Grpc/Properties/AssemblyInfo.cs b/src/DotNetWorker.Grpc/Properties/AssemblyInfo.cs index db2c09718..c11eba4ae 100644 --- a/src/DotNetWorker.Grpc/Properties/AssemblyInfo.cs +++ b/src/DotNetWorker.Grpc/Properties/AssemblyInfo.cs @@ -6,3 +6,4 @@ [assembly: InternalsVisibleTo("Microsoft.Azure.Functions.Worker.Tests, PublicKey=00240000048000009400000006020000002400005253413100040000010001005148be37ac1d9f58bd40a2e472c9d380d635b6048278f7d47480b08c928858f0f7fe17a6e4ce98da0e7a7f0b8c308aecd9e9b02d7e9680a5b5b75ac7773cec096fbbc64aebd429e77cb5f89a569a79b28e9c76426783f624b6b70327eb37341eb498a2c3918af97c4860db6cdca4732787150841e395a29cfacb959c1fd971c1")] [assembly: InternalsVisibleTo("Microsoft.Azure.Functions.Worker, PublicKey=00240000048000009400000006020000002400005253413100040000010001005148be37ac1d9f58bd40a2e472c9d380d635b6048278f7d47480b08c928858f0f7fe17a6e4ce98da0e7a7f0b8c308aecd9e9b02d7e9680a5b5b75ac7773cec096fbbc64aebd429e77cb5f89a569a79b28e9c76426783f624b6b70327eb37341eb498a2c3918af97c4860db6cdca4732787150841e395a29cfacb959c1fd971c1")] [assembly: InternalsVisibleTo("DynamicProxyGenAssembly2, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c547cac37abd99c8db225ef2f6c8a3602f3b3606cc9891605d02baa56104f4cfc0734aa39b93bf7852f7d9266654753cc297e7d2edfe0bac1cdcf9f717241550e0a7b191195b7667bb4f64bcb8e2121380fd1d9d46ad2d92d2d15605093924cceaf74c4861eff62abf69b9291ed0a340e113be11e6a7d3113e92484cf7045cc7")] +[assembly: InternalsVisibleTo("Microsoft.Azure.Functions.WorkerExtension.Tests, PublicKey=00240000048000009400000006020000002400005253413100040000010001005148be37ac1d9f58bd40a2e472c9d380d635b6048278f7d47480b08c928858f0f7fe17a6e4ce98da0e7a7f0b8c308aecd9e9b02d7e9680a5b5b75ac7773cec096fbbc64aebd429e77cb5f89a569a79b28e9c76426783f624b6b70327eb37341eb498a2c3918af97c4860db6cdca4732787150841e395a29cfacb959c1fd971c1")] diff --git a/src/ci.yml b/src/ci.yml index 5ba592f86..85b7dc96c 100644 --- a/src/ci.yml +++ b/src/ci.yml @@ -18,6 +18,7 @@ pr: include: - src/ - test/DotNetWorkerTests + - test/WorkerExtensionTests - test/E2ETests - test/TestUtility - sdk/ diff --git a/test/DotNetWorkerTests/Properties/AssemblyInfo.cs b/test/DotNetWorkerTests/Properties/AssemblyInfo.cs index a21acafe0..9e991d617 100644 --- a/test/DotNetWorkerTests/Properties/AssemblyInfo.cs +++ b/test/DotNetWorkerTests/Properties/AssemblyInfo.cs @@ -1,6 +1,9 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. See License.txt in the project root for license information. +using System.Runtime.CompilerServices; using Xunit; [assembly: CollectionBehavior(DisableTestParallelization = true)] +[assembly: InternalsVisibleTo("Microsoft.Azure.Functions.WorkerExtension.Tests, PublicKey=00240000048000009400000006020000002400005253413100040000010001005148be37ac1d9f58bd40a2e472c9d380d635b6048278f7d47480b08c928858f0f7fe17a6e4ce98da0e7a7f0b8c308aecd9e9b02d7e9680a5b5b75ac7773cec096fbbc64aebd429e77cb5f89a569a79b28e9c76426783f624b6b70327eb37341eb498a2c3918af97c4860db6cdca4732787150841e395a29cfacb959c1fd971c1")] + diff --git a/test/WorkerExtensionTests/Blob/BlobStorageConverterTests.cs b/test/WorkerExtensionTests/Blob/BlobStorageConverterTests.cs new file mode 100644 index 000000000..49ce572d3 --- /dev/null +++ b/test/WorkerExtensionTests/Blob/BlobStorageConverterTests.cs @@ -0,0 +1,411 @@ +// 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.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Azure.Storage.Blobs; +using Azure.Storage.Blobs.Specialized; +using Google.Protobuf; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Converters; +using Microsoft.Azure.Functions.Worker.Grpc.Messages; +using Microsoft.Azure.Functions.Worker.Tests; +using Microsoft.Azure.Functions.Worker.Tests.Converters; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using Xunit; + +namespace Microsoft.Azure.Functions.WorkerExtension.Tests +{ + public class BlobStorageConverterTests + { + private Mock _mockBlobStorageConverter; + + public BlobStorageConverterTests() + { + var host = new HostBuilder().ConfigureFunctionsWorkerDefaults((WorkerOptions options) => { }).Build(); + var workerOptions = host.Services.GetService>(); + var blobOptions = host.Services.GetService>(); + var logger = host.Services.GetService>(); + + _mockBlobStorageConverter = new Mock(workerOptions, blobOptions, logger); + _mockBlobStorageConverter.CallBase = true; + } + + [Fact] + public async Task ConvertAsync_SourceAsObject_ReturnsUnhandled() + { + var context = new TestConverterContext(typeof(string), new Object()); + + var conversionResult = await _mockBlobStorageConverter.Object.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Unhandled, conversionResult.Status); + } + + [Fact] + public async Task ConvertAsync_SourceAsModelBindingData_ReturnsSuccess() + { + object source = GetTestGrpcModelBindingData(GetFullTestBinaryData()); + var context = new TestConverterContext(typeof(string), source); + _mockBlobStorageConverter + .Setup(c => c.ToTargetTypeAsync(typeof(string), Constants.Connection, Constants.ContainerName, Constants.BlobName)) + .ReturnsAsync("test"); + + var conversionResult = await _mockBlobStorageConverter.Object.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Succeeded, conversionResult.Status); + } + + [Fact] + public async Task ConvertAsync_SourceAsCollectionModelBindingData_ReturnsSuccess() + { + object source = GetTestGrpcCollectionModelBindingData(); + var context = new TestConverterContext(typeof(string), source); + _mockBlobStorageConverter + .Setup(c => c.ConvertFromCollectionBindingDataAsync(context, (Worker.Core.CollectionModelBindingData) source)) + .Returns(new ValueTask(ConversionResult.Success("test"))); + + var conversionResult = await _mockBlobStorageConverter.Object.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Succeeded, conversionResult.Status); + } + + [Fact] + public async Task ConvertAsync_SourceAsModelBindingData_ReturnsFailure() + { + object source = GetTestGrpcModelBindingData(GetFullTestBinaryData()); + var context = new TestConverterContext(typeof(string), source); + _mockBlobStorageConverter + .Setup(c => c.ToTargetTypeAsync(typeof(string), Constants.Connection, Constants.ContainerName, Constants.BlobName)) + .ThrowsAsync(new Exception()); + + var conversionResult = await _mockBlobStorageConverter.Object.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Failed, conversionResult.Status); + } + + [Fact] + public async Task ConvertFromBindingDataAsync_ReturnsSuccess() + { + object source = GetTestGrpcModelBindingData(GetFullTestBinaryData()); + var contentDict = GetTestContentDict(); + var context = new TestConverterContext(typeof(string), source); + var modelBindingData = (Worker.Core.ModelBindingData) source; + + _mockBlobStorageConverter + .Setup(c => c.ConvertModelBindingDataAsync(contentDict, typeof(string), modelBindingData)) + .ReturnsAsync(new ValueTask(ConversionResult.Success("test"))); + + var conversionResult = await _mockBlobStorageConverter.Object.ConvertFromBindingDataAsync(context, modelBindingData); + + Assert.Equal(ConversionStatus.Succeeded, conversionResult.Status); + } + + [Fact] + public async Task ConvertFromBindingDataAsync_ReturnsFailure() + { + object source = GetTestGrpcModelBindingData(GetFullTestBinaryData()); + var contentDict = GetTestContentDict(); + var context = new TestConverterContext(typeof(string), source); + + _mockBlobStorageConverter + .Setup(c => c.ConvertModelBindingDataAsync(contentDict, typeof(string), (Worker.Core.ModelBindingData)source)) + .ThrowsAsync(new Exception()); + + var conversionResult = await _mockBlobStorageConverter.Object.ConvertFromBindingDataAsync(context, (Worker.Core.ModelBindingData)source); + + Assert.Equal(ConversionStatus.Failed, conversionResult.Status); + } + + [Fact] + public async Task ConvertFromBindingDataAsync_ReturnsUnhandled() + { + object source = GetTestGrpcModelBindingData(GetFullTestBinaryData()); + var context = new TestConverterContext(typeof(string), source); + var dict = _mockBlobStorageConverter.Object.GetBindingDataContent((Worker.Core.ModelBindingData)source); + + _mockBlobStorageConverter.Setup(c => c.ConvertModelBindingDataAsync(dict, typeof(string), (Worker.Core.ModelBindingData)source)).ReturnsAsync(null); + + var conversionResult = await _mockBlobStorageConverter.Object.ConvertFromBindingDataAsync(context, (Worker.Core.ModelBindingData)source); + + Assert.Equal(ConversionStatus.Unhandled, conversionResult.Status); + } + + [Theory] + [InlineData(Constants.BlobExtensionName, true)] + [InlineData(" ", false)] + [InlineData("incorrect-value", false)] + public void IsBlobExtension_MatchesExpectedOutput(string sourceVal, bool expectedResult) + { + var grpcModelBindingData = new GrpcModelBindingData(new ModelBindingData() + { + Version = "1.0", + Source = sourceVal, + Content = ByteString.CopyFrom(GetFullTestBinaryData()), + ContentType = "application/json" + }); + + var result = _mockBlobStorageConverter.Object.IsBlobExtension(grpcModelBindingData); + + Assert.Equal(result, expectedResult); + } + + [Fact] + public void GetBindingDataContent_CompleteGrpcModelBindingData_Works() + { + var grpcModelBindingData = GetTestGrpcModelBindingData(GetFullTestBinaryData()); + var result = _mockBlobStorageConverter.Object.GetBindingDataContent(grpcModelBindingData); + + result.TryGetValue(Constants.Connection, out var connectionName); + result.TryGetValue(Constants.ContainerName, out var containerName); + result.TryGetValue(Constants.BlobName, out var blobName); + + Assert.Equal(3, result.Count); + Assert.Equal(Constants.Connection, connectionName); + Assert.Equal(Constants.ContainerName, containerName); + Assert.Equal(Constants.BlobName, blobName); + } + + [Fact] + public void GetBindingDataContent_IncompleteGrpcModelBindingData_ReturnsNull() + { + var grpcModelBindingData = GetTestGrpcModelBindingData(GetTestBinaryData()); + + var result = _mockBlobStorageConverter.Object.GetBindingDataContent(grpcModelBindingData); + + result.TryGetValue(Constants.Connection, out var connectionName); + result.TryGetValue(Constants.ContainerName, out var containerName); + result.TryGetValue(Constants.BlobName, out var blobName); + + Assert.Single(result); + Assert.True(connectionName is null); + Assert.True(containerName is null); + Assert.Equal(Constants.BlobName, blobName); + } + + [Fact] + public void GetBindingDataContent_UnSupportedContentType_Throws() + { + var grpcModelBindingData = new GrpcModelBindingData(new ModelBindingData() + { + Version = "1.0", + Source = Constants.BlobExtensionName, + Content = ByteString.CopyFrom(GetFullTestBinaryData()), + ContentType = "NotSupported" + }); + + try + { + var dict = _mockBlobStorageConverter.Object.GetBindingDataContent(grpcModelBindingData); + Assert.Fail("Test fails as the expected exception not thrown"); + } + catch (Exception ex) + { + Assert.Equal(typeof(NotSupportedException), ex.GetType()); + } + } + + [Fact] + public async Task ConvertModelBindingDataAsync_IncompleteGrpcModelBindingData_Throws() + { + var grpcModelBindingData = GetTestGrpcModelBindingData(GetTestBinaryData()); + var dict = _mockBlobStorageConverter.Object.GetBindingDataContent(grpcModelBindingData); + _mockBlobStorageConverter.Setup(c => c.ToTargetTypeAsync(typeof(string), Constants.Connection, Constants.ContainerName, Constants.BlobName)).ReturnsAsync("test"); + + try + { + var result = await _mockBlobStorageConverter.Object.ConvertModelBindingDataAsync(dict, typeof(string), grpcModelBindingData); + Assert.Fail("Test fails as the expected exception not thrown"); + } + catch (ArgumentNullException) { } + } + + [Fact] + public async Task ConvertModelBindingDataAsync_GrpcModelBindingData_Works() + { + var grpcModelBindingData = GetTestGrpcModelBindingData(GetFullTestBinaryData()); + var contentDict = GetTestContentDict(); + + _mockBlobStorageConverter + .Setup(c => c.ToTargetTypeAsync(typeof(string), Constants.Connection, Constants.ContainerName, Constants.BlobName)) + .ReturnsAsync("test"); + + var result = await _mockBlobStorageConverter.Object.ConvertModelBindingDataAsync(contentDict, typeof(string), grpcModelBindingData); + + Assert.Equal(typeof(string), result.GetType()); + + } + + [Fact] + public async Task ToTargetTypeAsync_Works() + { + object source = GetTestGrpcModelBindingData(GetFullTestBinaryData()); + byte[] byteArray = Encoding.UTF8.GetBytes("test"); + + _mockBlobStorageConverter.Setup(c => c.GetBlobStreamAsync(Constants.Connection, Constants.ContainerName, Constants.BlobName)).ReturnsAsync(new MemoryStream(byteArray)); + _mockBlobStorageConverter.Setup(c => c.GetBlobBinaryDataAsync(Constants.Connection, Constants.ContainerName, Constants.BlobName)).ReturnsAsync(byteArray); + _mockBlobStorageConverter.Setup(c => c.GetBlobStringAsync(Constants.Connection, Constants.ContainerName, Constants.BlobName)).ReturnsAsync("test"); + _mockBlobStorageConverter.Setup(c => c.CreateBlobClient(Constants.Connection, Constants.ContainerName, Constants.BlobName)).Returns(new Mock().Object); + _mockBlobStorageConverter.Setup(c => c.CreateBlobClient(Constants.Connection, Constants.ContainerName, Constants.BlobName)).Returns(new Mock().Object); + _mockBlobStorageConverter.Setup(c => c.CreateBlobClient(Constants.Connection, Constants.ContainerName, Constants.BlobName)).Returns(new Mock().Object); + _mockBlobStorageConverter.Setup(c => c.CreateBlobClient(Constants.Connection, Constants.ContainerName, Constants.BlobName)).Returns(new Mock().Object); + _mockBlobStorageConverter.Setup(c => c.CreateBlobClient(Constants.Connection, Constants.ContainerName, Constants.BlobName)).Returns(new Mock().Object); + _mockBlobStorageConverter.Setup(c => c.CreateBlobContainerClient(Constants.Connection, Constants.ContainerName)).Returns(new Mock().Object); + + var streamResult = await _mockBlobStorageConverter.Object.ToTargetTypeAsync(typeof(Stream), Constants.Connection, Constants.ContainerName, Constants.BlobName); + var byteArrayResult = await _mockBlobStorageConverter.Object.ToTargetTypeAsync(typeof(Byte[]), Constants.Connection, Constants.ContainerName, Constants.BlobName); + var stringResult = await _mockBlobStorageConverter.Object.ToTargetTypeAsync(typeof(string), Constants.Connection, Constants.ContainerName, Constants.BlobName); + var blobClientResult = await _mockBlobStorageConverter.Object.ToTargetTypeAsync(typeof(BlobClient), Constants.Connection, Constants.ContainerName, Constants.BlobName); + var blobBaseClientResult = await _mockBlobStorageConverter.Object.ToTargetTypeAsync(typeof(BlobBaseClient), Constants.Connection, Constants.ContainerName, Constants.BlobName); + var blockBlobClientResult = await _mockBlobStorageConverter.Object.ToTargetTypeAsync(typeof(BlockBlobClient), Constants.Connection, Constants.ContainerName, Constants.BlobName); + var pageBlobClientResult = await _mockBlobStorageConverter.Object.ToTargetTypeAsync(typeof(PageBlobClient), Constants.Connection, Constants.ContainerName, Constants.BlobName); + var appendBlobClientResult = await _mockBlobStorageConverter.Object.ToTargetTypeAsync(typeof(AppendBlobClient), Constants.Connection, Constants.ContainerName, Constants.BlobName); + var blobContainerClientResult = await _mockBlobStorageConverter.Object.ToTargetTypeAsync(typeof(BlobContainerClient), Constants.Connection, Constants.ContainerName, Constants.BlobName); + + Assert.Equal(typeof(MemoryStream), streamResult.GetType()); + Assert.Equal(typeof(Byte[]), byteArrayResult.GetType()); + Assert.Equal(typeof(string), stringResult.GetType()); + Assert.Equal(typeof(BlobClient), blobClientResult.GetType().BaseType); + Assert.Equal(typeof(BlobBaseClient), blobBaseClientResult.GetType().BaseType); + Assert.Equal(typeof(BlockBlobClient), blockBlobClientResult.GetType().BaseType); + Assert.Equal(typeof(PageBlobClient), pageBlobClientResult.GetType().BaseType); + Assert.Equal(typeof(AppendBlobClient), appendBlobClientResult.GetType().BaseType); + Assert.Equal(typeof(BlobContainerClient), blobContainerClientResult.GetType().BaseType); + } + + [Fact] + public void ToTargetTypeCollection_CloneToArray_Works() + { + IEnumerable stringCollection = new List() { "hello", "world"}; + string[] stringResult = (string[])_mockBlobStorageConverter.Object.ToTargetTypeCollection(stringCollection, nameof(BlobStorageConverter.CloneToArray), typeof(string)); + + IEnumerable pocoCollection = new List() { new Book(), new Book() }; + Book[] pocoResult = (Book[])_mockBlobStorageConverter.Object.ToTargetTypeCollection(pocoCollection, nameof(BlobStorageConverter.CloneToArray), typeof(Book)); + + IEnumerable byteArraycollection = new List() { Encoding.UTF8.GetBytes("hello"), Encoding.UTF8.GetBytes("world") }; + Byte[][] byteArrayResult = (Byte[][])_mockBlobStorageConverter.Object.ToTargetTypeCollection(byteArraycollection, nameof(BlobStorageConverter.CloneToArray), typeof(Byte[])); + + Assert.Equal(2, stringResult.Length); + Assert.Equal(typeof(string), stringResult[0].GetType()); + + Assert.Equal(2, pocoResult.Length); + Assert.Equal(typeof(Book), pocoResult[0].GetType()); + + Assert.Equal(2, byteArrayResult.Length); + Assert.Equal(typeof(Byte[]), byteArrayResult[0].GetType()); + } + + [Fact] + public void ToTargetTypeCollection_CloneToList_Works() + { + IEnumerable stringCollection = new List() { "hello", "world" }; + IEnumerable stringResult = (IEnumerable)_mockBlobStorageConverter.Object.ToTargetTypeCollection(stringCollection, nameof(BlobStorageConverter.CloneToArray), typeof(string)); + + IEnumerable pocoCollection = new List() { new Book(), new Book() }; + IEnumerable pocoResult = (IEnumerable)_mockBlobStorageConverter.Object.ToTargetTypeCollection(pocoCollection, nameof(BlobStorageConverter.CloneToList), typeof(Book)); + + IEnumerable byteArraycollection = new List() { Encoding.UTF8.GetBytes("hello"), Encoding.UTF8.GetBytes("world") }; + IEnumerable byteArrayResult = (IEnumerable)_mockBlobStorageConverter.Object.ToTargetTypeCollection(byteArraycollection, nameof(BlobStorageConverter.CloneToArray), typeof(Byte[])); + + Assert.Equal(2, stringResult.Count()); + Assert.Equal(typeof(string), stringResult.FirstOrDefault().GetType()); + + Assert.Equal(2, pocoResult.Count()); + Assert.Equal(typeof(Book), pocoResult.FirstOrDefault().GetType()); + + Assert.Equal(2, byteArrayResult.Count()); + Assert.Equal(typeof(Byte[]), byteArrayResult.FirstOrDefault().GetType()); + } + + + [Fact] + public async Task DeserializeToTargetObjectAsync_CorrectPoco_Works() + { + object source = GetTestGrpcModelBindingData(GetFullTestBinaryData()); + string jsonstr = "{" + "\"Id\" : \"1\", \"Title\" : \"title\", \"Author\" : \"author\"}"; + byte[] byteArray = Encoding.UTF8.GetBytes(jsonstr); + + _mockBlobStorageConverter.Setup(c => c.GetBlobStreamAsync(Constants.Connection, Constants.ContainerName, Constants.BlobName)).ReturnsAsync(new MemoryStream(byteArray)); + + var result = await _mockBlobStorageConverter.Object.DeserializeToTargetObjectAsync(typeof(Book), Constants.Connection, Constants.ContainerName, Constants.BlobName); + + Assert.Equal(typeof(Book), result.GetType()); + } + + [Fact] + public async Task DeserializeToTargetObjectAsync_IncorrectPoco_Fails() + { + object source = GetTestGrpcModelBindingData(GetFullTestBinaryData()); + string jsonstr = "{" + "\"Id\" : \"1\", \"Name\" : \"name\"}"; + byte[] byteArray = Encoding.UTF8.GetBytes(jsonstr); + + _mockBlobStorageConverter.Setup(c => c.GetBlobStreamAsync(Constants.Connection, Constants.ContainerName, Constants.BlobName)).ReturnsAsync(new MemoryStream(byteArray)); + + try + { + var result = await _mockBlobStorageConverter.Object.DeserializeToTargetObjectAsync(typeof(Book), Constants.Connection, Constants.ContainerName, Constants.BlobName); + Assert.Fail("Test fails as the expected exception not thrown"); + } + catch (Xunit.Sdk.FailException) { } + } + + private BinaryData GetTestBinaryData() + { + return new BinaryData("{" + "\"BlobName\" : \"BlobName\"" + "}"); + } + + private BinaryData GetFullTestBinaryData() + { + return new BinaryData("{" + + "\"Connection\" : \"Connection\"," + + "\"ContainerName\" : \"ContainerName\"," + + "\"BlobName\" : \"BlobName\"" + + "}"); + } + + + private GrpcModelBindingData GetTestGrpcModelBindingData(BinaryData binaryData) + { + return new GrpcModelBindingData(new ModelBindingData() + { + Version = "1.0", + Source = "AzureStorageBlobs", + Content = ByteString.CopyFrom(binaryData), + ContentType = "application/json" + }); + } + + private GrpcCollectionModelBindingData GetTestGrpcCollectionModelBindingData() + { + var modelBindingData = new ModelBindingData() + { + Version = "1.0", + Source = "AzureStorageBlobs", + Content = ByteString.CopyFrom(GetFullTestBinaryData()), + ContentType = "application/json" + }; + + var array = new CollectionModelBindingData(); + array.ModelBindingData.Add(modelBindingData); + + return new GrpcCollectionModelBindingData(array); + } + + private Dictionary GetTestContentDict() + { + return new Dictionary + { + { Constants.Connection, Constants.Connection }, + { Constants.ContainerName, Constants.ContainerName }, + { Constants.BlobName, Constants.BlobName } + }; + } + } +} diff --git a/test/WorkerExtensionTests/WorkerExtensionTests.csproj b/test/WorkerExtensionTests/WorkerExtensionTests.csproj new file mode 100644 index 000000000..fd04a5bea --- /dev/null +++ b/test/WorkerExtensionTests/WorkerExtensionTests.csproj @@ -0,0 +1,29 @@ + + + + net7.0 + false + Microsoft.Azure.Functions.WorkerExtension.Tests + Microsoft.Azure.Functions.WorkerExtension.Tests + true + preview + ..\..\key.snk + disable + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + From fae3f6b20a54b75ada3000e33381d28c97d21129 Mon Sep 17 00:00:00 2001 From: Lilian Kasem Date: Tue, 21 Mar 2023 16:55:16 -0700 Subject: [PATCH 06/13] Cosmos DB converter for SDK-type support and samples (#1406) --- .../release_notes.md | 3 +- .../src/Config/CosmosDBBindingOptions.cs | 52 +++ .../src/Config/CosmosDBBindingOptionsSetup.cs | 56 +++ .../src/Constants.cs | 15 + .../src/CosmosDBConverter.cs | 206 ++++++++ .../src/CosmosDBInputAttribute.cs | 10 +- .../src/CosmosExtensionStartup.cs | 34 ++ .../src/Properties/AssemblyInfo.cs | 5 +- .../src/Utilities.cs | 25 + .../src/Worker.Extensions.CosmosDB.csproj | 14 +- .../src/BlobStorageConverter.cs | 2 +- .../Config/BlobStorageBindingOptionsSetup.cs | 1 + .../src/Constants.cs | 2 +- .../WorkerBindingSamples/{ => Blob}/Book.cs | 0 .../Cosmos/CosmosInputBindingFunctions.cs | 221 +++++++++ .../Cosmos/CosmosTriggerFunction.cs | 33 ++ .../WorkerBindingSamples/Cosmos/ToDoItem.cs | 11 + .../WorkerBindingSamples.csproj | 3 + .../Blob/BlobStorageConverterTests.cs | 2 + .../Cosmos/CosmosDBConverterTests.cs | 441 ++++++++++++++++++ .../Cosmos/UtilitiesTests.cs | 46 ++ .../WorkerExtensionTests.csproj | 1 + 22 files changed, 1177 insertions(+), 6 deletions(-) create mode 100644 extensions/Worker.Extensions.CosmosDB/src/Config/CosmosDBBindingOptions.cs create mode 100644 extensions/Worker.Extensions.CosmosDB/src/Config/CosmosDBBindingOptionsSetup.cs create mode 100644 extensions/Worker.Extensions.CosmosDB/src/Constants.cs create mode 100644 extensions/Worker.Extensions.CosmosDB/src/CosmosDBConverter.cs create mode 100644 extensions/Worker.Extensions.CosmosDB/src/CosmosExtensionStartup.cs create mode 100644 extensions/Worker.Extensions.CosmosDB/src/Utilities.cs rename samples/WorkerBindingSamples/{ => Blob}/Book.cs (100%) create mode 100644 samples/WorkerBindingSamples/Cosmos/CosmosInputBindingFunctions.cs create mode 100644 samples/WorkerBindingSamples/Cosmos/CosmosTriggerFunction.cs create mode 100644 samples/WorkerBindingSamples/Cosmos/ToDoItem.cs create mode 100644 test/WorkerExtensionTests/Cosmos/CosmosDBConverterTests.cs create mode 100644 test/WorkerExtensionTests/Cosmos/UtilitiesTests.cs diff --git a/extensions/Worker.Extensions.CosmosDB/release_notes.md b/extensions/Worker.Extensions.CosmosDB/release_notes.md index 9176a67d3..b557ab76e 100644 --- a/extensions/Worker.Extensions.CosmosDB/release_notes.md +++ b/extensions/Worker.Extensions.CosmosDB/release_notes.md @@ -2,4 +2,5 @@ -- Fixed incorrect type of CosmosDBTriggerAttribute's `StartFromTime` property. + +- Add support for SDK-type bindings via deferred binding feature (#1406) diff --git a/extensions/Worker.Extensions.CosmosDB/src/Config/CosmosDBBindingOptions.cs b/extensions/Worker.Extensions.CosmosDB/src/Config/CosmosDBBindingOptions.cs new file mode 100644 index 000000000..21f6446ca --- /dev/null +++ b/extensions/Worker.Extensions.CosmosDB/src/Config/CosmosDBBindingOptions.cs @@ -0,0 +1,52 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Collections.Concurrent; +using Azure.Core; +using Microsoft.Azure.Cosmos; +using Microsoft.Azure.Functions.Worker.Extensions.CosmosDB; + +namespace Microsoft.Azure.Functions.Worker +{ + internal class CosmosDBBindingOptions + { + public string? ConnectionString { get; set; } + + public string? AccountEndpoint { get; set; } + + public TokenCredential? Credential { get; set; } + + internal string BuildCacheKey(string connectionString, string region) => $"{connectionString}|{region}"; + internal ConcurrentDictionary ClientCache { get; } = new ConcurrentDictionary(); + + internal virtual CosmosClient GetClient(string connection, string preferredLocations = "") + { + if (string.IsNullOrEmpty(connection)) + { + throw new ArgumentNullException(nameof(connection)); + } + + string cacheKey = BuildCacheKey(connection, preferredLocations); + + CosmosClientOptions cosmosClientOptions = new () + { + ConnectionMode = ConnectionMode.Gateway + }; + + if (!string.IsNullOrEmpty(preferredLocations)) + { + cosmosClientOptions.ApplicationPreferredRegions = Utilities.ParsePreferredLocations(preferredLocations); + } + + return ClientCache.GetOrAdd(cacheKey, (c) => CreateService(cosmosClientOptions)); + } + + private CosmosClient CreateService(CosmosClientOptions cosmosClientOptions) + { + return string.IsNullOrEmpty(ConnectionString) + ? new CosmosClient(AccountEndpoint, Credential, cosmosClientOptions) // AAD auth + : new CosmosClient(ConnectionString, cosmosClientOptions); // Connection string based auth + } + } +} \ No newline at end of file diff --git a/extensions/Worker.Extensions.CosmosDB/src/Config/CosmosDBBindingOptionsSetup.cs b/extensions/Worker.Extensions.CosmosDB/src/Config/CosmosDBBindingOptionsSetup.cs new file mode 100644 index 000000000..60c2a374a --- /dev/null +++ b/extensions/Worker.Extensions.CosmosDB/src/Config/CosmosDBBindingOptionsSetup.cs @@ -0,0 +1,56 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using Microsoft.Azure.Functions.Worker.Extensions; +using Microsoft.Azure.Functions.Worker.Extensions.CosmosDB; +using Microsoft.Extensions.Azure; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; + +namespace Microsoft.Azure.Functions.Worker +{ + internal class CosmosDBBindingOptionsSetup : IConfigureNamedOptions + { + private readonly IConfiguration _configuration; + private readonly AzureComponentFactory _componentFactory; + + public CosmosDBBindingOptionsSetup(IConfiguration configuration, AzureComponentFactory componentFactory) + { + _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); + _componentFactory = componentFactory ?? throw new ArgumentNullException(nameof(componentFactory)); + } + + public void Configure(CosmosDBBindingOptions options) + { + Configure(Options.DefaultName, options); + } + + public void Configure(string name, CosmosDBBindingOptions options) + { + IConfigurationSection connectionSection = _configuration.GetWebJobsConnectionStringSection(name); + + if (!connectionSection.Exists()) + { + throw new InvalidOperationException($"Cosmos DB connection configuration '{name}' does not exist. " + + "Make sure that it is a defined App Setting."); + } + + if (!string.IsNullOrWhiteSpace(connectionSection.Value)) + { + options.ConnectionString = connectionSection.Value; + } + else + { + options.AccountEndpoint = connectionSection[Constants.AccountEndpoint]; + if (string.IsNullOrWhiteSpace(options.AccountEndpoint)) + { + throw new InvalidOperationException($"Connection should have an '{Constants.AccountEndpoint}' property or be a " + + $"string representing a connection string."); + } + + options.Credential = _componentFactory.CreateTokenCredential(connectionSection); + } + } + } +} \ No newline at end of file diff --git a/extensions/Worker.Extensions.CosmosDB/src/Constants.cs b/extensions/Worker.Extensions.CosmosDB/src/Constants.cs new file mode 100644 index 000000000..99f3cd088 --- /dev/null +++ b/extensions/Worker.Extensions.CosmosDB/src/Constants.cs @@ -0,0 +1,15 @@ + +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +namespace Microsoft.Azure.Functions.Worker.Extensions.CosmosDB +{ + internal static class Constants + { + internal const string CosmosExtensionName = "CosmosDB"; + internal const string ConfigurationSectionName = "AzureWebJobs"; + internal const string ConnectionStringsSectionName = "ConnectionStrings"; + internal const string AccountEndpoint = "accountEndpoint"; + internal const string JsonContentType = "application/json"; + } +} \ No newline at end of file diff --git a/extensions/Worker.Extensions.CosmosDB/src/CosmosDBConverter.cs b/extensions/Worker.Extensions.CosmosDB/src/CosmosDBConverter.cs new file mode 100644 index 000000000..a50823f1c --- /dev/null +++ b/extensions/Worker.Extensions.CosmosDB/src/CosmosDBConverter.cs @@ -0,0 +1,206 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.Azure.Cosmos; +using Microsoft.Azure.Functions.Worker.Core; +using Microsoft.Azure.Functions.Worker.Converters; +using Microsoft.Azure.Functions.Worker.Extensions.CosmosDB; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Azure.Functions.Worker +{ + /// + /// Converter to bind Cosmos DB type parameters. + /// + internal class CosmosDBConverter : IInputConverter + { + private readonly IOptionsSnapshot _cosmosOptions; + private readonly ILogger _logger; + + public CosmosDBConverter(IOptionsSnapshot cosmosOptions, ILogger logger) + { + _cosmosOptions = cosmosOptions ?? throw new ArgumentNullException(nameof(cosmosOptions)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async ValueTask ConvertAsync(ConverterContext context) + { + return context?.Source switch + { + ModelBindingData binding => await ConvertFromBindingDataAsync(context, binding), + _ => ConversionResult.Unhandled(), + }; + } + + private async ValueTask ConvertFromBindingDataAsync(ConverterContext context, ModelBindingData modelBindingData) + { + if (!IsCosmosExtension(modelBindingData)) + { + return ConversionResult.Unhandled(); + } + + try + { + var cosmosAttribute = GetBindingDataContent(modelBindingData); + object result = await ToTargetTypeAsync(context.TargetType, cosmosAttribute); + + if (result is not null) + { + return ConversionResult.Success(result); + } + } + catch (Exception ex) + { + return ConversionResult.Failed(ex); + } + + return ConversionResult.Unhandled(); + } + + private bool IsCosmosExtension(ModelBindingData bindingData) + { + if (bindingData?.Source is not Constants.CosmosExtensionName) + { + _logger.LogTrace("Source '{source}' is not supported by {converter}", bindingData?.Source, nameof(CosmosDBConverter)); + return false; + } + + return true; + } + + private CosmosDBInputAttribute GetBindingDataContent(ModelBindingData bindingData) + { + return bindingData?.ContentType switch + { + Constants.JsonContentType => bindingData.Content.ToObjectFromJson(), + _ => throw new NotSupportedException($"Unexpected content-type. Currently only '{Constants.JsonContentType}' is supported.") + }; + } + + private async Task ToTargetTypeAsync(Type targetType, CosmosDBInputAttribute cosmosAttribute) => targetType switch + { + Type _ when targetType == typeof(CosmosClient) => CreateCosmosClient(cosmosAttribute), + Type _ when targetType == typeof(Database) => CreateCosmosClient(cosmosAttribute), + Type _ when targetType == typeof(Container) => CreateCosmosClient(cosmosAttribute), + _ => await CreateTargetObjectAsync(targetType, cosmosAttribute) + }; + + private async Task CreateTargetObjectAsync(Type targetType, CosmosDBInputAttribute cosmosAttribute) + { + MethodInfo createPOCOMethod; + + if (targetType.GenericTypeArguments.Any()) + { + targetType = targetType.GenericTypeArguments.FirstOrDefault(); + + createPOCOMethod = GetType() + .GetMethod(nameof(CreatePOCOCollectionAsync), BindingFlags.Instance | BindingFlags.NonPublic) + .MakeGenericMethod(targetType); + } + else + { + createPOCOMethod = GetType() + .GetMethod(nameof(CreatePOCOAsync), BindingFlags.Instance | BindingFlags.NonPublic) + .MakeGenericMethod(targetType); + } + + + var container = CreateCosmosClient(cosmosAttribute) as Container; + + if (container is null) + { + throw new InvalidOperationException($"Unable to create Cosmos container client for '{cosmosAttribute.ContainerName}'."); + } + + return await (Task)createPOCOMethod.Invoke(this, new object[] { container, cosmosAttribute }); + } + + private async Task CreatePOCOAsync(Container container, CosmosDBInputAttribute cosmosAttribute) + { + if (String.IsNullOrEmpty(cosmosAttribute.Id) || String.IsNullOrEmpty(cosmosAttribute.PartitionKey)) + { + throw new InvalidOperationException("The 'Id' and 'PartitionKey' properties of a CosmosDB single-item input binding cannot be null or empty."); + } + + ItemResponse item = await container.ReadItemAsync(cosmosAttribute.Id, new PartitionKey(cosmosAttribute.PartitionKey)); + + if (item is null || item?.StatusCode is not System.Net.HttpStatusCode.OK || item.Resource is null) + { + throw new InvalidOperationException($"Unable to retrieve document with ID '{cosmosAttribute.Id}' and PartitionKey '{cosmosAttribute.PartitionKey}'"); + } + + return item.Resource; + } + + private async Task CreatePOCOCollectionAsync(Container container, CosmosDBInputAttribute cosmosAttribute) + { + QueryDefinition queryDefinition = null!; + if (!String.IsNullOrEmpty(cosmosAttribute.SqlQuery)) + { + queryDefinition = new QueryDefinition(cosmosAttribute.SqlQuery); + if (cosmosAttribute.SqlQueryParameters?.Count() > 0) + { + foreach (var parameter in cosmosAttribute.SqlQueryParameters) + { + queryDefinition.WithParameter(parameter.Key, parameter.Value.ToString()); + } + } + } + + PartitionKey partitionKey = String.IsNullOrEmpty(cosmosAttribute.PartitionKey) + ? PartitionKey.None + : new PartitionKey(cosmosAttribute.PartitionKey); + + // Workaround until bug in Cosmos SDK is fixed + // Currently pending release: https://github.com/Azure/azure-cosmos-dotnet-v3/commit/d6e04a92f8778565eb1d1452738d37c7faf3c47a + QueryRequestOptions queryRequestOptions = new(); + if (partitionKey != PartitionKey.None) + { + queryRequestOptions = new() { PartitionKey = partitionKey }; + } + + using (var iterator = container.GetItemQueryIterator(queryDefinition: queryDefinition, requestOptions: queryRequestOptions)) + { + if (iterator is null) + { + throw new InvalidOperationException($"Unable to retrieve documents for container '{container.Id}'."); + } + + return await ExtractCosmosDocumentsAsync(iterator); + } + } + + private async Task> ExtractCosmosDocumentsAsync(FeedIterator iterator) + { + var documentList = new List(); + while (iterator.HasMoreResults) + { + FeedResponse response = await iterator.ReadNextAsync(); + documentList.AddRange(response.Resource); + } + return documentList; + } + + private T CreateCosmosClient(CosmosDBInputAttribute cosmosAttribute) + { + var cosmosDBOptions = _cosmosOptions.Get(cosmosAttribute?.Connection); + CosmosClient cosmosClient = cosmosDBOptions.GetClient(cosmosAttribute?.Connection!, cosmosAttribute?.PreferredLocations!); + + Type targetType = typeof(T); + object cosmosReference = targetType switch + { + Type _ when targetType == typeof(Database) => cosmosClient.GetDatabase(cosmosAttribute?.DatabaseName), + Type _ when targetType == typeof(Container) => cosmosClient.GetContainer(cosmosAttribute?.DatabaseName, cosmosAttribute?.ContainerName), + _ => cosmosClient + }; + + return (T)cosmosReference; + } + } +} diff --git a/extensions/Worker.Extensions.CosmosDB/src/CosmosDBInputAttribute.cs b/extensions/Worker.Extensions.CosmosDB/src/CosmosDBInputAttribute.cs index 895ec9e9b..30e35a713 100644 --- a/extensions/Worker.Extensions.CosmosDB/src/CosmosDBInputAttribute.cs +++ b/extensions/Worker.Extensions.CosmosDB/src/CosmosDBInputAttribute.cs @@ -1,10 +1,12 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. See License.txt in the project root for license information. +using System.Collections.Generic; using Microsoft.Azure.Functions.Worker.Extensions.Abstractions; namespace Microsoft.Azure.Functions.Worker { + [SupportsDeferredBinding] public sealed class CosmosDBInputAttribute : InputBindingAttribute { /// @@ -44,7 +46,7 @@ public CosmosDBInputAttribute(string databaseName, string containerName) /// /// Optional. - /// When specified on an output binding and is true, defines the partition key + /// When specified on an output binding and is true, defines the partition key /// path for the created container. /// When specified on an input binding, specifies the partition key value for the lookup. /// May include binding parameters. @@ -67,5 +69,11 @@ public CosmosDBInputAttribute(string databaseName, string containerName) /// PreferredLocations = "East US,South Central US,North Europe" /// public string? PreferredLocations { get; set; } + + /// + /// Optional. + /// Defines the parameters to be used with the SqlQuery + /// + public IDictionary? SqlQueryParameters { get; set; } } } diff --git a/extensions/Worker.Extensions.CosmosDB/src/CosmosExtensionStartup.cs b/extensions/Worker.Extensions.CosmosDB/src/CosmosExtensionStartup.cs new file mode 100644 index 000000000..d67838cf7 --- /dev/null +++ b/extensions/Worker.Extensions.CosmosDB/src/CosmosExtensionStartup.cs @@ -0,0 +1,34 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Core; +using Microsoft.Extensions.Azure; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +[assembly: WorkerExtensionStartup(typeof(CosmosExtensionStartup))] + +namespace Microsoft.Azure.Functions.Worker +{ + public class CosmosExtensionStartup : WorkerExtensionStartup + { + public override void Configure(IFunctionsWorkerApplicationBuilder applicationBuilder) + { + if (applicationBuilder == null) + { + throw new ArgumentNullException(nameof(applicationBuilder)); + } + + applicationBuilder.Services.AddAzureClientsCore(); // Adds AzureComponentFactory + applicationBuilder.Services.AddOptions(); + applicationBuilder.Services.AddSingleton, CosmosDBBindingOptionsSetup>(); + + applicationBuilder.Services.Configure((workerOption) => + { + workerOption.InputConverters.RegisterAt(0); + }); + } + } +} diff --git a/extensions/Worker.Extensions.CosmosDB/src/Properties/AssemblyInfo.cs b/extensions/Worker.Extensions.CosmosDB/src/Properties/AssemblyInfo.cs index 2d9119cea..fce96c7ce 100644 --- a/extensions/Worker.Extensions.CosmosDB/src/Properties/AssemblyInfo.cs +++ b/extensions/Worker.Extensions.CosmosDB/src/Properties/AssemblyInfo.cs @@ -1,6 +1,9 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. See License.txt in the project root for license information. +using System.Runtime.CompilerServices; using Microsoft.Azure.Functions.Worker.Extensions.Abstractions; -[assembly: ExtensionInformation("Microsoft.Azure.WebJobs.Extensions.CosmosDB", "4.0.0")] +[assembly: ExtensionInformation("Microsoft.Azure.WebJobs.Extensions.CosmosDB", "4.2.0")] +[assembly: InternalsVisibleTo("Microsoft.Azure.Functions.WorkerExtension.Tests, PublicKey=00240000048000009400000006020000002400005253413100040000010001005148be37ac1d9f58bd40a2e472c9d380d635b6048278f7d47480b08c928858f0f7fe17a6e4ce98da0e7a7f0b8c308aecd9e9b02d7e9680a5b5b75ac7773cec096fbbc64aebd429e77cb5f89a569a79b28e9c76426783f624b6b70327eb37341eb498a2c3918af97c4860db6cdca4732787150841e395a29cfacb959c1fd971c1")] +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c547cac37abd99c8db225ef2f6c8a3602f3b3606cc9891605d02baa56104f4cfc0734aa39b93bf7852f7d9266654753cc297e7d2edfe0bac1cdcf9f717241550e0a7b191195b7667bb4f64bcb8e2121380fd1d9d46ad2d92d2d15605093924cceaf74c4861eff62abf69b9291ed0a340e113be11e6a7d3113e92484cf7045cc7")] \ No newline at end of file diff --git a/extensions/Worker.Extensions.CosmosDB/src/Utilities.cs b/extensions/Worker.Extensions.CosmosDB/src/Utilities.cs new file mode 100644 index 000000000..b7794f2a8 --- /dev/null +++ b/extensions/Worker.Extensions.CosmosDB/src/Utilities.cs @@ -0,0 +1,25 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.Azure.Functions.Worker.Extensions.CosmosDB +{ + internal class Utilities + { + internal static IReadOnlyList ParsePreferredLocations(string preferredRegions) + { + if (string.IsNullOrEmpty(preferredRegions)) + { + return Enumerable.Empty().ToList(); + } + + return preferredRegions + .Split(',') + .Select((region) => region.Trim()) + .Where((region) => !string.IsNullOrEmpty(region)) + .ToList(); + } + } +} \ No newline at end of file diff --git a/extensions/Worker.Extensions.CosmosDB/src/Worker.Extensions.CosmosDB.csproj b/extensions/Worker.Extensions.CosmosDB/src/Worker.Extensions.CosmosDB.csproj index 72e58bce2..97a840aff 100644 --- a/extensions/Worker.Extensions.CosmosDB/src/Worker.Extensions.CosmosDB.csproj +++ b/extensions/Worker.Extensions.CosmosDB/src/Worker.Extensions.CosmosDB.csproj @@ -6,7 +6,8 @@ Azure Cosmos DB extensions for .NET isolated functions - 4.0.1 + 4.1.0 + -preview1 false @@ -14,8 +15,19 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/extensions/Worker.Extensions.Storage.Blobs/src/BlobStorageConverter.cs b/extensions/Worker.Extensions.Storage.Blobs/src/BlobStorageConverter.cs index f8d34b4cb..280ccc22e 100644 --- a/extensions/Worker.Extensions.Storage.Blobs/src/BlobStorageConverter.cs +++ b/extensions/Worker.Extensions.Storage.Blobs/src/BlobStorageConverter.cs @@ -12,6 +12,7 @@ using Azure.Storage.Blobs.Specialized; using Microsoft.Azure.Functions.Worker.Converters; using Microsoft.Azure.Functions.Worker.Core; +using Microsoft.Azure.Functions.Worker.Extensions.Storage.Blobs; using Microsoft.Extensions.Options; using Microsoft.Extensions.Logging; @@ -24,7 +25,6 @@ internal class BlobStorageConverter : IInputConverter { private readonly IOptions _workerOptions; private readonly IOptionsSnapshot _blobOptions; - private readonly ILogger _logger; public BlobStorageConverter(IOptions workerOptions, IOptionsSnapshot blobOptions, ILogger logger) diff --git a/extensions/Worker.Extensions.Storage.Blobs/src/Config/BlobStorageBindingOptionsSetup.cs b/extensions/Worker.Extensions.Storage.Blobs/src/Config/BlobStorageBindingOptionsSetup.cs index b4c538042..654653702 100644 --- a/extensions/Worker.Extensions.Storage.Blobs/src/Config/BlobStorageBindingOptionsSetup.cs +++ b/extensions/Worker.Extensions.Storage.Blobs/src/Config/BlobStorageBindingOptionsSetup.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Options; using Microsoft.Azure.Functions.Worker.Extensions; +using Microsoft.Azure.Functions.Worker.Extensions.Storage.Blobs; using System.Globalization; namespace Microsoft.Azure.Functions.Worker diff --git a/extensions/Worker.Extensions.Storage.Blobs/src/Constants.cs b/extensions/Worker.Extensions.Storage.Blobs/src/Constants.cs index 95b378704..5320bff21 100644 --- a/extensions/Worker.Extensions.Storage.Blobs/src/Constants.cs +++ b/extensions/Worker.Extensions.Storage.Blobs/src/Constants.cs @@ -1,7 +1,7 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. See License.txt in the project root for license information. -namespace Microsoft.Azure.Functions.Worker +namespace Microsoft.Azure.Functions.Worker.Extensions.Storage.Blobs { internal static class Constants { diff --git a/samples/WorkerBindingSamples/Book.cs b/samples/WorkerBindingSamples/Blob/Book.cs similarity index 100% rename from samples/WorkerBindingSamples/Book.cs rename to samples/WorkerBindingSamples/Blob/Book.cs diff --git a/samples/WorkerBindingSamples/Cosmos/CosmosInputBindingFunctions.cs b/samples/WorkerBindingSamples/Cosmos/CosmosInputBindingFunctions.cs new file mode 100644 index 000000000..437c08bc7 --- /dev/null +++ b/samples/WorkerBindingSamples/Cosmos/CosmosInputBindingFunctions.cs @@ -0,0 +1,221 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System.Net; +using Microsoft.Azure.Cosmos; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; + +namespace SampleApp +{ + public class CosmosInputBindingFunctions + { + private readonly ILogger _logger; + + public CosmosInputBindingFunctions(ILogger logger) + { + _logger = logger; + } + + [Function(nameof(DocsByUsingCosmosClient))] + public async Task DocsByUsingCosmosClient( + [HttpTrigger(AuthorizationLevel.Function, "get", "post")] HttpRequestData req, + [CosmosDBInput("", "", Connection = "CosmosDBConnection")] CosmosClient client) + { + _logger.LogInformation("C# HTTP trigger function processed a request."); + + var iterator = client.GetContainer("ToDoItems", "Items") + .GetItemQueryIterator("SELECT * FROM c"); + + while (iterator.HasMoreResults) + { + var documents = await iterator.ReadNextAsync(); + foreach (dynamic d in documents) + { + _logger.LogInformation((string)d.description); + } + } + + return req.CreateResponse(HttpStatusCode.OK); + } + + [Function(nameof(DocsByUsingDatabaseClient))] + public async Task DocsByUsingDatabaseClient( + [HttpTrigger(AuthorizationLevel.Function, "get", "post")] HttpRequestData req, + [CosmosDBInput("ToDoItems", "", Connection = "CosmosDBConnection")] Database database) + { + _logger.LogInformation("C# HTTP trigger function processed a request."); + + var iterator = database.GetContainerQueryIterator("SELECT * FROM c"); + + while (iterator.HasMoreResults) + { + var containers = await iterator.ReadNextAsync(); + foreach (dynamic c in containers) + { + _logger.LogInformation((string)c.id); + } + } + + return req.CreateResponse(HttpStatusCode.OK); + } + + [Function(nameof(DocsByUsingContainerClient))] + public async Task DocsByUsingContainerClient( + [HttpTrigger(AuthorizationLevel.Function, "get", "post")] HttpRequestData req, + [CosmosDBInput("ToDoItems", "Items", Connection = "CosmosDBConnection")] Container container) + { + _logger.LogInformation("C# HTTP trigger function processed a request."); + + var iterator = container.GetItemQueryIterator("SELECT * FROM c"); + + while (iterator.HasMoreResults) + { + var documents = await iterator.ReadNextAsync(); + foreach (dynamic d in documents) + { + _logger.LogInformation("Found ToDo item, Description={desc}", (string)d.description); + } + } + + return req.CreateResponse(HttpStatusCode.OK); + } + + [Function(nameof(DocByIdFromQueryString))] + public HttpResponseData DocByIdFromQueryString( + [HttpTrigger(AuthorizationLevel.Function, "get", "post")] HttpRequestData req, + [CosmosDBInput( + databaseName: "ToDoItems", + containerName: "Items", + Connection = "CosmosDBConnection", + Id = "{Query.id}", + PartitionKey = "{Query.partitionKey}")] ToDoItem toDoItem) + { + _logger.LogInformation("C# HTTP trigger function processed a request."); + + if (toDoItem == null) + { + _logger.LogInformation("ToDo item not found"); + } + else + { + _logger.LogInformation("Found ToDo item, Description={desc}", toDoItem.Description); + } + + return req.CreateResponse(HttpStatusCode.OK); + } + + [Function(nameof(DocByIdFromRouteData))] + public HttpResponseData DocByIdFromRouteData( + [HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = "todoitems/{partitionKey}/{id}")] HttpRequestData req, + [CosmosDBInput( + databaseName: "ToDoItems", + containerName: "Items", + Connection = "CosmosDBConnection", + Id = "{id}", + PartitionKey = "{partitionKey}")] ToDoItem toDoItem) + { + _logger.LogInformation("C# HTTP trigger function processed a request."); + + if (toDoItem == null) + { + _logger.LogInformation($"ToDo item not found"); + } + else + { + _logger.LogInformation($"Found ToDo item, Description={toDoItem.Description}"); + } + + return req.CreateResponse(HttpStatusCode.OK); + } + + [Function(nameof(DocByIdFromRouteDataUsingSqlQuery))] + public HttpResponseData DocByIdFromRouteDataUsingSqlQuery( + [HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = "todoitems2/{id}")] HttpRequestData req, + [CosmosDBInput( + databaseName: "ToDoItems", + containerName: "Items", + Connection = "CosmosDBConnection", + SqlQuery = "SELECT * FROM ToDoItems t where t.id = {id}")] + IEnumerable toDoItems) + { + _logger.LogInformation("C# HTTP trigger function processed a request."); + + foreach (ToDoItem toDoItem in toDoItems) + { + _logger.LogInformation(toDoItem.Description); + } + + return req.CreateResponse(HttpStatusCode.OK); + } + + [Function(nameof(DocByIdFromQueryStringUsingSqlQuery))] + public HttpResponseData DocByIdFromQueryStringUsingSqlQuery( + [HttpTrigger(AuthorizationLevel.Function, "get", "post")] HttpRequestData req, + [CosmosDBInput( + databaseName: "ToDoItems", + containerName: "Items", + Connection = "CosmosDBConnection", + SqlQuery = "SELECT * FROM ToDoItems t where t.id = {id}")] + IEnumerable toDoItems) + { + _logger.LogInformation("C# HTTP trigger function processed a request."); + + foreach (ToDoItem toDoItem in toDoItems) + { + _logger.LogInformation(toDoItem.Description); + } + + return req.CreateResponse(HttpStatusCode.OK); + } + + [Function(nameof(DocsBySqlQuery))] + public HttpResponseData DocsBySqlQuery( + [HttpTrigger(AuthorizationLevel.Function, "get", "post")] HttpRequestData req, + [CosmosDBInput( + databaseName: "ToDoItems", + containerName: "Items", + Connection = "CosmosDBConnection", + SqlQuery = "SELECT * FROM ToDoItems t WHERE CONTAINS(t.description, 'cat')")] IEnumerable toDoItems) + { + _logger.LogInformation("C# HTTP trigger function processed a request."); + + foreach (ToDoItem toDoItem in toDoItems) + { + _logger.LogInformation(toDoItem.Description); + } + + return req.CreateResponse(HttpStatusCode.OK); + } + + [Function(nameof(DocByIdFromJSON))] + public void DocByIdFromJSON( + [QueueTrigger("todoqueueforlookup")] ToDoItemLookup toDoItemLookup, + [CosmosDBInput( + databaseName: "ToDoItems", + containerName: "Items", + Connection = "CosmosDBConnection", + Id = "{ToDoItemId}", + PartitionKey = "{ToDoItemPartitionKeyValue}")] ToDoItem toDoItem) + { + _logger.LogInformation($"C# Queue trigger function processed Id={toDoItemLookup?.ToDoItemId} Key={toDoItemLookup?.ToDoItemPartitionKeyValue}"); + + if (toDoItem == null) + { + _logger.LogInformation($"ToDo item not found"); + } + else + { + _logger.LogInformation($"Found ToDo item, Description={toDoItem.Description}"); + } + } + + public class ToDoItemLookup + { + public string? ToDoItemId { get; set; } + + public string? ToDoItemPartitionKeyValue { get; set; } + } + } +} diff --git a/samples/WorkerBindingSamples/Cosmos/CosmosTriggerFunction.cs b/samples/WorkerBindingSamples/Cosmos/CosmosTriggerFunction.cs new file mode 100644 index 000000000..f7e11f6cd --- /dev/null +++ b/samples/WorkerBindingSamples/Cosmos/CosmosTriggerFunction.cs @@ -0,0 +1,33 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using Microsoft.Azure.Functions.Worker; +using Microsoft.Extensions.Logging; + +namespace SampleApp +{ + // We cannot use SDK-type bindings with the Cosmos trigger binding. There is no way for + // the CosmosDB SDK to let us know the ID of the document that triggered the function; + // therefore we cannot create a client that is able to pull the triggering document. + public static class CosmosTriggerFunction + { + [Function(nameof(CosmosTriggerFunction))] + public static void Run([CosmosDBTrigger( + databaseName: "ToDoItems", + containerName:"TriggerItems", + Connection = "CosmosDBConnection", + CreateLeaseContainerIfNotExists = true)] IReadOnlyList todoItems, + FunctionContext context) + { + var logger = context.GetLogger(nameof(CosmosTriggerFunction)); + + if (todoItems is not null && todoItems.Any()) + { + foreach (var doc in todoItems) + { + logger.LogInformation("ToDoItem: {desc}", doc.Description); + } + } + } + } +} diff --git a/samples/WorkerBindingSamples/Cosmos/ToDoItem.cs b/samples/WorkerBindingSamples/Cosmos/ToDoItem.cs new file mode 100644 index 000000000..86027c8f2 --- /dev/null +++ b/samples/WorkerBindingSamples/Cosmos/ToDoItem.cs @@ -0,0 +1,11 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +namespace SampleApp +{ + public class ToDoItem + { + public string? Id { get; set; } + public string? Description { get; set; } + } +} diff --git a/samples/WorkerBindingSamples/WorkerBindingSamples.csproj b/samples/WorkerBindingSamples/WorkerBindingSamples.csproj index 7b54a7565..66fe3d12a 100644 --- a/samples/WorkerBindingSamples/WorkerBindingSamples.csproj +++ b/samples/WorkerBindingSamples/WorkerBindingSamples.csproj @@ -14,6 +14,9 @@ + + + PreserveNewest diff --git a/test/WorkerExtensionTests/Blob/BlobStorageConverterTests.cs b/test/WorkerExtensionTests/Blob/BlobStorageConverterTests.cs index 49ce572d3..903d7a7f1 100644 --- a/test/WorkerExtensionTests/Blob/BlobStorageConverterTests.cs +++ b/test/WorkerExtensionTests/Blob/BlobStorageConverterTests.cs @@ -22,6 +22,8 @@ using Moq; using Xunit; +using Constants = Microsoft.Azure.Functions.Worker.Extensions.Storage.Blobs.Constants; + namespace Microsoft.Azure.Functions.WorkerExtension.Tests { public class BlobStorageConverterTests diff --git a/test/WorkerExtensionTests/Cosmos/CosmosDBConverterTests.cs b/test/WorkerExtensionTests/Cosmos/CosmosDBConverterTests.cs new file mode 100644 index 000000000..e64399b97 --- /dev/null +++ b/test/WorkerExtensionTests/Cosmos/CosmosDBConverterTests.cs @@ -0,0 +1,441 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Google.Protobuf; +using Microsoft.Azure.Cosmos; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Converters; +using Microsoft.Azure.Functions.Worker.Grpc.Messages; +using Microsoft.Azure.Functions.Worker.Tests.Converters; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using Xunit; + +namespace Microsoft.Azure.Functions.WorkerExtension.Tests +{ + public class CosmosDBConverterTests + { + private CosmosDBConverter _cosmosDBConverter; + private Mock _mockCosmosClient; + + public CosmosDBConverterTests() + { + var host = new HostBuilder().ConfigureFunctionsWorkerDefaults((WorkerOptions options) => { }).Build(); + var logger = host.Services.GetService>(); + + _mockCosmosClient = new Mock(); + + var mockCosmosOptions = new Mock(); + mockCosmosOptions + .Setup(m => m.GetClient(It.IsAny(), It.IsAny())) + .Returns(_mockCosmosClient.Object); + + var mockCosmosOptionsSnapshot = new Mock>(); + mockCosmosOptionsSnapshot + .Setup(m => m.Get(It.IsAny())) + .Returns(mockCosmosOptions.Object); + + _cosmosDBConverter = new CosmosDBConverter(mockCosmosOptionsSnapshot.Object, logger); + } + + [Fact] + public async Task ConvertAsync_ValidModelBindingData_CosmosClient_ReturnsSuccess() + { + var grpcModelBindingData = GetTestGrpcModelBindingData(GetTestBinaryData()); + var context = new TestConverterContext(typeof(CosmosClient), grpcModelBindingData); + + _mockCosmosClient.Setup(m => m.Endpoint).Returns(new Uri("https://www.example.com")); + + var conversionResult = await _cosmosDBConverter.ConvertAsync(context); + + var expectedClient = (CosmosClient)conversionResult.Value; + Assert.Equal(ConversionStatus.Succeeded, conversionResult.Status); + Assert.Equal(new Uri("https://www.example.com"), expectedClient.Endpoint); + } + + [Fact] + public async Task ConvertAsync_ValidModelBindingData_DatabaseClient_ReturnsSuccess() + { + var grpcModelBindingData = GetTestGrpcModelBindingData(GetTestBinaryData()); + var context = new TestConverterContext(typeof(Database), grpcModelBindingData); + + var _mockDatabase = new Mock(); + _mockDatabase.Setup(m => m .Id).Returns("testId"); + + _mockCosmosClient + .Setup(m => m.GetDatabase(It.IsAny())) + .Returns(_mockDatabase.Object); + + var conversionResult = await _cosmosDBConverter.ConvertAsync(context); + + var expectedDatabase = (Database)conversionResult.Value; + Assert.Equal(ConversionStatus.Succeeded, conversionResult.Status); + Assert.Equal("testId", expectedDatabase.Id); + } + + [Fact] + public async Task ConvertAsync_ValidModelBindingData_ContainerClient_ReturnsSuccess() + { + var grpcModelBindingData = GetTestGrpcModelBindingData(GetTestBinaryData()); + var context = new TestConverterContext(typeof(Container), grpcModelBindingData); + + var mockContainer = new Mock(); + mockContainer.Setup(m => m .Id).Returns("testId"); + + _mockCosmosClient + .Setup(m => m.GetContainer(It.IsAny(), It.IsAny())) + .Returns(mockContainer.Object); + + var conversionResult = await _cosmosDBConverter.ConvertAsync(context); + + var expectedContainer = (Container)conversionResult.Value; + Assert.Equal(ConversionStatus.Succeeded, conversionResult.Status); + Assert.Equal("testId", expectedContainer.Id); + } + + [Fact] + public async Task ConvertAsync_ValidModelBindingData_SinglePOCO_ReturnsSuccess() + { + var grpcModelBindingData = GetTestGrpcModelBindingData(GetTestBinaryData(id: "1", partitionKey: "1")); + var context = new TestConverterContext(typeof(ToDoItem), grpcModelBindingData); + + var expectedToDoItem = new ToDoItem() { Id = "1", Description = "Take out the rubbish"}; + + var mockResponse = new Mock>(); + mockResponse.Setup(x => x.StatusCode).Returns(System.Net.HttpStatusCode.OK); + mockResponse.Setup(x => x.Resource).Returns(expectedToDoItem); + + var mockContainer = new Mock(); + mockContainer + .Setup(m => m.ReadItemAsync(It.IsAny(), It.IsAny(), null, CancellationToken.None)) + .ReturnsAsync(mockResponse.Object); + + _mockCosmosClient + .Setup(m => m.GetContainer(It.IsAny(), It.IsAny())) + .Returns(mockContainer.Object); + + var conversionResult = await _cosmosDBConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Succeeded, conversionResult.Status); + Assert.Equal(expectedToDoItem, conversionResult.Value); + } + + [Fact] + public async Task ConvertAsync_ValidModelBindingData_SinglePOCO_WithoutId_ReturnsFailed() + { + var grpcModelBindingData = GetTestGrpcModelBindingData(GetTestBinaryData(partitionKey: "1")); + var context = new TestConverterContext(typeof(ToDoItem), grpcModelBindingData); + + var mockContainer = new Mock(); + + _mockCosmosClient + .Setup(m => m.GetContainer(It.IsAny(), It.IsAny())) + .Returns(mockContainer.Object); + + var conversionResult = await _cosmosDBConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Failed, conversionResult.Status); + Assert.Equal("The 'Id' and 'PartitionKey' properties of a CosmosDB single-item input binding cannot be null or empty.", conversionResult.Error.Message); + } + + [Fact] + public async Task ConvertAsync_ValidModelBindingData_SinglePOCO_WithoutPK_ReturnsFailed() + { + var grpcModelBindingData = GetTestGrpcModelBindingData(GetTestBinaryData(id: "1")); + var context = new TestConverterContext(typeof(ToDoItem), grpcModelBindingData); + + var mockContainer = new Mock(); + + _mockCosmosClient + .Setup(m => m.GetContainer(It.IsAny(), It.IsAny())) + .Returns(mockContainer.Object); + + var conversionResult = await _cosmosDBConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Failed, conversionResult.Status); + Assert.Equal("The 'Id' and 'PartitionKey' properties of a CosmosDB single-item input binding cannot be null or empty.", conversionResult.Error.Message); + } + + [Fact] + public async Task ConvertAsync_ValidModelBindingData_IEnumerablePOCO_ReturnsSuccess() + { + var query = "SELECT * FROM TodoItems t WHERE t.id = @id"; + var queryParams = @"{""@id"":""1""}"; + var grpcModelBindingData = GetTestGrpcModelBindingData(GetTestBinaryData(query: query, queryParams: queryParams)); + var context = new TestConverterContext(typeof(IEnumerable), grpcModelBindingData); + + var todo1 = new ToDoItem() { Id = "1", Description = "Take out the rubbish"}; + var todo2 = new ToDoItem() { Id = "2", Description = "Write unit tests for cosmos converter"}; + var expectedList = new List(){ todo1, todo2 }; + + var mockFeedResponse = new Mock>(); + mockFeedResponse.Setup(x => x.StatusCode).Returns(System.Net.HttpStatusCode.OK); + mockFeedResponse.Setup(x => x.Resource).Returns(expectedList); + + var mockFeedIterator = new Mock>(); + mockFeedIterator.SetupSequence(x => x.HasMoreResults).Returns(true).Returns(false); + mockFeedIterator.Setup(x => x.ReadNextAsync(default)).ReturnsAsync(mockFeedResponse.Object); + + var mockContainer = new Mock(); + mockContainer + .Setup(m => m.GetItemQueryIterator(It.IsAny(), default, It.IsAny())) + .Returns(mockFeedIterator.Object); + + _mockCosmosClient + .Setup(m => m.GetContainer(It.IsAny(), It.IsAny())) + .Returns(mockContainer.Object); + + var conversionResult = await _cosmosDBConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Succeeded, conversionResult.Status); + Assert.Equal(expectedList, conversionResult.Value); + } + + [Fact] + public async Task ConvertAsync_Container_NullFeedIterator_ReturnsFailed() + { + var grpcModelBindingData = GetTestGrpcModelBindingData(GetTestBinaryData()); + var context = new TestConverterContext(typeof(IEnumerable), grpcModelBindingData); + + var mockContainer = new Mock(); + mockContainer.Setup(m => m .Id).Returns("testId"); + mockContainer + .Setup(m => m.GetItemQueryIterator(It.IsAny(), null, null)) + .Returns>(null); + + _mockCosmosClient + .Setup(m => m.GetContainer(It.IsAny(), It.IsAny())) + .Returns(mockContainer.Object); + + var conversionResult = await _cosmosDBConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Failed, conversionResult.Status); + Assert.Contains("Unable to retrieve documents for container 'testId'.", conversionResult.Error.Message); + } + + [Fact] + public async Task ConvertAsync_ContainerWithSqlQuery_NullFeedIterator_ReturnsFailed() + { + var query = "SELECT * FROM TodoItems t WHERE t.id = @id"; + var queryParams = @"{""@id"":""1""}"; + var grpcModelBindingData = GetTestGrpcModelBindingData(GetTestBinaryData(query: query, queryParams: queryParams)); + var context = new TestConverterContext(typeof(IEnumerable), grpcModelBindingData); + + var mockContainer = new Mock(); + mockContainer.Setup(m => m .Id).Returns("testId"); + mockContainer + .Setup(m => m.GetItemQueryIterator(It.IsAny(), null, null)) + .Returns((FeedIterator)null); + + _mockCosmosClient + .Setup(m => m.GetContainer(It.IsAny(), It.IsAny())) + .Returns(mockContainer.Object); + + var conversionResult = await _cosmosDBConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Failed, conversionResult.Status); + Assert.Contains($"Unable to retrieve documents for container 'testId'.", conversionResult.Error.Message); + } + + [Fact] + public async Task ConvertAsync_ContentSource_AsObject_ReturnsUnhandled() + { + var context = new TestConverterContext(typeof(CosmosClient), new Object()); + + var conversionResult = await _cosmosDBConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Unhandled, conversionResult.Status); + } + + [Fact] + public async Task ConvertAsync_ModelBindingData_Null_ReturnsUnhandled() + { + var context = new TestConverterContext(typeof(CosmosClient), null); + + var conversionResult = await _cosmosDBConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Unhandled, conversionResult.Status); + } + + [Fact] // Should we fail if the result is ever null? + public async Task ConvertAsync_ResultIsNull_ReturnsUnhandled() + { + var grpcModelBindingData = GetTestGrpcModelBindingData(GetTestBinaryData()); + var context = new TestConverterContext(typeof(Database), grpcModelBindingData); + + _mockCosmosClient + .Setup(m => m.GetDatabase(It.IsAny())) + .Returns(null); + + var conversionResult = await _cosmosDBConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Unhandled, conversionResult.Status); + } + + [Fact] + public async Task ConvertAsync_ThrowsException_ReturnsFailure() + { + var grpcModelBindingData = GetTestGrpcModelBindingData(GetTestBinaryData()); + var context = new TestConverterContext(typeof(Database), grpcModelBindingData); + + _mockCosmosClient + .Setup(m => m.GetDatabase(It.IsAny())) + .Throws(new Exception()); + + var conversionResult = await _cosmosDBConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Failed, conversionResult.Status); + } + + [Fact] + public async Task ConvertAsync_ItemResponse_ResourceIsNull_ThrowsException_ReturnsFailed() + { + var grpcModelBindingData = GetTestGrpcModelBindingData(GetTestBinaryData(id: "1", partitionKey: "1")); + var context = new TestConverterContext(typeof(ToDoItem), grpcModelBindingData); + + var mockResponse = new Mock>(); + mockResponse.Setup(x => x.StatusCode).Returns(System.Net.HttpStatusCode.OK); + mockResponse.Setup(x => x.Resource).Returns((ToDoItem)null); + + var mockContainer = new Mock(); + mockContainer + .Setup(m => m.ReadItemAsync(It.IsAny(), It.IsAny(), null, CancellationToken.None)) + .ReturnsAsync(mockResponse.Object); + + _mockCosmosClient + .Setup(m => m.GetContainer(It.IsAny(), It.IsAny())) + .Returns(mockContainer.Object); + + var conversionResult = await _cosmosDBConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Failed, conversionResult.Status); + Assert.Equal("Unable to retrieve document with ID '1' and PartitionKey '1'", conversionResult.Error.Message); + } + + [Fact] + public async Task ConvertAsync_ModelBindingDataSource_NotCosmosExtension_ReturnsUnhandled() + { + var grpcModelBindingData = GetTestGrpcModelBindingData(GetTestBinaryData(), source: "anotherExtensions"); + var context = new TestConverterContext(typeof(CosmosClient), grpcModelBindingData); + + var conversionResult = await _cosmosDBConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Unhandled, conversionResult.Status); + } + + [Fact] + public async Task ConvertAsync_ModelBindingDataContentType_Unsupported_ReturnsFailed() + { + var grpcModelBindingData = GetTestGrpcModelBindingData(GetTestBinaryData(), contentType: "binary"); + var context = new TestConverterContext(typeof(CosmosClient), grpcModelBindingData); + + var conversionResult = await _cosmosDBConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Failed, conversionResult.Status); + Assert.Equal("Unexpected content-type. Currently only 'application/json' is supported.", conversionResult.Error.Message); + } + + [Fact] + public async Task ConvertAsync_CosmosContainerIsNull_ThrowsException_ReturnsFailure() + { + object grpcModelBindingData = GetTestGrpcModelBindingData(GetTestBinaryData(container: "myContainer")); + var context = new TestConverterContext(typeof(ToDoItem), grpcModelBindingData); + + _mockCosmosClient + .Setup(m => m.GetContainer(It.IsAny(), It.IsAny())) + .Returns(null); + + var conversionResult = await _cosmosDBConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Failed, conversionResult.Status); + Assert.Equal($"Unable to create Cosmos container client for 'myContainer'.", conversionResult.Error.Message); + } + + [Fact] + public async Task ConvertAsync_POCO_ItemResponseNull_ThrowsException_ReturnsFailure() + { + object grpcModelBindingData = GetTestGrpcModelBindingData(GetTestBinaryData(id: "1", partitionKey: "1")); + var context = new TestConverterContext(typeof(ToDoItem), grpcModelBindingData); + + var mockContainer = new Mock(); + mockContainer + .Setup(m => m.ReadItemAsync(It.IsAny(), It.IsAny(), null, CancellationToken.None)) + .Returns(Task.FromResult>(null)); + + _mockCosmosClient + .Setup(m => m.GetContainer(It.IsAny(), It.IsAny())) + .Returns(mockContainer.Object); + + var conversionResult = await _cosmosDBConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Failed, conversionResult.Status); + Assert.Equal("Unable to retrieve document with ID '1' and PartitionKey '1'", conversionResult.Error.Message); + } + + [Fact] + public async Task ConvertAsync_POCO_IdProvided_StatusNot200_ThrowsException_ReturnsFailure() + { + object grpcModelBindingData = GetTestGrpcModelBindingData(GetTestBinaryData(id: "1", partitionKey: "1")); + var context = new TestConverterContext(typeof(ToDoItem), grpcModelBindingData); + + var mockResponse = new Mock>(); + mockResponse.Setup(x => x.StatusCode).Returns(System.Net.HttpStatusCode.InternalServerError); + + var mockContainer = new Mock(); + mockContainer + .Setup(m => m.ReadItemAsync(It.IsAny(), It.IsAny(), null, CancellationToken.None)) + .ReturnsAsync(mockResponse.Object); + + _mockCosmosClient + .Setup(m => m.GetContainer(It.IsAny(), It.IsAny())) + .Returns(mockContainer.Object); + + var conversionResult = await _cosmosDBConverter.ConvertAsync(context); + + Assert.Equal(ConversionStatus.Failed, conversionResult.Status); + Assert.Equal("Unable to retrieve document with ID '1' and PartitionKey '1'", conversionResult.Error.Message); + } + + private BinaryData GetTestBinaryData(string db = "testDb", string container = "testContainer", string connection = "cosmosConnection", string id = "", string partitionKey = "", string query = "", string location = "", string queryParams = "{}") + { + string jsonData = $@"{{ + ""DatabaseName"" : ""{db}"", + ""ContainerName"" : ""{container}"", + ""Connection"" : ""{connection}"", + ""Id"" : ""{id}"", + ""PartitionKey"" : ""{partitionKey}"", + ""SqlQuery"" : ""{query}"", + ""PreferredLocations"" : ""{location}"", + ""SqlQueryParameters"" : {queryParams} + }}"; + + return new BinaryData(jsonData); + } + + private GrpcModelBindingData GetTestGrpcModelBindingData(BinaryData content, string source = "CosmosDB", string contentType = "application/json") + { + var data = new ModelBindingData() + { + Version = "1.0", + Source = source, + Content = ByteString.CopyFrom(content), + ContentType = contentType + }; + + return new GrpcModelBindingData(data); + } + + public class ToDoItem + { + public string Id { get; set; } + public string Description { get; set; } + } + } +} \ No newline at end of file diff --git a/test/WorkerExtensionTests/Cosmos/UtilitiesTests.cs b/test/WorkerExtensionTests/Cosmos/UtilitiesTests.cs new file mode 100644 index 000000000..12ab19867 --- /dev/null +++ b/test/WorkerExtensionTests/Cosmos/UtilitiesTests.cs @@ -0,0 +1,46 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Linq; +using Microsoft.Azure.Functions.Worker.Extensions.CosmosDB; +using Xunit; + +namespace Microsoft.Azure.Functions.WorkerExtension.Tests +{ + public class UtilitiesTests + { + [Theory] + [InlineData("westeurope,eastus")] + [InlineData("westeurope , eastus")] + [InlineData(" westeurope, eastus ")] + public void ParsePreferredLocations_ValidInput_ReturnsList(string input) + { + // Arrange + var expectedList = new List(); + expectedList.Add("westeurope"); + expectedList.Add("eastus"); + + // Act + var result = Utilities.ParsePreferredLocations(input); + + //Assert + Assert.Equal(expectedList, result); + } + + [Theory] + [InlineData("")] + [InlineData(null)] + public void ParsePreferredLocations_InvalidInput_ReturnsEmptyList(string input) + { + // Arrange + var expectedList = Enumerable.Empty().ToList(); + + // Act + var result = Utilities.ParsePreferredLocations(input); + + //Assert + Assert.Equal(expectedList, result); + } + } +} \ No newline at end of file diff --git a/test/WorkerExtensionTests/WorkerExtensionTests.csproj b/test/WorkerExtensionTests/WorkerExtensionTests.csproj index fd04a5bea..8122e45b2 100644 --- a/test/WorkerExtensionTests/WorkerExtensionTests.csproj +++ b/test/WorkerExtensionTests/WorkerExtensionTests.csproj @@ -23,6 +23,7 @@ + From 97920d759473b39b22c51b060ec66c62605f6a7e Mon Sep 17 00:00:00 2001 From: Lilian Kasem Date: Tue, 21 Mar 2023 17:07:43 -0700 Subject: [PATCH 07/13] Add tests --- .../E2EApps/E2EApp/Cosmos/CosmosFunction.cs | 145 +++++++++++++++++- .../E2ETests/Cosmos/CosmosDBEndToEndTests.cs | 127 +++++++++++++++ .../E2ETests/Helpers/CosmosDBHelpers.cs | 5 + test/E2ETests/E2ETests/Helpers/HttpHelpers.cs | 2 +- test/E2ETests/E2ETests/HttpEndToEndTests.cs | 6 +- 5 files changed, 278 insertions(+), 7 deletions(-) diff --git a/test/E2ETests/E2EApps/E2EApp/Cosmos/CosmosFunction.cs b/test/E2ETests/E2EApps/E2EApp/Cosmos/CosmosFunction.cs index 888636bd4..4e23a3811 100644 --- a/test/E2ETests/E2EApps/E2EApp/Cosmos/CosmosFunction.cs +++ b/test/E2ETests/E2EApps/E2EApp/Cosmos/CosmosFunction.cs @@ -3,20 +3,30 @@ using System.Collections.Generic; using System.Linq; -using System.Text.Json.Serialization; +using System.Net; +using System.Threading.Tasks; +using Microsoft.Azure.Cosmos; +using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; namespace Microsoft.Azure.Functions.Worker.E2EApp { - public static class CosmosFunction + public class CosmosFunction { + private readonly ILogger _logger; + + public CosmosFunction(ILogger logger) + { + _logger = logger; + } + [Function(nameof(CosmosTrigger))] [CosmosDBOutput( databaseName: "%CosmosDb%", containerName: "%CosmosCollOut%", Connection = "CosmosConnection", CreateIfNotExists = true)] - public static object CosmosTrigger([CosmosDBTrigger( + public object CosmosTrigger([CosmosDBTrigger( databaseName: "%CosmosDb%", containerName: "%CosmosCollIn%", Connection = "CosmosConnection", @@ -36,6 +46,135 @@ public static object CosmosTrigger([CosmosDBTrigger( return null; } + [Function(nameof(DocsByUsingCosmosClient))] + public async Task DocsByUsingCosmosClient( + [HttpTrigger(AuthorizationLevel.Function, "get", "post")] HttpRequestData req, + [CosmosDBInput("", "", Connection = "CosmosConnection")] CosmosClient client) + { + var iterator = client.GetContainer("ItemDb", "ItemCollectionIn") + .GetItemQueryIterator("SELECT * FROM c"); + + var output = ""; + + while (iterator.HasMoreResults) + { + var documents = await iterator.ReadNextAsync(); + foreach (MyDocument d in documents) + { + output += $"{(string)d.Text}, "; + } + } + + var response = req.CreateResponse(HttpStatusCode.OK); + await response.WriteStringAsync(output); + return response; + } + + [Function(nameof(DocsByUsingDatabaseClient))] + public async Task DocsByUsingDatabaseClient( + [HttpTrigger(AuthorizationLevel.Function, "get", "post")] HttpRequestData req, + [CosmosDBInput("%CosmosDb%", "", Connection = "CosmosConnection")] Database database) + { + var iterator = database.GetContainerQueryIterator("SELECT * FROM c"); + + var output = ""; + + while (iterator.HasMoreResults) + { + var containers = await iterator.ReadNextAsync(); + foreach (dynamic c in containers) + { + output += $"{(string)c.id}, "; + } + } + + var response = req.CreateResponse(HttpStatusCode.OK); + await response.WriteStringAsync(output); + return response; + } + + [Function(nameof(DocsByUsingContainerClient))] + public async Task DocsByUsingContainerClient( + [HttpTrigger(AuthorizationLevel.Function, "get", "post")] HttpRequestData req, + [CosmosDBInput("%CosmosDb%", "%CosmosCollIn%", Connection = "CosmosConnection")] Container container) + { + var iterator = container.GetItemQueryIterator("SELECT * FROM c"); + + var output = ""; + + while (iterator.HasMoreResults) + { + var documents = await iterator.ReadNextAsync(); + foreach (MyDocument d in documents) + { + output += $"{(string)d.Text}, "; + } + } + + var response = req.CreateResponse(HttpStatusCode.OK); + await response.WriteStringAsync(output); + return response; + } + + [Function(nameof(DocByIdFromRouteData))] + public async Task DocByIdFromRouteData( + [HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = "docsbyroute/{partitionKey}/{id}")] HttpRequestData req, + [CosmosDBInput( + databaseName: "%CosmosDb%", + containerName: "%CosmosCollIn%", + Connection = "CosmosConnection", + Id = "{id}", + PartitionKey = "{partitionKey}")] MyDocument doc) + { + var response = req.CreateResponse(HttpStatusCode.OK); + await response.WriteStringAsync(doc.Text); + return response; + } + + [Function(nameof(DocByIdFromRouteDataUsingSqlQuery))] + public async Task DocByIdFromRouteDataUsingSqlQuery( + [HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = "docsbysql/{id}")] HttpRequestData req, + [CosmosDBInput( + databaseName: "%CosmosDb%", + containerName: "%CosmosCollIn%", + Connection = "CosmosConnection", + SqlQuery = "SELECT * FROM ToDoItems t where t.id = {id}")] + IEnumerable myDocs) + { + var output = ""; + + foreach (MyDocument doc in myDocs) + { + output += $"{(string)doc.Text}, "; + } + + var response = req.CreateResponse(HttpStatusCode.OK); + await response.WriteStringAsync(output); + return response; + } + + [Function(nameof(DocByIdFromQueryStringUsingSqlQuery))] + public async Task DocByIdFromQueryStringUsingSqlQuery( + [HttpTrigger(AuthorizationLevel.Function, "get", "post")] HttpRequestData req, + [CosmosDBInput( + databaseName: "%CosmosDb%", + containerName: "%CosmosCollIn%", + Connection = "CosmosConnection", + SqlQuery = "SELECT * FROM ToDoItems t where t.id = {id}")] + IEnumerable myDocs) + { + var output = ""; + + foreach (MyDocument doc in myDocs) + { + output += $"{(string)doc.Text}, "; + } + + var response = req.CreateResponse(HttpStatusCode.OK); + await response.WriteStringAsync(output); + return response; + } + public class MyDocument { public string Id { get; set; } diff --git a/test/E2ETests/E2ETests/Cosmos/CosmosDBEndToEndTests.cs b/test/E2ETests/E2ETests/Cosmos/CosmosDBEndToEndTests.cs index 71fb75934..eb98b2d66 100644 --- a/test/E2ETests/E2ETests/Cosmos/CosmosDBEndToEndTests.cs +++ b/test/E2ETests/E2ETests/Cosmos/CosmosDBEndToEndTests.cs @@ -2,6 +2,8 @@ // 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 Xunit; using Xunit.Abstractions; @@ -40,9 +42,134 @@ public async Task CosmosDBTriggerAndOutput_Succeeds() } } + [Theory] + [InlineData("DocsByUsingCosmosClient")] + [InlineData("DocsByUsingDatabaseClient")] + [InlineData("DocsByUsingContainerClient")] + public async Task CosmosInput_ClientBinding_Succeeds(string functionName) + { + string expectedDocId = Guid.NewGuid().ToString(); + try + { + //Setup + await CosmosDBHelpers.CreateDocument(expectedDocId); + + //Trigger + HttpResponseMessage response = await HttpHelpers.InvokeHttpTrigger(functionName); + string actualMessage = await response.Content.ReadAsStringAsync(); + + //Verify + HttpStatusCode expectedStatusCode = HttpStatusCode.OK; + + Assert.Equal(expectedStatusCode, response.StatusCode); + Assert.Contains(expectedDocId, actualMessage); + } + finally + { + //Clean up + await CosmosDBHelpers.DeleteTestDocuments(expectedDocId); + } + } + + [Fact] + public async Task CosmosInput_DocByIdFromRouteData_Succeeds() + { + string expectedDocId = Guid.NewGuid().ToString(); + string functionPath = $"docsbyroute/{expectedDocId}/{expectedDocId}"; + try + { + //Setup + MyDocument document = new() { Id = expectedDocId, Text = "hello world", Number = 1, Boolean = true }; + await CosmosDBHelpers.CreateDocument(document); + + //Trigger + HttpResponseMessage response = await HttpHelpers.InvokeHttpTrigger(functionPath); + string actualMessage = await response.Content.ReadAsStringAsync(); + + //Verify + HttpStatusCode expectedStatusCode = HttpStatusCode.OK; + + Assert.Equal(expectedStatusCode, response.StatusCode); + Assert.Contains("hello world", actualMessage); + } + finally + { + //Clean up + await CosmosDBHelpers.DeleteTestDocuments(expectedDocId); + } + } + + [Fact] + public async Task CosmosInput_DocByIdFromRouteDataUsingSqlQuery_Succeeds() + { + string expectedDocId = Guid.NewGuid().ToString(); + string functionPath = $"docsbysql/{expectedDocId}"; + try + { + //Setup + MyDocument document = new() { Id = expectedDocId, Text = "hello world", Number = 1, Boolean = true }; + await CosmosDBHelpers.CreateDocument(document); + + //Trigger + HttpResponseMessage response = await HttpHelpers.InvokeHttpTrigger(functionPath); + string actualMessage = await response.Content.ReadAsStringAsync(); + + //Verify + HttpStatusCode expectedStatusCode = HttpStatusCode.OK; + + Assert.Equal(expectedStatusCode, response.StatusCode); + Assert.Contains("hello world", actualMessage); + } + finally + { + //Clean up + await CosmosDBHelpers.DeleteTestDocuments(expectedDocId); + } + } + + [Fact] + public async Task CosmosInput_DocByIdFromQueryStringUsingSqlQuery_Succeeds() + { + string expectedDocId = Guid.NewGuid().ToString(); + string functionName = "DocByIdFromQueryStringUsingSqlQuery"; + string requestBody = @$"{{ ""id"": ""{expectedDocId}"" }}"; + try + { + //Setup + MyDocument document = new() { Id = expectedDocId, Text = "hello world", Number = 1, Boolean = true }; + await CosmosDBHelpers.CreateDocument(document); + + //Trigger + HttpResponseMessage response = await HttpHelpers.InvokeHttpTriggerWithBody(functionName, requestBody, "application/json"); + string actualMessage = await response.Content.ReadAsStringAsync(); + + //Verify + HttpStatusCode expectedStatusCode = HttpStatusCode.OK; + + Assert.Equal(expectedStatusCode, response.StatusCode); + Assert.Contains("hello world", actualMessage); + } + finally + { + //Clean up + await CosmosDBHelpers.DeleteTestDocuments(expectedDocId); + } + } + public void Dispose() { _disposeLog?.Dispose(); } + + public class MyDocument + { + public string Id { get; set; } + + public string Text { get; set; } + + public int Number { get; set; } + + public bool Boolean { get; set; } + } } } diff --git a/test/E2ETests/E2ETests/Helpers/CosmosDBHelpers.cs b/test/E2ETests/E2ETests/Helpers/CosmosDBHelpers.cs index 92d512d23..c40340c83 100644 --- a/test/E2ETests/E2ETests/Helpers/CosmosDBHelpers.cs +++ b/test/E2ETests/E2ETests/Helpers/CosmosDBHelpers.cs @@ -38,6 +38,11 @@ public async static Task CreateDocument(string docId) _ = await _docDbClient.CreateDocumentAsync(UriFactory.CreateDocumentCollectionUri(Constants.CosmosDB.DbName, Constants.CosmosDB.InputCollectionName), documentToTest); } + public async static Task CreateDocument(object documentToTest) + { + _ = await _docDbClient.CreateDocumentAsync(UriFactory.CreateDocumentCollectionUri(Constants.CosmosDB.DbName, Constants.CosmosDB.InputCollectionName), documentToTest); + } + // keep public async static Task ReadDocument(string docId) { diff --git a/test/E2ETests/E2ETests/Helpers/HttpHelpers.cs b/test/E2ETests/E2ETests/Helpers/HttpHelpers.cs index a7fd68ed6..d5f788498 100644 --- a/test/E2ETests/E2ETests/Helpers/HttpHelpers.cs +++ b/test/E2ETests/E2ETests/Helpers/HttpHelpers.cs @@ -19,7 +19,7 @@ public static async Task InvokeHttpTrigger(string functionN return await GetResponseMessage(request); } - public static async Task InvokeHttpTriggerWithBody(string functionName, string body, HttpStatusCode expectedStatusCode, string mediaType, int expectedCode = 0) + public static async Task InvokeHttpTriggerWithBody(string functionName, string body, string mediaType) { HttpRequestMessage request = GetTestRequest(functionName); request.Content = new StringContent(body); diff --git a/test/E2ETests/E2ETests/HttpEndToEndTests.cs b/test/E2ETests/E2ETests/HttpEndToEndTests.cs index 0d95a45c3..abd56ee71 100644 --- a/test/E2ETests/E2ETests/HttpEndToEndTests.cs +++ b/test/E2ETests/E2ETests/HttpEndToEndTests.cs @@ -47,7 +47,7 @@ public async Task HttpTriggerTests(string functionName, string queryString, Http [InlineData("HelloFromJsonBody", "{\"Name\": \"Bob\"}", "application/octet-stream", HttpStatusCode.OK, "Hello Bob")] public async Task HttpTriggerTestsMediaTypeDoNotMatter(string functionName, string body, string mediaType, HttpStatusCode expectedStatusCode, string expectedBody) { - HttpResponseMessage response = await HttpHelpers.InvokeHttpTriggerWithBody(functionName, body, expectedStatusCode, mediaType); + HttpResponseMessage response = await HttpHelpers.InvokeHttpTriggerWithBody(functionName, body, mediaType); string responseBody = await response.Content.ReadAsStringAsync(); Assert.Equal(expectedStatusCode, response.StatusCode); @@ -57,7 +57,7 @@ public async Task HttpTriggerTestsMediaTypeDoNotMatter(string functionName, stri [Fact] public async Task HttpTriggerTestsPocoResult() { - HttpResponseMessage response = await HttpHelpers.InvokeHttpTriggerWithBody("HelloUsingPoco", string.Empty, HttpStatusCode.OK, "application/json"); + HttpResponseMessage response = await HttpHelpers.InvokeHttpTriggerWithBody("HelloUsingPoco", string.Empty, "application/json"); string responseBody = await response.Content.ReadAsStringAsync(); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -67,7 +67,7 @@ public async Task HttpTriggerTestsPocoResult() [Fact(Skip = "Proxies not currently supported in V4 but will be coming back.")] public async Task HttpProxy() { - HttpResponseMessage response = await HttpHelpers.InvokeHttpTriggerWithBody("proxytest", string.Empty, HttpStatusCode.OK, "application/json"); + HttpResponseMessage response = await HttpHelpers.InvokeHttpTriggerWithBody("proxytest", string.Empty, "application/json"); string responseBody = await response.Content.ReadAsStringAsync(); Assert.Equal(HttpStatusCode.OK, response.StatusCode); From 2c2b30a13a63309a6cc3a4f22ba1f67caf5cf587 Mon Sep 17 00:00:00 2001 From: Lilian Kasem Date: Fri, 24 Mar 2023 15:32:24 -0700 Subject: [PATCH 08/13] clean up test app and fix delete command --- .../E2EApps/E2EApp/Cosmos/CosmosFunction.cs | 45 +++++++------------ .../E2ETests/Cosmos/CosmosDBEndToEndTests.cs | 26 +++-------- .../E2ETests/Helpers/CosmosDBHelpers.cs | 21 +++------ 3 files changed, 28 insertions(+), 64 deletions(-) diff --git a/test/E2ETests/E2EApps/E2EApp/Cosmos/CosmosFunction.cs b/test/E2ETests/E2EApps/E2EApp/Cosmos/CosmosFunction.cs index 4e23a3811..c7515a8d8 100644 --- a/test/E2ETests/E2EApps/E2EApp/Cosmos/CosmosFunction.cs +++ b/test/E2ETests/E2EApps/E2EApp/Cosmos/CosmosFunction.cs @@ -51,17 +51,17 @@ public async Task DocsByUsingCosmosClient( [HttpTrigger(AuthorizationLevel.Function, "get", "post")] HttpRequestData req, [CosmosDBInput("", "", Connection = "CosmosConnection")] CosmosClient client) { - var iterator = client.GetContainer("ItemDb", "ItemCollectionIn") - .GetItemQueryIterator("SELECT * FROM c"); + var container = client.GetContainer("ItemDb", "ItemCollectionIn"); + var iterator = container.GetItemQueryIterator("SELECT * FROM c"); var output = ""; while (iterator.HasMoreResults) { var documents = await iterator.ReadNextAsync(); - foreach (MyDocument d in documents) + foreach (dynamic d in documents) { - output += $"{(string)d.Text}, "; + output += $"{(string)d.id}, "; } } @@ -75,16 +75,17 @@ public async Task DocsByUsingDatabaseClient( [HttpTrigger(AuthorizationLevel.Function, "get", "post")] HttpRequestData req, [CosmosDBInput("%CosmosDb%", "", Connection = "CosmosConnection")] Database database) { - var iterator = database.GetContainerQueryIterator("SELECT * FROM c"); + var container = database.GetContainer("ItemCollectionIn");; + var iterator = container.GetItemQueryIterator("SELECT * FROM c"); var output = ""; while (iterator.HasMoreResults) { - var containers = await iterator.ReadNextAsync(); - foreach (dynamic c in containers) + var documents = await iterator.ReadNextAsync(); + foreach (dynamic d in documents) { - output += $"{(string)c.id}, "; + output += $"{(string)d.id}, "; } } @@ -105,9 +106,9 @@ public async Task DocsByUsingContainerClient( while (iterator.HasMoreResults) { var documents = await iterator.ReadNextAsync(); - foreach (MyDocument d in documents) + foreach (dynamic d in documents) { - output += $"{(string)d.Text}, "; + output += $"{(string)d.id}, "; } } @@ -138,16 +139,10 @@ public async Task DocByIdFromRouteDataUsingSqlQuery( databaseName: "%CosmosDb%", containerName: "%CosmosCollIn%", Connection = "CosmosConnection", - SqlQuery = "SELECT * FROM ToDoItems t where t.id = {id}")] + SqlQuery = "SELECT * FROM ItemCollectionIn t where t.Id = {id}")] IEnumerable myDocs) { - var output = ""; - - foreach (MyDocument doc in myDocs) - { - output += $"{(string)doc.Text}, "; - } - + var output = myDocs.FirstOrDefault().Text; var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteStringAsync(output); return response; @@ -160,16 +155,10 @@ public async Task DocByIdFromQueryStringUsingSqlQuery( databaseName: "%CosmosDb%", containerName: "%CosmosCollIn%", Connection = "CosmosConnection", - SqlQuery = "SELECT * FROM ToDoItems t where t.id = {id}")] + SqlQuery = "SELECT * FROM ItemCollectionIn t where t.Id = {id}")] IEnumerable myDocs) { - var output = ""; - - foreach (MyDocument doc in myDocs) - { - output += $"{(string)doc.Text}, "; - } - + var output = myDocs.FirstOrDefault().Text; var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteStringAsync(output); return response; @@ -180,10 +169,6 @@ public class MyDocument public string Id { get; set; } public string Text { get; set; } - - public int Number { get; set; } - - public bool Boolean { get; set; } } } } diff --git a/test/E2ETests/E2ETests/Cosmos/CosmosDBEndToEndTests.cs b/test/E2ETests/E2ETests/Cosmos/CosmosDBEndToEndTests.cs index eb98b2d66..7cda01a3c 100644 --- a/test/E2ETests/E2ETests/Cosmos/CosmosDBEndToEndTests.cs +++ b/test/E2ETests/E2ETests/Cosmos/CosmosDBEndToEndTests.cs @@ -79,8 +79,7 @@ public async Task CosmosInput_DocByIdFromRouteData_Succeeds() try { //Setup - MyDocument document = new() { Id = expectedDocId, Text = "hello world", Number = 1, Boolean = true }; - await CosmosDBHelpers.CreateDocument(document); + await CosmosDBHelpers.CreateDocument(expectedDocId, "DocByIdFromRouteData"); //Trigger HttpResponseMessage response = await HttpHelpers.InvokeHttpTrigger(functionPath); @@ -90,7 +89,7 @@ public async Task CosmosInput_DocByIdFromRouteData_Succeeds() HttpStatusCode expectedStatusCode = HttpStatusCode.OK; Assert.Equal(expectedStatusCode, response.StatusCode); - Assert.Contains("hello world", actualMessage); + Assert.Contains("DocByIdFromRouteData", actualMessage); } finally { @@ -107,8 +106,7 @@ public async Task CosmosInput_DocByIdFromRouteDataUsingSqlQuery_Succeeds() try { //Setup - MyDocument document = new() { Id = expectedDocId, Text = "hello world", Number = 1, Boolean = true }; - await CosmosDBHelpers.CreateDocument(document); + await CosmosDBHelpers.CreateDocument(expectedDocId, "DocByIdFromRouteDataUsingSqlQuery"); //Trigger HttpResponseMessage response = await HttpHelpers.InvokeHttpTrigger(functionPath); @@ -118,7 +116,7 @@ public async Task CosmosInput_DocByIdFromRouteDataUsingSqlQuery_Succeeds() HttpStatusCode expectedStatusCode = HttpStatusCode.OK; Assert.Equal(expectedStatusCode, response.StatusCode); - Assert.Contains("hello world", actualMessage); + Assert.Contains("DocByIdFromRouteDataUsingSqlQuery", actualMessage); } finally { @@ -136,8 +134,7 @@ public async Task CosmosInput_DocByIdFromQueryStringUsingSqlQuery_Succeeds() try { //Setup - MyDocument document = new() { Id = expectedDocId, Text = "hello world", Number = 1, Boolean = true }; - await CosmosDBHelpers.CreateDocument(document); + await CosmosDBHelpers.CreateDocument(expectedDocId, functionName); //Trigger HttpResponseMessage response = await HttpHelpers.InvokeHttpTriggerWithBody(functionName, requestBody, "application/json"); @@ -147,7 +144,7 @@ public async Task CosmosInput_DocByIdFromQueryStringUsingSqlQuery_Succeeds() HttpStatusCode expectedStatusCode = HttpStatusCode.OK; Assert.Equal(expectedStatusCode, response.StatusCode); - Assert.Contains("hello world", actualMessage); + Assert.Contains(functionName, actualMessage); } finally { @@ -160,16 +157,5 @@ public void Dispose() { _disposeLog?.Dispose(); } - - public class MyDocument - { - public string Id { get; set; } - - public string Text { get; set; } - - public int Number { get; set; } - - public bool Boolean { get; set; } - } } } diff --git a/test/E2ETests/E2ETests/Helpers/CosmosDBHelpers.cs b/test/E2ETests/E2ETests/Helpers/CosmosDBHelpers.cs index c40340c83..f146c3396 100644 --- a/test/E2ETests/E2ETests/Helpers/CosmosDBHelpers.cs +++ b/test/E2ETests/E2ETests/Helpers/CosmosDBHelpers.cs @@ -28,18 +28,11 @@ static CosmosDBHelpers() } // keep - public async static Task CreateDocument(string docId) + public async static Task CreateDocument(string docId, string docText = "test") { - Document documentToTest = new Document() - { - Id = docId - }; - - _ = await _docDbClient.CreateDocumentAsync(UriFactory.CreateDocumentCollectionUri(Constants.CosmosDB.DbName, Constants.CosmosDB.InputCollectionName), documentToTest); - } + Document documentToTest = new Document() { Id = docId }; + documentToTest.SetPropertyValue("Text", docText); - public async static Task CreateDocument(object documentToTest) - { _ = await _docDbClient.CreateDocumentAsync(UriFactory.CreateDocumentCollectionUri(Constants.CosmosDB.DbName, Constants.CosmosDB.InputCollectionName), documentToTest); } @@ -68,16 +61,16 @@ await TestUtility.RetryAsync(async () => public async static Task DeleteTestDocuments(string docId) { var inputDocUri = UriFactory.CreateDocumentUri(Constants.CosmosDB.DbName, Constants.CosmosDB.InputCollectionName, docId); - await DeleteDocument(inputDocUri); + await DeleteDocument(inputDocUri, docId); var outputDocUri = UriFactory.CreateDocumentUri(Constants.CosmosDB.DbName, Constants.CosmosDB.OutputCollectionName, docId); - await DeleteDocument(outputDocUri); + await DeleteDocument(outputDocUri, docId); } - private async static Task DeleteDocument(Uri docUri) + private async static Task DeleteDocument(Uri docUri, string docId) { try { - await _docDbClient.DeleteDocumentAsync(docUri); + await _docDbClient.DeleteDocumentAsync(docUri, new RequestOptions { PartitionKey = new PartitionKey(docId) }); } catch (Exception) { From 4c2f259a9f715e53c4aa79471a13176dc102de43 Mon Sep 17 00:00:00 2001 From: Lilian Kasem Date: Fri, 26 May 2023 14:30:02 -0700 Subject: [PATCH 09/13] undo change to blob startup --- .../src/StorageExtensionStartup.cs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/extensions/Worker.Extensions.Storage.Blobs/src/StorageExtensionStartup.cs b/extensions/Worker.Extensions.Storage.Blobs/src/StorageExtensionStartup.cs index a7bee100d..9f8b10bb1 100644 --- a/extensions/Worker.Extensions.Storage.Blobs/src/StorageExtensionStartup.cs +++ b/extensions/Worker.Extensions.Storage.Blobs/src/StorageExtensionStartup.cs @@ -24,11 +24,6 @@ public override void Configure(IFunctionsWorkerApplicationBuilder applicationBui applicationBuilder.Services.AddAzureClientsCore(); // Adds AzureComponentFactory applicationBuilder.Services.AddOptions(); applicationBuilder.Services.AddSingleton, BlobStorageBindingOptionsSetup>(); - - applicationBuilder.Services.Configure((workerOption) => - { - workerOption.InputConverters.RegisterAt(0); - }); } } } From 677769e3c0e629e74a0981c66a140965a04a7860 Mon Sep 17 00:00:00 2001 From: Lilian Kasem Date: Fri, 26 May 2023 15:05:35 -0700 Subject: [PATCH 10/13] Replace id with Id --- test/E2ETests/E2EApps/E2EApp/Cosmos/CosmosFunction.cs | 6 +++--- test/E2ETests/E2ETests/E2ETests.csproj | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/test/E2ETests/E2EApps/E2EApp/Cosmos/CosmosFunction.cs b/test/E2ETests/E2EApps/E2EApp/Cosmos/CosmosFunction.cs index c7515a8d8..c1b661cf8 100644 --- a/test/E2ETests/E2EApps/E2EApp/Cosmos/CosmosFunction.cs +++ b/test/E2ETests/E2EApps/E2EApp/Cosmos/CosmosFunction.cs @@ -61,7 +61,7 @@ public async Task DocsByUsingCosmosClient( var documents = await iterator.ReadNextAsync(); foreach (dynamic d in documents) { - output += $"{(string)d.id}, "; + output += $"{(string)d.Id}, "; } } @@ -85,7 +85,7 @@ public async Task DocsByUsingDatabaseClient( var documents = await iterator.ReadNextAsync(); foreach (dynamic d in documents) { - output += $"{(string)d.id}, "; + output += $"{(string)d.Id}, "; } } @@ -108,7 +108,7 @@ public async Task DocsByUsingContainerClient( var documents = await iterator.ReadNextAsync(); foreach (dynamic d in documents) { - output += $"{(string)d.id}, "; + output += $"{(string)d.Id}, "; } } diff --git a/test/E2ETests/E2ETests/E2ETests.csproj b/test/E2ETests/E2ETests/E2ETests.csproj index f7cbb5240..75e1d31eb 100644 --- a/test/E2ETests/E2ETests/E2ETests.csproj +++ b/test/E2ETests/E2ETests/E2ETests.csproj @@ -8,6 +8,7 @@ + @@ -19,7 +20,6 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - From 29e59b33d048b3fe6ee7891d57d76528aa102df6 Mon Sep 17 00:00:00 2001 From: Lilian Kasem Date: Fri, 26 May 2023 16:25:48 -0700 Subject: [PATCH 11/13] use lower case id for query --- test/E2ETests/E2EApps/E2EApp/Cosmos/CosmosFunction.cs | 4 ++-- test/E2ETests/E2ETests/Fixtures/FunctionAppFixture.cs | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/test/E2ETests/E2EApps/E2EApp/Cosmos/CosmosFunction.cs b/test/E2ETests/E2EApps/E2EApp/Cosmos/CosmosFunction.cs index c1b661cf8..c41e47935 100644 --- a/test/E2ETests/E2EApps/E2EApp/Cosmos/CosmosFunction.cs +++ b/test/E2ETests/E2EApps/E2EApp/Cosmos/CosmosFunction.cs @@ -139,7 +139,7 @@ public async Task DocByIdFromRouteDataUsingSqlQuery( databaseName: "%CosmosDb%", containerName: "%CosmosCollIn%", Connection = "CosmosConnection", - SqlQuery = "SELECT * FROM ItemCollectionIn t where t.Id = {id}")] + SqlQuery = "SELECT * FROM ItemCollectionIn t where t.id = {id}")] IEnumerable myDocs) { var output = myDocs.FirstOrDefault().Text; @@ -155,7 +155,7 @@ public async Task DocByIdFromQueryStringUsingSqlQuery( databaseName: "%CosmosDb%", containerName: "%CosmosCollIn%", Connection = "CosmosConnection", - SqlQuery = "SELECT * FROM ItemCollectionIn t where t.Id = {id}")] + SqlQuery = "SELECT * FROM ItemCollectionIn t where t.id = {id}")] IEnumerable myDocs) { var output = myDocs.FirstOrDefault().Text; diff --git a/test/E2ETests/E2ETests/Fixtures/FunctionAppFixture.cs b/test/E2ETests/E2ETests/Fixtures/FunctionAppFixture.cs index 41a164a21..524864b53 100644 --- a/test/E2ETests/E2ETests/Fixtures/FunctionAppFixture.cs +++ b/test/E2ETests/E2ETests/Fixtures/FunctionAppFixture.cs @@ -90,6 +90,13 @@ await TestUtility.RetryAsync(async () => } catch { + if (_funcProcess.HasExited) + { + // Something went wrong starting the host - check the logs + _logger.LogInformation($" Current state: process exited - something may have gone wrong."); + return false; + } + // Can get exceptions before host is running. _logger.LogInformation($" Current state: process starting"); return false; From d4c954a69f908d6b8d8628e690441beeeea42a51 Mon Sep 17 00:00:00 2001 From: Lilian Kasem Date: Wed, 31 May 2023 10:29:19 -0700 Subject: [PATCH 12/13] Update cosmos test app --- test/E2ETests/E2EApps/E2EApp/Cosmos/CosmosFunction.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/E2ETests/E2EApps/E2EApp/Cosmos/CosmosFunction.cs b/test/E2ETests/E2EApps/E2EApp/Cosmos/CosmosFunction.cs index c41e47935..9791f8bbf 100644 --- a/test/E2ETests/E2EApps/E2EApp/Cosmos/CosmosFunction.cs +++ b/test/E2ETests/E2EApps/E2EApp/Cosmos/CosmosFunction.cs @@ -37,10 +37,10 @@ public object CosmosTrigger([CosmosDBTrigger( { foreach (var doc in input) { - context.GetLogger("Function.CosmosTrigger").LogInformation($"id: {doc.Id}"); + context.GetLogger("Function.CosmosTrigger").LogInformation($"id: {doc.DocId}"); } - return input.Select(p => new { id = p.Id }); + return input.Select(p => new { id = p.DocId }); } return null; @@ -139,7 +139,7 @@ public async Task DocByIdFromRouteDataUsingSqlQuery( databaseName: "%CosmosDb%", containerName: "%CosmosCollIn%", Connection = "CosmosConnection", - SqlQuery = "SELECT * FROM ItemCollectionIn t where t.id = {id}")] + SqlQuery = "SELECT * FROM ItemDb t where t.id = {id}")] IEnumerable myDocs) { var output = myDocs.FirstOrDefault().Text; @@ -155,7 +155,7 @@ public async Task DocByIdFromQueryStringUsingSqlQuery( databaseName: "%CosmosDb%", containerName: "%CosmosCollIn%", Connection = "CosmosConnection", - SqlQuery = "SELECT * FROM ItemCollectionIn t where t.id = {id}")] + SqlQuery = "SELECT * FROM ItemDb t where t.id = {id}")] IEnumerable myDocs) { var output = myDocs.FirstOrDefault().Text; @@ -166,7 +166,7 @@ public async Task DocByIdFromQueryStringUsingSqlQuery( public class MyDocument { - public string Id { get; set; } + public string DocId { get; set; } public string Text { get; set; } } From 8cd1a5240ab2277fb33cfb7e364acc9dd787b7e0 Mon Sep 17 00:00:00 2001 From: Lilian Kasem Date: Wed, 31 May 2023 11:52:34 -0700 Subject: [PATCH 13/13] Fix bug! --- .../src/CosmosDBConverter.cs | 11 ++++++----- .../E2ETests/E2EApps/E2EApp/Cosmos/CosmosFunction.cs | 12 +++++------- .../E2ETests/Cosmos/CosmosDBEndToEndTests.cs | 4 ++-- 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/extensions/Worker.Extensions.CosmosDB/src/CosmosDBConverter.cs b/extensions/Worker.Extensions.CosmosDB/src/CosmosDBConverter.cs index efd8a0b84..76615a115 100644 --- a/extensions/Worker.Extensions.CosmosDB/src/CosmosDBConverter.cs +++ b/extensions/Worker.Extensions.CosmosDB/src/CosmosDBConverter.cs @@ -155,11 +155,12 @@ private async Task CreatePOCOCollectionAsync(Container container, Cos } } - PartitionKey partitionKey = String.IsNullOrEmpty(cosmosAttribute.PartitionKey) - ? PartitionKey.None - : new PartitionKey(cosmosAttribute.PartitionKey); - - QueryRequestOptions queryRequestOptions = new() { PartitionKey = partitionKey }; + QueryRequestOptions queryRequestOptions = new(); + if (!String.IsNullOrEmpty(cosmosAttribute.PartitionKey)) + { + var partitionKey = new PartitionKey(cosmosAttribute.PartitionKey); + queryRequestOptions = new() { PartitionKey = partitionKey }; + } using (var iterator = container.GetItemQueryIterator(queryDefinition: queryDefinition, requestOptions: queryRequestOptions)) { diff --git a/test/E2ETests/E2EApps/E2EApp/Cosmos/CosmosFunction.cs b/test/E2ETests/E2EApps/E2EApp/Cosmos/CosmosFunction.cs index 9791f8bbf..22bcd3521 100644 --- a/test/E2ETests/E2EApps/E2EApp/Cosmos/CosmosFunction.cs +++ b/test/E2ETests/E2EApps/E2EApp/Cosmos/CosmosFunction.cs @@ -37,10 +37,10 @@ public object CosmosTrigger([CosmosDBTrigger( { foreach (var doc in input) { - context.GetLogger("Function.CosmosTrigger").LogInformation($"id: {doc.DocId}"); + context.GetLogger("Function.CosmosTrigger").LogInformation($"id: {doc.Text}"); } - return input.Select(p => new { id = p.DocId }); + return input.Select(p => new { id = p.Text }); } return null; @@ -61,7 +61,7 @@ public async Task DocsByUsingCosmosClient( var documents = await iterator.ReadNextAsync(); foreach (dynamic d in documents) { - output += $"{(string)d.Id}, "; + output += $"{(string)d.Text}, "; } } @@ -85,7 +85,7 @@ public async Task DocsByUsingDatabaseClient( var documents = await iterator.ReadNextAsync(); foreach (dynamic d in documents) { - output += $"{(string)d.Id}, "; + output += $"{(string)d.Text}, "; } } @@ -108,7 +108,7 @@ public async Task DocsByUsingContainerClient( var documents = await iterator.ReadNextAsync(); foreach (dynamic d in documents) { - output += $"{(string)d.Id}, "; + output += $"{(string)d.Text}, "; } } @@ -166,8 +166,6 @@ public async Task DocByIdFromQueryStringUsingSqlQuery( public class MyDocument { - public string DocId { get; set; } - public string Text { get; set; } } } diff --git a/test/E2ETests/E2ETests/Cosmos/CosmosDBEndToEndTests.cs b/test/E2ETests/E2ETests/Cosmos/CosmosDBEndToEndTests.cs index 7cda01a3c..5e1593f3c 100644 --- a/test/E2ETests/E2ETests/Cosmos/CosmosDBEndToEndTests.cs +++ b/test/E2ETests/E2ETests/Cosmos/CosmosDBEndToEndTests.cs @@ -29,7 +29,7 @@ public async Task CosmosDBTriggerAndOutput_Succeeds() try { //Trigger - await CosmosDBHelpers.CreateDocument(expectedDocId); + await CosmosDBHelpers.CreateDocument(expectedDocId, expectedDocId); //Read var documentId = await CosmosDBHelpers.ReadDocument(expectedDocId); @@ -52,7 +52,7 @@ public async Task CosmosInput_ClientBinding_Succeeds(string functionName) try { //Setup - await CosmosDBHelpers.CreateDocument(expectedDocId); + await CosmosDBHelpers.CreateDocument(expectedDocId, expectedDocId); //Trigger HttpResponseMessage response = await HttpHelpers.InvokeHttpTrigger(functionName);