diff --git a/src/Microsoft.SqlTools.ServiceLayer/AzureFunctions/AzureFunctionsUtils.cs b/src/Microsoft.SqlTools.ServiceLayer/AzureFunctions/AzureFunctionsUtils.cs index 3e7ff34e..e0f0b223 100644 --- a/src/Microsoft.SqlTools.ServiceLayer/AzureFunctions/AzureFunctionsUtils.cs +++ b/src/Microsoft.SqlTools.ServiceLayer/AzureFunctions/AzureFunctionsUtils.cs @@ -3,6 +3,7 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. // +using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using System; using System.Collections.Generic; @@ -12,8 +13,10 @@ namespace Microsoft.SqlTools.ServiceLayer.AzureFunctions { internal static class AzureFunctionsUtils { - public const string functionAttributeText = "FunctionName"; - public const string net5FunctionAttributeText = "Function"; + private const string FUNCTION_NAME_ATTRIBUTE_NAME = "FunctionName"; + private const string NET5_FUNCTION_ATTRIBUTE_NAME = "Function"; + private const string HTTP_TRIGGER_ATTRIBUTE_NAME = "HttpTrigger"; + private const string ROUTE_ARGUMENT_NAME = "Route"; /// /// Gets all the methods in the syntax tree with an Azure Function attribute @@ -31,19 +34,74 @@ namespace Microsoft.SqlTools.ServiceLayer.AzureFunctions } // get all the method declarations with the FunctionName attribute - IEnumerable methodsWithFunctionAttributes = methodDeclarations.Where(md => md.AttributeLists.Where(a => a.Attributes.Where(attr => attr.Name.ToString().Equals(functionAttributeText)).Any()).Any()); + IEnumerable methodsWithFunctionAttributes = methodDeclarations.Where(md => md.AttributeLists.Where(a => a.Attributes.Where(attr => attr.Name.ToString().Equals(FUNCTION_NAME_ATTRIBUTE_NAME)).Any()).Any()); return methodsWithFunctionAttributes; } + /// + /// Gets the route from an HttpTrigger attribute if specified + /// + /// The method + /// The name of the route, or null if no route is specified (or there isn't an HttpTrigger binding) + public static string? GetHttpRoute(this MethodDeclarationSyntax m) + { + return m + .ParameterList + .Parameters // Get all the parameters for the method + .SelectMany(p => + p.AttributeLists // Get a list of all attributes on any of the parameters + .SelectMany(al => al.Attributes) + ).Where(a => a.Name.ToString().Equals(HTTP_TRIGGER_ATTRIBUTE_NAME) // Find any HttpTrigger attributes + ).FirstOrDefault() // Get the first one available - there should only ever be 0 or 1 + ?.ArgumentList // Get all the arguments for the attribute + ?.Arguments + .Where(a => a.ChildNodes().OfType().Where(nes => nes.Name.ToString().Equals(ROUTE_ARGUMENT_NAME)).Any()) // Find the Route argument - it should always be a named argument + .FirstOrDefault() + ?.ChildNodes() + .OfType() // Find the child identifier node with our value + .Where(le => le.Kind() != SyntaxKind.NullLiteralExpression) // Skip the null expressions so they aren't ToString()'d into "null" + .FirstOrDefault() + ?.ToString() + .TrimStart('$') // Remove $ from interpolated string, since this will always be outside the quotes we can just trim + .Trim('\"'); // Trim off the quotes from the string value - additional quotes at the beginning and end aren't valid syntax so it's fine to trim + } + + /// + /// Gets the function name from the FunctionName attribute on a method + /// + /// The method + /// The function name, or an empty string if the name attribute doesn't exist + public static string GetFunctionName(this MethodDeclarationSyntax m) + { + // Note that we return an empty string as the default because a null name isn't valid - every function + // should have a name. So we should never actually hit that scenario, but just to be safe we return the + // empty string as the default in case we hit some unexpected edge case. + return m + .AttributeLists // Get all the attribute lists on the method + .Select(a => + a.Attributes.Where( + attr => attr.Name.ToString().Equals(AzureFunctionsUtils.FUNCTION_NAME_ATTRIBUTE_NAME) // Find any FunctionName attributes + ).FirstOrDefault() // Get the first one available - there should only ever be 0 or 1 + ).Where(a => // Filter out any that didn't have a FunctionName attribute + a != null + ).FirstOrDefault() // Get the first one available - there should only ever be 0 or 1 + ?.ArgumentList // Get all the arguments for the attribute + ?.Arguments + .FirstOrDefault() // The first argument is the function name + ?.ToString() + .TrimStart('$') // Remove $ from interpolated string, since this will always be outside the quotes we can just trim + .Trim('\"') ?? ""; // Trim off the quotes from the string value - additional quotes at the beginning and end aren't valid syntax so it's fine to trim + } + /// /// Checks if any of the method declarations have .NET 5 style Azure Function attributes - /// .NET 5 AFs use the Function attribute, while .NET 3.1 AFs use FunctionName attritube + /// .NET 5 AFs use the Function attribute, while .NET 3.1 AFs use FunctionName attribute /// public static bool HasNet5StyleAzureFunctions(IEnumerable methodDeclarations) { // get all the method declarations with the Function attribute - return methodDeclarations.Any(md => md.AttributeLists.Any(al => al.Attributes.Any(attr => attr.Name.ToString().Equals(net5FunctionAttributeText)))); + return methodDeclarations.Any(md => md.AttributeLists.Any(al => al.Attributes.Any(attr => attr.Name.ToString().Equals(NET5_FUNCTION_ATTRIBUTE_NAME)))); } } } diff --git a/src/Microsoft.SqlTools.ServiceLayer/AzureFunctions/Contracts/GetAzureFunctionsRequest.cs b/src/Microsoft.SqlTools.ServiceLayer/AzureFunctions/Contracts/GetAzureFunctionsRequest.cs index 610a5d6f..839ded38 100644 --- a/src/Microsoft.SqlTools.ServiceLayer/AzureFunctions/Contracts/GetAzureFunctionsRequest.cs +++ b/src/Microsoft.SqlTools.ServiceLayer/AzureFunctions/Contracts/GetAzureFunctionsRequest.cs @@ -3,7 +3,6 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. // using Microsoft.SqlTools.Hosting.Protocol.Contracts; -using Microsoft.SqlTools.ServiceLayer.Utility; namespace Microsoft.SqlTools.ServiceLayer.AzureFunctions.Contracts { @@ -15,15 +14,39 @@ namespace Microsoft.SqlTools.ServiceLayer.AzureFunctions.Contracts /// /// Gets or sets the filePath /// - public string filePath { get; set; } + public string FilePath { get; set; } + } + + public class AzureFunction + { + /// + /// The name of the function + /// + public string Name { get; set; } + + /// + /// The route of the HttpTrigger binding if one exists on this function + /// + public string? Route { get; set; } + + public AzureFunction(string name, string? route) + { + this.Name = name; + this.Route = route; + } } /// /// Parameters returned from a get Azure functions request /// - public class GetAzureFunctionsResult : ResultStatus + public class GetAzureFunctionsResult { - public string[] azureFunctions { get; set; } + public AzureFunction[] AzureFunctions { get; set; } + + public GetAzureFunctionsResult(AzureFunction[] azureFunctions) + { + this.AzureFunctions = azureFunctions; + } } /// diff --git a/src/Microsoft.SqlTools.ServiceLayer/AzureFunctions/GetAzureFunctionsOperation.cs b/src/Microsoft.SqlTools.ServiceLayer/AzureFunctions/GetAzureFunctionsOperation.cs index beaf5ab1..4cf46835 100644 --- a/src/Microsoft.SqlTools.ServiceLayer/AzureFunctions/GetAzureFunctionsOperation.cs +++ b/src/Microsoft.SqlTools.ServiceLayer/AzureFunctions/GetAzureFunctionsOperation.cs @@ -37,7 +37,7 @@ namespace Microsoft.SqlTools.ServiceLayer.AzureFunctions { try { - string text = File.ReadAllText(Parameters.filePath); + string text = File.ReadAllText(Parameters.FilePath); SyntaxTree tree = CSharpSyntaxTree.ParseText(text); CompilationUnitSyntax root = tree.GetCompilationUnitRoot(); @@ -45,20 +45,9 @@ namespace Microsoft.SqlTools.ServiceLayer.AzureFunctions // get all the method declarations with the FunctionName attribute IEnumerable methodsWithFunctionAttributes = AzureFunctionsUtils.GetMethodsWithFunctionAttributes(root); - // Get FunctionName attributes - IEnumerable functionNameAttributes = methodsWithFunctionAttributes.Select(md => md.AttributeLists.Select(a => a.Attributes.Where(attr => attr.Name.ToString().Equals(AzureFunctionsUtils.functionAttributeText)).First()).First()); + var azureFunctions = methodsWithFunctionAttributes.Select(m => new AzureFunction(m.GetFunctionName(), m.GetHttpRoute())).ToArray(); - // Get the function names in the FunctionName attributes - IEnumerable nameArgs = functionNameAttributes.Select(a => a.ArgumentList.Arguments.First()); - - // Remove quotes from around the names - string[] aFNames = nameArgs.Select(ab => ab.ToString().Trim('\"')).ToArray(); - - return new GetAzureFunctionsResult() - { - Success = true, - azureFunctions = aFNames - }; + return new GetAzureFunctionsResult(azureFunctions); } catch (Exception ex) { diff --git a/test/Microsoft.SqlTools.ServiceLayer.IntegrationTests/AzureFunctions/AzureFunctionTestFiles/AzureFunctionsName.cs b/test/Microsoft.SqlTools.ServiceLayer.IntegrationTests/AzureFunctions/AzureFunctionTestFiles/AzureFunctionsName.cs new file mode 100644 index 00000000..6e3f180e --- /dev/null +++ b/test/Microsoft.SqlTools.ServiceLayer.IntegrationTests/AzureFunctions/AzureFunctionTestFiles/AzureFunctionsName.cs @@ -0,0 +1,35 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Company.Namespace.Models; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Azure.WebJobs; +using Microsoft.Azure.WebJobs.Extensions.Http; +using Microsoft.Extensions.Logging; + +namespace Company.Namespace +{ + public class ArtistsApi + { + /// + /// Function with basic name + /// + [FunctionName("WithName")] + public IActionResult WithName([HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "artists")] HttpRequest req) + { + throw new NotImplementedException(); + } + + private const string interpolated = "interpolated"; + + /// + /// Function with interpolated string as name + /// + [FunctionName($"{interpolated}String")] + public async IActionResult InterpolatedString([HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "artists")] Artist body, HttpRequest req) + { + throw new NotImplementedException(); + } + } +} diff --git a/test/Microsoft.SqlTools.ServiceLayer.IntegrationTests/AzureFunctions/AzureFunctionTestFiles/AzureFunctionsRoute.cs b/test/Microsoft.SqlTools.ServiceLayer.IntegrationTests/AzureFunctions/AzureFunctionTestFiles/AzureFunctionsRoute.cs new file mode 100644 index 00000000..d712dd68 --- /dev/null +++ b/test/Microsoft.SqlTools.ServiceLayer.IntegrationTests/AzureFunctions/AzureFunctionTestFiles/AzureFunctionsRoute.cs @@ -0,0 +1,89 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Company.Namespace.Models; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Azure.WebJobs; +using Microsoft.Azure.WebJobs.Extensions.Http; +using Microsoft.Extensions.Logging; +using System.Collections.Generic; + +namespace Company.Namespace +{ + public class AzureFunctionsRoute + { + /// + /// Tests binding with a route specified + /// + [FunctionName("WithRoute")] + public IActionResult WithRoute([HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "withRoute")] HttpRequest req, [Sql("select * from [dbo].[table1]", CommandType = System.Data.CommandType.Text, ConnectionStringSetting = "SqlConnectionString")] IEnumerable result) + { + throw new NotImplementedException(); + } + + private const string interpolated = "interpolated"; + /// + /// Tests binding with a route specified using an interpolated string + /// + [FunctionName("InterpolatedString")] + public IActionResult InterpolatedString([HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = $"{interpolated}String")] HttpRequest req, [Sql("select * from [dbo].[table1]", CommandType = System.Data.CommandType.Text, ConnectionStringSetting = "SqlConnectionString")] IEnumerable result) + { + throw new NotImplementedException(); + } + + /// + /// Tests binding with a route specified that has $'s on the beginning and end + /// + [FunctionName("WithDollarSigns")] + public IActionResult WithDollarSigns([HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "$withDollarSigns$")] HttpRequest req, [Sql("select * from [dbo].[table1]", CommandType = System.Data.CommandType.Text, ConnectionStringSetting = "SqlConnectionString")] IEnumerable result) + { + throw new NotImplementedException(); + } + + /// + /// Tests binding with a route specified and no spaces between tokens + /// + [FunctionName("WithRouteNoSpaces")] + public IActionResult WithRouteNoSpaces([HttpTrigger(AuthorizationLevel.Anonymous, "get", Route="withRouteNoSpaces")] HttpRequest req, [Sql("select * from [dbo].[table1]", CommandType = System.Data.CommandType.Text, ConnectionStringSetting = "SqlConnectionString")] IEnumerable result) + { + throw new NotImplementedException(); + } + + /// + /// Tests binding with a route specified and no spaces between tokens + /// + [FunctionName("WithRouteExtraSpaces")] + public IActionResult WithRouteExtraSpaces([HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "withRouteExtraSpaces")] HttpRequest req, [Sql("select * from [dbo].[table1]", CommandType = System.Data.CommandType.Text, ConnectionStringSetting = "SqlConnectionString")] IEnumerable result) + { + throw new NotImplementedException(); + } + + /// + /// Tests binding with a null route specified + /// + [FunctionName("WithNullRoute")] + public IActionResult WithNullRoute([HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = null)] HttpRequest req, [Sql("select * from [dbo].[table1]", CommandType = System.Data.CommandType.Text, ConnectionStringSetting = "SqlConnectionString")] IEnumerable result) + { + throw new NotImplementedException(); + } + + /// + /// Tests binding with a null route specified + /// + [FunctionName("NoRoute")] + public IActionResult NoRoute([HttpTrigger(AuthorizationLevel.Anonymous, "get")] HttpRequest req, [Sql("select * from [dbo].[table1]", CommandType = System.Data.CommandType.Text, ConnectionStringSetting = "SqlConnectionString")] IEnumerable result) + { + throw new NotImplementedException(); + } + + /// + /// Tests binding with an empty route specified + /// + [FunctionName("EmptyRoute")] + public IActionResult EmptyRoute([HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "")] HttpRequest req, [Sql("select * from [dbo].[table1]", CommandType = System.Data.CommandType.Text, ConnectionStringSetting = "SqlConnectionString")] IEnumerable result) + { + throw new NotImplementedException(); + } + } +} diff --git a/test/Microsoft.SqlTools.ServiceLayer.IntegrationTests/AzureFunctions/AzureFunctionsServiceTests.cs b/test/Microsoft.SqlTools.ServiceLayer.IntegrationTests/AzureFunctions/AzureFunctionsServiceTests.cs index dd2e95db..94b89522 100644 --- a/test/Microsoft.SqlTools.ServiceLayer.IntegrationTests/AzureFunctions/AzureFunctionsServiceTests.cs +++ b/test/Microsoft.SqlTools.ServiceLayer.IntegrationTests/AzureFunctions/AzureFunctionsServiceTests.cs @@ -102,12 +102,12 @@ namespace Microsoft.SqlTools.ServiceLayer.IntegrationTests.AzureFunctions AddSqlBindingOperation operation = new AddSqlBindingOperation(parameters); ResultStatus result = operation.AddBinding(); - Assert.True(result.Success); - Assert.IsNull(result.ErrorMessage); + Assert.That(result.Success, Is.True); + Assert.That(result.ErrorMessage, Is.Null); string expectedFileText = File.ReadAllText(Path.Join(testAzureFunctionsFolder, "AzureFunctionsOutputBindingAsync.cs")); string actualFileText = File.ReadAllText(testFile); - Assert.AreEqual(expectedFileText, actualFileText); + Assert.That(actualFileText, Is.EqualTo(expectedFileText)); } /// @@ -133,9 +133,9 @@ namespace Microsoft.SqlTools.ServiceLayer.IntegrationTests.AzureFunctions AddSqlBindingOperation operation = new AddSqlBindingOperation(parameters); ResultStatus result = operation.AddBinding(); - Assert.False(result.Success); - Assert.NotNull(result.ErrorMessage); - Assert.True(result.ErrorMessage.Equals(SR.CouldntFindAzureFunction("noExistingFunction", testFile))); + Assert.That(result.Success, Is.False); + Assert.That(result.ErrorMessage, Is.Not.Null); + Assert.That(result.ErrorMessage, Is.EqualTo(SR.CouldntFindAzureFunction("noExistingFunction", testFile))); } /// @@ -161,33 +161,30 @@ namespace Microsoft.SqlTools.ServiceLayer.IntegrationTests.AzureFunctions AddSqlBindingOperation operation = new AddSqlBindingOperation(parameters); ResultStatus result = operation.AddBinding(); - Assert.False(result.Success); - Assert.NotNull(result.ErrorMessage); - Assert.True(result.ErrorMessage.Equals(SR.MoreThanOneAzureFunctionWithName("GetArtists_get", testFile))); + Assert.That(result.Success, Is.False); + Assert.That(result.ErrorMessage, Is.Not.Null); + Assert.That(result.ErrorMessage, Is.EqualTo(SR.MoreThanOneAzureFunctionWithName("GetArtists_get", testFile))); } /// /// Verify getting the names of Azure functions in a file /// [Test] - public void GetAzureFunctions() + public void GetAzureFunctionNames() { - string testFile = Path.Join(testAzureFunctionsFolder, "AzureFunctionsNoBindings.cs"); + string testFile = Path.Join(testAzureFunctionsFolder, "AzureFunctionsName.cs"); GetAzureFunctionsParams parameters = new GetAzureFunctionsParams { - filePath = testFile + FilePath = testFile }; GetAzureFunctionsOperation operation = new GetAzureFunctionsOperation(parameters); GetAzureFunctionsResult result = operation.GetAzureFunctions(); - Assert.True(result.Success); - Assert.Null(result.ErrorMessage); - Assert.AreEqual(3, result.azureFunctions.Length); - Assert.AreEqual(result.azureFunctions[0], "GetArtists_get"); - Assert.AreEqual(result.azureFunctions[1], "NewArtist_post"); - Assert.AreEqual(result.azureFunctions[2], "NewArtists_post"); + Assert.That(result.AzureFunctions.Length, Is.EqualTo(2)); + Assert.That(result.AzureFunctions[0].Name, Is.EqualTo("WithName")); + Assert.That(result.AzureFunctions[1].Name, Is.EqualTo("{interpolated}String")); } /// @@ -202,15 +199,13 @@ namespace Microsoft.SqlTools.ServiceLayer.IntegrationTests.AzureFunctions fstream.Close(); GetAzureFunctionsParams parameters = new GetAzureFunctionsParams { - filePath = testFile + FilePath = testFile }; GetAzureFunctionsOperation operation = new GetAzureFunctionsOperation(parameters); GetAzureFunctionsResult result = operation.GetAzureFunctions(); - Assert.True(result.Success); - Assert.Null(result.ErrorMessage); - Assert.AreEqual(0, result.azureFunctions.Length); + Assert.That(result.AzureFunctions.Length, Is.EqualTo(0)); } /// @@ -223,13 +218,40 @@ namespace Microsoft.SqlTools.ServiceLayer.IntegrationTests.AzureFunctions GetAzureFunctionsParams parameters = new GetAzureFunctionsParams { - filePath = testFile + FilePath = testFile }; GetAzureFunctionsOperation operation = new GetAzureFunctionsOperation(parameters); Exception ex = Assert.Throws(() => { operation.GetAzureFunctions(); }); - Assert.AreEqual(SR.SqlBindingsNet5NotSupported, ex.Message); + Assert.That(ex.Message, Is.EqualTo(SR.SqlBindingsNet5NotSupported)); + } + + /// + /// Verify getting the routes of Azure Functions in a file + /// + [Test] + public void GetAzureFunctionsRoute() + { + string testFile = Path.Join(testAzureFunctionsFolder, "AzureFunctionsRoute.cs"); + + GetAzureFunctionsParams parameters = new GetAzureFunctionsParams + { + FilePath = testFile + }; + + GetAzureFunctionsOperation operation = new GetAzureFunctionsOperation(parameters); + GetAzureFunctionsResult result = operation.GetAzureFunctions(); + + Assert.That(result.AzureFunctions.Length, Is.EqualTo(8)); + Assert.That(result.AzureFunctions[0].Route, Is.EqualTo("withRoute")); + Assert.That(result.AzureFunctions[1].Route, Is.EqualTo("{interpolated}String")); + Assert.That(result.AzureFunctions[2].Route, Is.EqualTo("$withDollarSigns$")); + Assert.That(result.AzureFunctions[3].Route, Is.EqualTo("withRouteNoSpaces")); + Assert.That(result.AzureFunctions[4].Route, Is.EqualTo("withRouteExtraSpaces")); + Assert.That(result.AzureFunctions[5].Route, Is.Null, "Route specified as null should be null"); + Assert.That(result.AzureFunctions[6].Route, Is.Null, "No route specified should be null"); + Assert.That(result.AzureFunctions[7].Route, Is.EqualTo("")); } } } diff --git a/test/Microsoft.SqlTools.ServiceLayer.IntegrationTests/Microsoft.SqlTools.ServiceLayer.IntegrationTests.csproj b/test/Microsoft.SqlTools.ServiceLayer.IntegrationTests/Microsoft.SqlTools.ServiceLayer.IntegrationTests.csproj index 3bf44cd7..13cdad2b 100644 --- a/test/Microsoft.SqlTools.ServiceLayer.IntegrationTests/Microsoft.SqlTools.ServiceLayer.IntegrationTests.csproj +++ b/test/Microsoft.SqlTools.ServiceLayer.IntegrationTests/Microsoft.SqlTools.ServiceLayer.IntegrationTests.csproj @@ -39,12 +39,8 @@ - - - - - - + +