diff --git a/src/Azure.Functions.Cli/Actions/DurableActions/DurableCreateHelpersAction.cs b/src/Azure.Functions.Cli/Actions/DurableActions/DurableCreateHelpersAction.cs index 8d4053e4f..286ee6ba4 100644 --- a/src/Azure.Functions.Cli/Actions/DurableActions/DurableCreateHelpersAction.cs +++ b/src/Azure.Functions.Cli/Actions/DurableActions/DurableCreateHelpersAction.cs @@ -57,9 +57,9 @@ public override async Task RunAsync() var project = workspace.CurrentSolution.Projects.FirstOrDefault(p => p.FilePath == csproj); - var methodsBySemanticModel = await GetActivityFunctionMethods(project); + var activityAndOrchestratorMethods = await GetActivityAndOrchestratorFunctionMethods(project); - var compilationUnitSyntax = GenerateCode(project, methodsBySemanticModel); + var compilationUnitSyntax = GenerateCode(project, activityAndOrchestratorMethods); var generatedCode = compilationUnitSyntax.ToString(); @@ -70,9 +70,22 @@ public override async Task RunAsync() await FileSystemHelpers.WriteAllTextToFileAsync(generatedFilePath, generatedCode); } - private static bool MethodHasActivityTriggerParameter(IMethodSymbol method) + enum FunctionType { - return method.Parameters.Any(ParameterIsActivityTrigger); + Unknown, + Activity, + Orchestrator + } + private static FunctionType GetFunctionType(IMethodSymbol method) + { + foreach (var parameter in method.Parameters) + { + if (ParameterIsActivityTrigger(parameter)) + return FunctionType.Activity; + if (ParameterIsOrchestratorTrigger(parameter)) + return FunctionType.Orchestrator; + } + return FunctionType.Unknown; } private static bool ParameterIsActivityTrigger(IParameterSymbol parameter) @@ -81,7 +94,14 @@ private static bool ParameterIsActivityTrigger(IParameterSymbol parameter) .Any(a => a.AttributeClass.ToString() == "Microsoft.Azure.WebJobs.ActivityTriggerAttribute"); } - private static async Task> GetActivityFunctionMethods(Project project) + private static bool ParameterIsOrchestratorTrigger(IParameterSymbol parameter) + { + return parameter.Type.ToString() == "Microsoft.Azure.WebJobs.DurableOrchestrationContext" + && parameter.GetAttributes() + .Any(a => a.AttributeClass.ToString() == "Microsoft.Azure.WebJobs.OrchestrationTriggerAttribute"); + } + + private static async Task> GetActivityAndOrchestratorFunctionMethods(Project project) { var attributeType = typeof(FunctionNameAttribute); string[] attributeNames; @@ -124,7 +144,7 @@ bool attributeMatchesName(AttributeSyntax attribute) var root = await document.GetSyntaxRootAsync(); var model = await document.GetSemanticModelAsync(); - IEnumerable matchingMethods = root.DescendantNodes() + var functionMethods = root.DescendantNodes() .OfType() .Where(attributeMatchesName) // Project attribute -> attribute list -> method @@ -134,9 +154,13 @@ bool attributeMatchesName(AttributeSyntax attribute) // project to method declaration .Select(m => model.GetDeclaredSymbol(m)) // filter to just methods with attribute of the correct type - .Where(m => m.GetAttributes().Any(a => a.AttributeClass.ToString() == attributeType.FullName)) - .Where(MethodHasActivityTriggerParameter); - return matchingMethods; + .Where(m => m.GetAttributes().Any(a => a.AttributeClass.ToString() == attributeType.FullName)); + + + var actvityAndOrchestratorMethods = functionMethods + .Select(m => (functionType: GetFunctionType(m), method: m )) + .Where(f => f.functionType != FunctionType.Unknown); + return actvityAndOrchestratorMethods; }) .WaitAllAndUnwrap() ) diff --git a/src/Azure.Functions.Cli/Actions/DurableActions/DurableCreateHelpersAction_GenerateCode.cs b/src/Azure.Functions.Cli/Actions/DurableActions/DurableCreateHelpersAction_GenerateCode.cs index 9410ed577..661bae197 100644 --- a/src/Azure.Functions.Cli/Actions/DurableActions/DurableCreateHelpersAction_GenerateCode.cs +++ b/src/Azure.Functions.Cli/Actions/DurableActions/DurableCreateHelpersAction_GenerateCode.cs @@ -18,7 +18,7 @@ namespace Azure.Functions.Cli.Actions.DurableActions // Putting this method in a separate file for the inclusion of the SyntaxFactory methods internal partial class DurableCreateHelpersAction : BaseAction { - private static CompilationUnitSyntax GenerateCode(Project project, IEnumerable activityMethods) + private static CompilationUnitSyntax GenerateCode(Project project, IEnumerable<(FunctionType functionType, IMethodSymbol method)> activitiesAndOrchestrators) { var syntaxGenerator = SyntaxGenerator.GetGenerator(project); @@ -38,13 +38,13 @@ NameSyntax buildNameSyntax(string @namespace) } return nameSyntax; } - var activityMethodsByNamespace = activityMethods.GroupBy(m => m.ContainingNamespace); + var activitiesAndOrchestratorsByNamespace = activitiesAndOrchestrators.GroupBy(m => m.method.ContainingNamespace); - var generatedMethodsByNamespace = activityMethodsByNamespace.Select( + var generatedMethodsByNamespace = activitiesAndOrchestratorsByNamespace.Select( group => { var generatedMethods = group - .SelectMany(m => (MemberDeclarationSyntax[])GenerateMethodForActivity(m)); + .SelectMany(m => (MemberDeclarationSyntax[])GenerateMethod(m)); return new { Namespace = group.Key, @@ -83,36 +83,36 @@ NameSyntax buildNameSyntax(string @namespace) UsingDirective( QualifiedName( QualifiedName( - IdentifierName("System"), + IdentifierName("System"), IdentifierName("Collections") - ), + ), IdentifierName("Generic") ) ), UsingDirective( QualifiedName( QualifiedName( - IdentifierName("System"), + IdentifierName("System"), IdentifierName("Net") - ), + ), IdentifierName("Http") ) ), UsingDirective( QualifiedName( QualifiedName( - IdentifierName("System"), + IdentifierName("System"), IdentifierName("Threading") - ), + ), IdentifierName("Tasks") ) ), UsingDirective( QualifiedName( QualifiedName( - IdentifierName("Microsoft"), + IdentifierName("Microsoft"), IdentifierName("Azure") - ), + ), IdentifierName("WebJobs") ) ), @@ -121,13 +121,13 @@ NameSyntax buildNameSyntax(string @namespace) QualifiedName( QualifiedName( QualifiedName( - IdentifierName("Microsoft"), + IdentifierName("Microsoft"), IdentifierName("Azure") - ), + ), IdentifierName("WebJobs") - ), + ), IdentifierName("Extensions") - ), + ), IdentifierName("Http") ) ), @@ -135,11 +135,11 @@ NameSyntax buildNameSyntax(string @namespace) QualifiedName( QualifiedName( QualifiedName( - IdentifierName("Microsoft"), + IdentifierName("Microsoft"), IdentifierName("Azure") - ), + ), IdentifierName("WebJobs") - ), + ), IdentifierName("Host") ) ) @@ -155,7 +155,6 @@ NameSyntax buildNameSyntax(string @namespace) .NormalizeWhitespace(); } - private static MemberDeclarationSyntax GetContextExtensionPoint() { return NamespaceDeclaration( @@ -209,42 +208,119 @@ private static MemberDeclarationSyntax GetContextExtensionPoint() } ) ), - ClassDeclaration("DurableFunctionActivityExtensions") - .WithModifiers(TokenList(new[] {Token(SyntaxKind.PublicKeyword),Token(SyntaxKind.StaticKeyword)}) - ).WithMembers( - SingletonList( - MethodDeclaration( - IdentifierName("DurableFunctionActivityHelpers"), - Identifier("Activities") - ).WithModifiers(TokenList(new[] { Token(SyntaxKind.PublicKeyword), Token(SyntaxKind.StaticKeyword) })) - .WithParameterList( + ClassDeclaration("DurableFunctionSubOrchestrationHelpers") + .WithModifiers(TokenList(Token(SyntaxKind.PublicKeyword))) + .WithMembers( + List( + new MemberDeclarationSyntax[]{ + ConstructorDeclaration( + Identifier("DurableFunctionSubOrchestrationHelpers")) + .WithModifiers(TokenList(Token(SyntaxKind.PublicKeyword))) + .WithParameterList( ParameterList( SingletonSeparatedList( - Parameter(Identifier("context")) - .WithModifiers(TokenList(Token(SyntaxKind.ThisKeyword))) - .WithType(IdentifierName("DurableOrchestrationContext")) + Parameter(Identifier("context")).WithType(IdentifierName("DurableOrchestrationContext"))))) + .WithBody( + Block( + SingletonList( + ExpressionStatement( + AssignmentExpression( + SyntaxKind.SimpleAssignmentExpression, + IdentifierName("Context"), + IdentifierName("context")))))), + PropertyDeclaration( + IdentifierName("DurableOrchestrationContext"), + Identifier("Context")) + .WithModifiers( + TokenList( + Token(SyntaxKind.PublicKeyword))) + .WithAccessorList( + AccessorList( + List( + new AccessorDeclarationSyntax[]{ + AccessorDeclaration(SyntaxKind.GetAccessorDeclaration) + .WithSemicolonToken(Token(SyntaxKind.SemicolonToken)), + AccessorDeclaration(SyntaxKind.SetAccessorDeclaration) + .WithModifiers(TokenList(Token(SyntaxKind.PrivateKeyword))) + .WithSemicolonToken(Token(SyntaxKind.SemicolonToken))})))})), + + ClassDeclaration("DurableFunctionExtensions") + .WithModifiers(TokenList(new[] { Token(SyntaxKind.PublicKeyword), Token(SyntaxKind.StaticKeyword) })) + .WithMembers( + List( + new MemberDeclarationSyntax[]{ + MethodDeclaration( + IdentifierName("DurableFunctionActivityHelpers"), + Identifier("Activities") + ).WithModifiers(TokenList(new[] { Token(SyntaxKind.PublicKeyword), Token(SyntaxKind.StaticKeyword) })) + .WithParameterList( + ParameterList( + SingletonSeparatedList( + Parameter(Identifier("context")) + .WithModifiers(TokenList(Token(SyntaxKind.ThisKeyword))) + .WithType(IdentifierName("DurableOrchestrationContext")) + ) + ) + ).WithBody( + Block( + SingletonList( + ReturnStatement( + ObjectCreationExpression(IdentifierName("DurableFunctionActivityHelpers")) + .WithArgumentList( + ArgumentList( + SingletonSeparatedList(Argument(IdentifierName("context"))) + ) + ) + ) ) ) - ).WithBody( - Block( - SingletonList( - ReturnStatement( - ObjectCreationExpression(IdentifierName("DurableFunctionActivityHelpers")) - .WithArgumentList( - ArgumentList( - SingletonSeparatedList(Argument(IdentifierName("context"))) + ), + MethodDeclaration( + IdentifierName("DurableFunctionSubOrchestrationHelpers"), + Identifier("SubOrchestrators") + ).WithModifiers(TokenList(new[] { Token(SyntaxKind.PublicKeyword), Token(SyntaxKind.StaticKeyword) })) + .WithParameterList( + ParameterList( + SingletonSeparatedList( + Parameter(Identifier("context")) + .WithModifiers(TokenList(Token(SyntaxKind.ThisKeyword))) + .WithType(IdentifierName("DurableOrchestrationContext")) + ) + ) + ).WithBody( + Block( + SingletonList( + ReturnStatement( + ObjectCreationExpression(IdentifierName("DurableFunctionSubOrchestrationHelpers")) + .WithArgumentList( + ArgumentList( + SingletonSeparatedList(Argument(IdentifierName("context"))) + ) ) - ) + ) ) ) ) - ) - ) + }) ) } ) ); } + + private static MemberDeclarationSyntax[] GenerateMethod((FunctionType functionType, IMethodSymbol method) m) + { + switch (m.functionType) + { + case FunctionType.Activity: + return GenerateMethodForActivity(m.method); + case FunctionType.Orchestrator: + return GenerateMethodForOrchestrator(m.method); + default: + throw new NotSupportedException($"Unhandled function type: {m.functionType}"); + } + } + private static MethodDeclarationSyntax[] GenerateMethodForActivity(IMethodSymbol method) { // Ensure return type is Task/Task @@ -383,7 +459,98 @@ private static MethodDeclarationSyntax[] GenerateMethodForActivity(IMethodSymbol ), }; } + private static MethodDeclarationSyntax[] GenerateMethodForOrchestrator(IMethodSymbol method) + { + var returnType = GetAsyncTypeFromType(method.ReturnType); + var functionName = GetFunctionNameForMethod(method); + + var methodName = EnsureMethodNameEndsInAsync(method); + var methodNameWithRetry = methodName + .Substring(0, methodName.Length - 5) // strip Async + + "WithRetryAsync"; + + var callSubOrchestratorAsyncSyntax = GetCallSubOrchestratorAsyncSyntax(returnType); + var callSubOrchestratorWithRetryAsyncSyntax = GetCallSubOrchestratorAsyncSyntax(returnType, "CallSubOrchestratorWithRetryAsync"); + var identifierOrchestrationHelpers = IdentifierName("DurableFunctionSubOrchestrationHelpers"); + return new[] + { + MethodDeclaration(returnType, Identifier(methodName)) + .WithModifiers(TokenList(new []{ Token(SyntaxKind.PublicKeyword), Token(SyntaxKind.StaticKeyword)})) + .WithParameterList( + ParameterList( + SeparatedList( + new SyntaxNodeOrToken[]{ + Parameter(Identifier("orchestrationHelper")) + .WithModifiers(TokenList(Token(SyntaxKind.ThisKeyword))) + .WithType(identifierOrchestrationHelpers), + Token(SyntaxKind.CommaToken), + Parameter(Identifier("input")) + .WithType(PredefinedType(Token(SyntaxKind.ObjectKeyword)))}))) + .WithBody( + Block( + SingletonList( + (StatementSyntax) ReturnStatement( + InvocationExpression( + MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("orchestrationHelper"), + IdentifierName("Context")), + callSubOrchestratorAsyncSyntax)) + .WithArgumentList( + ArgumentList( + SeparatedList( + new SyntaxNodeOrToken[]{ + Argument( + LiteralExpression( + SyntaxKind.StringLiteralExpression, + Literal(functionName))), + Token(SyntaxKind.CommaToken), + Argument( + IdentifierName("input"))}))))))), + MethodDeclaration(returnType, Identifier(methodNameWithRetry)) + .WithModifiers(TokenList(new []{ Token(SyntaxKind.PublicKeyword), Token(SyntaxKind.StaticKeyword)})) + .WithParameterList( + ParameterList( + SeparatedList( + new SyntaxNodeOrToken[]{ + Parameter(Identifier("orchestrationHelper")) + .WithModifiers(TokenList(Token(SyntaxKind.ThisKeyword))) + .WithType(identifierOrchestrationHelpers), + Token(SyntaxKind.CommaToken), + Parameter(Identifier("retryOptions")) + .WithType(IdentifierName("RetryOptions")), + Token(SyntaxKind.CommaToken), + Parameter(Identifier("input")) + .WithType(PredefinedType(Token(SyntaxKind.ObjectKeyword)))}))) + .WithBody( + Block( + SingletonList( + (StatementSyntax) ReturnStatement( + InvocationExpression( + MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("orchestrationHelper"), + IdentifierName("Context")), + callSubOrchestratorWithRetryAsyncSyntax)) + .WithArgumentList( + ArgumentList( + SeparatedList( + new SyntaxNodeOrToken[]{ + Argument( + LiteralExpression( + SyntaxKind.StringLiteralExpression, + Literal(functionName))), + Token(SyntaxKind.CommaToken), + Argument(IdentifierName("retryOptions")), + Token(SyntaxKind.CommaToken), + Argument(IdentifierName("input"))}))))))) + }; + } private static string EnsureMethodNameEndsInAsync(IMethodSymbol method) { var methodName = method.Name; @@ -395,6 +562,7 @@ private static string EnsureMethodNameEndsInAsync(IMethodSymbol method) return methodName; } + /// /// Generate the Syntax for calling CallActivityAsync, including the type argument list if required /// @@ -426,6 +594,16 @@ private static SimpleNameSyntax GetCallActivityAsyncSyntax(TypeSyntax returnType return callActivityAsyncSyntax; } + /// + /// Generate the Syntax for calling CallActivityAsync, including the type argument list if required + /// + /// + /// + private static SimpleNameSyntax GetCallSubOrchestratorAsyncSyntax(TypeSyntax returnType, string methodName = "CallSubOrchestratorAsync") + { + // since the signatures are asligned between CallActivityAsync and CallSubOrchestratorAsync we can reuse GetCallActivityAsyncSyntax + return GetCallActivityAsyncSyntax(returnType, methodName); + } /// /// Get the FunctionName for the method based on the FunctionNameAttribute diff --git a/test/Azure.Functions.Cli.Tests/E2E/DurableCreateHelpersActionTests.cs b/test/Azure.Functions.Cli.Tests/E2E/DurableCreateHelpersActionTests.cs index c8edf989c..c45b95eac 100644 --- a/test/Azure.Functions.Cli.Tests/E2E/DurableCreateHelpersActionTests.cs +++ b/test/Azure.Functions.Cli.Tests/E2E/DurableCreateHelpersActionTests.cs @@ -87,10 +87,26 @@ await RunResourceTest( }); } + [SkippableFact] + public async Task suborchestrator_test() + { + await RunResourceTest( + taskHubName: "suborchestratorTest", + testFolderName: "SubOrchestratorTest", + resultVerifier: result => + { + Assert.NotNull(result); + var output = (string)result.output; + output.Should().Be("Hello Test!"); + }, + orchestrationStartUri: "/api/Parent_HttpStart"); + } + private async Task RunResourceTest( string taskHubName, string testFolderName, - Action resultVerifier) + Action resultVerifier, + string orchestrationStartUri = "/api/Function1_HttpStart") { Skip.If(string.IsNullOrEmpty(StorageConnectionString), reason: _storageReason); @@ -118,7 +134,7 @@ await CliTester.Run(new RunConfiguration await Task.Delay(TimeSpan.FromSeconds(15)); using (var client = new HttpClient() { BaseAddress = new Uri("http://localhost:7073") }) { - var statusUri = await StartNewOrchestrationAsync(client, "/api/Function1_HttpStart"); + var statusUri = await StartNewOrchestrationAsync(client, orchestrationStartUri); dynamic result = await WaitForCompletionAsync( client, statusUri, diff --git a/test/Azure.Functions.Cli.Tests/Resources/DurableCreateHelperTestProjects/SubOrchestratorTest/Function1.cs b/test/Azure.Functions.Cli.Tests/Resources/DurableCreateHelperTestProjects/SubOrchestratorTest/Function1.cs new file mode 100644 index 000000000..50f185822 --- /dev/null +++ b/test/Azure.Functions.Cli.Tests/Resources/DurableCreateHelperTestProjects/SubOrchestratorTest/Function1.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.Azure.WebJobs; +using Microsoft.Azure.WebJobs.Extensions.Http; +using Microsoft.Azure.WebJobs.Host; +using Microsoft.Extensions.Logging; + +namespace BaseCallActivityTest +{ + public static class Function1 + { + // Project for testing Durable type-safe helpers + // SubOrchestrator tests cover ?????????????????????????????????????????????????????????????? + + + [FunctionName("Parent")] + public static async Task ParentOrchestrator( + [OrchestrationTrigger] DurableOrchestrationContext context) + { + var name = context.GetInput(); + var retryOptions = new RetryOptions(TimeSpan.FromSeconds(10), 2); + + // non-retry call to orchestrator that returns a value + var result = await context.SubOrchestrators().ChildOrchestratorAsync(name); + // retry call to orchestrator that returns a value + result = await context.SubOrchestrators().ChildOrchestratorWithRetryAsync(retryOptions, name); + + // non-retry call to orchestrator that doesn't return a value + await context.SubOrchestrators().VoidOrchestratorAsync("void"); + // retry call to orchestrator that returns a value + await context.SubOrchestrators().VoidOrchestratorWithRetryAsync(retryOptions, "void"); + + return result; + } + + [FunctionName("Child")] + public static async Task ChildOrchestrator( + [OrchestrationTrigger] DurableOrchestrationContext context) + { + var input = context.GetInput(); + var result = await context.Activities().SayHelloAsync(input); + + return result; + } + [FunctionName("Void")] + public static async Task VoidOrchestrator( + [OrchestrationTrigger] DurableOrchestrationContext context) + { + var input = context.GetInput(); + var result = await context.Activities().SayHelloAsync(input); + + return; + } + + [FunctionName("Function1_Hello")] + public static string SayHello([ActivityTrigger] string name, ILogger log) // Standard activity call + { + log.LogInformation($"Saying hello to {name}."); + return $"Hello {name}!"; + } + + [FunctionName("Parent_HttpStart")] + public static async Task HttpStart( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", "post")]HttpRequestMessage req, + [OrchestrationClient]DurableOrchestrationClient starter, + ILogger log) + { + // Function input comes from the request content. + string instanceId = await starter.StartNewAsync("Parent", "Test"); + + log.LogInformation($"Started orchestration with ID = '{instanceId}'."); + + return starter.CreateCheckStatusResponse(req, instanceId); + } + } +} \ No newline at end of file diff --git a/test/Azure.Functions.Cli.Tests/Resources/DurableCreateHelperTestProjects/SubOrchestratorTest/SubOrchestratorTest.csproj b/test/Azure.Functions.Cli.Tests/Resources/DurableCreateHelperTestProjects/SubOrchestratorTest/SubOrchestratorTest.csproj new file mode 100644 index 000000000..d5dcc9d20 --- /dev/null +++ b/test/Azure.Functions.Cli.Tests/Resources/DurableCreateHelperTestProjects/SubOrchestratorTest/SubOrchestratorTest.csproj @@ -0,0 +1,19 @@ + + + netcoreapp2.1 + v2 + + + + + + + + PreserveNewest + + + PreserveNewest + Never + + + \ No newline at end of file diff --git a/test/Azure.Functions.Cli.Tests/Resources/DurableCreateHelperTestProjects/SubOrchestratorTest/host.json b/test/Azure.Functions.Cli.Tests/Resources/DurableCreateHelperTestProjects/SubOrchestratorTest/host.json new file mode 100644 index 000000000..b5d6c91e5 --- /dev/null +++ b/test/Azure.Functions.Cli.Tests/Resources/DurableCreateHelperTestProjects/SubOrchestratorTest/host.json @@ -0,0 +1,8 @@ +{ + "version": "2.0", + "extensions": { + "durableTask": { + "HubName": "suborchestratorTest" + } + } +} \ No newline at end of file