diff --git a/Net.Sdk.Web.Extensions.SourceGenerators/Constants.cs b/Net.Sdk.Web.Extensions.SourceGenerators/Constants.cs index 2ae0288..e132e90 100644 --- a/Net.Sdk.Web.Extensions.SourceGenerators/Constants.cs +++ b/Net.Sdk.Web.Extensions.SourceGenerators/Constants.cs @@ -12,6 +12,9 @@ public static class Constants public const string IntType = "int"; public const string ObjectType = "object"; public const string Default = "default"; + public const string Typeof = "typeof"; + public const string Readonly = "readonly"; + public const string Async = "async"; public const string Namespace = "Net.Sdk.Web"; public const string UsingSystemThreading = "System.Threading"; public const string UsingSystemRuntimeCompilerServices = "System.Runtime.CompilerServices"; @@ -81,9 +84,38 @@ public static class Constants public const string RouteFilterAttributeShortName = "RouteFilter"; public const string RouteFilterTypePropertyName = "RouteFilterType"; + public const string DeferredResultClass = "DeferredResult"; + public const string IResultInterface = "IResult"; + public const string Task = "Task"; + public const string ExecuteAsyncMethodName = "ExecuteAsync"; + public const string T = "T"; public const string IEndpointFilterName = "IEndpointFilter"; public const string MethodImplAttribute = "MethodImpl"; public const string MethodImplArgument = "MethodImplOptions.NoInlining | MethodImplOptions.NoOptimization | MethodImplOptions.PreserveSig"; + + public const string ProducesResponseTypeType = "Microsoft.AspNetCore.Mvc.ProducesResponseTypeAttribute"; + public const string OpenApiConvetionBuilderExtensionsType = "Microsoft.AspNetCore.Builder.OpenApiEndpointConventionBuilderExtensions"; + + public const string EndpointNameAttributeName = "EndpointNameAttribute"; + public const string EndpointNameAttributeShortName = "EndpointName"; + public const string WithNameExtension = "WithName"; + public const string EndpointSummaryAttributeName = "EndpointSummaryAttribute"; + public const string EndpointSummaryAttributeShortName = "EndpointSummary"; + public const string WithSummaryExtension = "WithSummary"; + public const string EndpointDescriptionAttributeName = "EndpointDescriptionAttribute"; + public const string EndpointDescriptionAttributeShortName = "EndpointDescription"; + public const string WithDescriptionExtension = "WithDescription"; + public const string ProducesAttributeName = "ProducesAttribute"; + public const string ProducesAttributeShortName = "Produces"; + public const string ProducesResponseTypeAttributeName = "ProducesResponseTypeAttribute"; + public const string ProducesResponseTypeAttributeShortName = "ProducesResponseType"; + public const string AuthorizeAttributeName = "AuthorizeAttribute"; + public const string AuthorizeAttributeShortName = "Authorize"; + + public const string RequiresAuthorization = "RequireAuthorization"; + public const string WithOpenApiExtension = "WithOpenApi"; + + public const string Default200Status = "StatusCodes.Status200OK"; } diff --git a/Net.Sdk.Web.Extensions.SourceGenerators/DeferredResultGenerator.cs b/Net.Sdk.Web.Extensions.SourceGenerators/DeferredResultGenerator.cs new file mode 100644 index 0000000..b2a41fa --- /dev/null +++ b/Net.Sdk.Web.Extensions.SourceGenerators/DeferredResultGenerator.cs @@ -0,0 +1,48 @@ +using Microsoft.CodeAnalysis; +using Sybil; +using System; + +namespace Net.Sdk.Web.Extensions.SourceGenerators; + +[Generator(LanguageNames.CSharp)] +#nullable enable +public class DeferredResultGenerator : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext context) + { + context.RegisterPostInitializationOutput(GenerateRouteFilterAttribute); + } + + private static void GenerateRouteFilterAttribute(IncrementalGeneratorPostInitializationContext context) + { + var builder = SyntaxBuilder.CreateCompilationUnit() + .WithNamespace( + SyntaxBuilder.CreateNamespace(Constants.Namespace) + .WithUsing(Constants.UsingSystemThreading) + .WithUsing(Constants.UsingMicrosoftAspNetCoreHttp) + .WithClass(SyntaxBuilder.CreateClass(Constants.DeferredResultClass) + .WithBaseClass(Constants.IResultTypeName) + .WithModifier(Constants.Public) + .WithModifier(Constants.Sealed) + .WithField(SyntaxBuilder.CreateField($"Task<{Constants.IResultInterface}>", "inner") + .WithModifier(Constants.Public) + .WithModifier(Constants.Readonly)) + .WithConstructor(SyntaxBuilder.CreateConstructor(Constants.DeferredResultClass) + .WithModifier(Constants.Public) + .WithParameter($"Task<{Constants.IResultInterface}>", "inner") + .WithBody("this.inner = inner;")) + .WithMethod(SyntaxBuilder.CreateMethod(Constants.Task, Constants.ExecuteAsyncMethodName) + .WithParameter("HttpContext", "httpContext") + .WithModifier(Constants.Public) + .WithModifier(Constants.Async) + .WithBody(@$" + var result = await this.inner; + await result.ExecuteAsync(httpContext);")))); + + var syntax = builder.Build(); + var source = syntax.ToFullString(); + context.AddSource($"{Constants.DeferredResultClass}.g", $"{source}"); + } +} + +#nullable disable \ No newline at end of file diff --git a/Net.Sdk.Web.Extensions.SourceGenerators/Net.Sdk.Web.Extensions.SourceGenerators.csproj b/Net.Sdk.Web.Extensions.SourceGenerators/Net.Sdk.Web.Extensions.SourceGenerators.csproj index 1e04d7d..4cd9dc8 100644 --- a/Net.Sdk.Web.Extensions.SourceGenerators/Net.Sdk.Web.Extensions.SourceGenerators.csproj +++ b/Net.Sdk.Web.Extensions.SourceGenerators/Net.Sdk.Web.Extensions.SourceGenerators.csproj @@ -2,7 +2,7 @@ netstandard2.0 - 0.9.0 + 0.9.1 latest true Alexandru Macocian @@ -13,6 +13,7 @@ true true true + Enable diff --git a/Net.Sdk.Web.Extensions.SourceGenerators/UseRoutesGenerator.cs b/Net.Sdk.Web.Extensions.SourceGenerators/UseRoutesGenerator.cs index 782645e..f7a6213 100644 --- a/Net.Sdk.Web.Extensions.SourceGenerators/UseRoutesGenerator.cs +++ b/Net.Sdk.Web.Extensions.SourceGenerators/UseRoutesGenerator.cs @@ -2,8 +2,10 @@ using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using Sybil; +using System; using System.Collections.Generic; using System.Collections.Immutable; +using System.Diagnostics; using System.Linq; using System.Text; @@ -64,6 +66,13 @@ public class UseRoutesGenerator : IIncrementalGenerator return; } + var openApiSupport = false; + if (compilation.GetTypeByMetadataName(Constants.ProducesResponseTypeType) is not null && + compilation.GetTypeByMetadataName(Constants.OpenApiConvetionBuilderExtensionsType) is not null) + { + openApiSupport = true; + } + var languageVersion = maybeLanguageVersion.Value; var routeUsings = new HashSet { @@ -83,9 +92,7 @@ public class UseRoutesGenerator : IIncrementalGenerator namespaceBuilder.WithClass(webAppBuilder); var useRoutesWebAppMethodBuilder = SyntaxBuilder.CreateMethod(Constants.WebApplicationTypeName, Constants.UseRoutesMethodName) .WithThisParameter(Constants.WebApplicationTypeName, Constants.BuilderParameterName) - .WithModifiers($"{Constants.Public} {Constants.Static}") - .WithAttribute(SyntaxBuilder.CreateAttribute(Constants.MethodImplAttribute) - .WithRawArgument(Constants.MethodImplArgument)); + .WithModifiers($"{Constants.Public} {Constants.Static}"); webAppBuilder.WithMethod(useRoutesWebAppMethodBuilder); var useRoutesWebAppBody = new StringBuilder(); @@ -157,22 +164,22 @@ public class UseRoutesGenerator : IIncrementalGenerator var finalPattern = $"{outerPattern}{innerPattern}".Replace("//", "/"); if (methodAttributes.FirstOrDefault(a => a.Name.ToString() is Constants.GetAttributeName or Constants.GetAttributeShortName) is not null && - GetMethodBodyByType("Get", finalPattern, classDeclarationSyntax, methodDeclarationSyntax, compilation, routeUsings) is string getMethodBody) + GetMethodBodyByType("Get", finalPattern, classDeclarationSyntax, methodDeclarationSyntax, compilation, routeUsings, openApiSupport) is string getMethodBody) { useRoutesWebAppBody.AppendLine(getMethodBody); } else if (methodAttributes.FirstOrDefault(a => a.Name.ToString() is Constants.PostAttributeName or Constants.PostAttributeShortName) is not null && - GetMethodBodyByType("Post", finalPattern, classDeclarationSyntax, methodDeclarationSyntax, compilation, routeUsings) is string postMethodBody) + GetMethodBodyByType("Post", finalPattern, classDeclarationSyntax, methodDeclarationSyntax, compilation, routeUsings, openApiSupport) is string postMethodBody) { useRoutesWebAppBody.AppendLine(postMethodBody); } else if (methodAttributes.FirstOrDefault(a => a.Name.ToString() is Constants.PutAttributeName or Constants.PutAttributeShortName) is not null && - GetMethodBodyByType("Put", finalPattern, classDeclarationSyntax, methodDeclarationSyntax, compilation, routeUsings) is string putMethodBoty) + GetMethodBodyByType("Put", finalPattern, classDeclarationSyntax, methodDeclarationSyntax, compilation, routeUsings, openApiSupport) is string putMethodBoty) { useRoutesWebAppBody.AppendLine(putMethodBoty); } else if (methodAttributes.FirstOrDefault(a => a.Name.ToString() is Constants.DeleteAttributeName or Constants.DeleteAttributeShortName) is not null && - GetMethodBodyByType("Delete", finalPattern, classDeclarationSyntax, methodDeclarationSyntax, compilation, routeUsings) is string deleteMethodBody) + GetMethodBodyByType("Delete", finalPattern, classDeclarationSyntax, methodDeclarationSyntax, compilation, routeUsings, openApiSupport) is string deleteMethodBody) { useRoutesWebAppBody.AppendLine(deleteMethodBody); } @@ -196,54 +203,55 @@ public class UseRoutesGenerator : IIncrementalGenerator ClassDeclarationSyntax classDeclarationSyntax, MethodDeclarationSyntax methodDeclarationSyntax, Compilation compilation, - HashSet usings) + HashSet usings, + bool openApiSupport) { // Go over all attributes on the method and parent class and find all RouteFilters var semanticModel = compilation.GetSemanticModel(methodDeclarationSyntax.SyntaxTree); - var routeFilterSb = new StringBuilder(); - foreach(var attribute in methodDeclarationSyntax.AttributeLists + var extensionsBuilder = new StringBuilder(); + + foreach (var attribute in methodDeclarationSyntax.AttributeLists .Concat(classDeclarationSyntax.AttributeLists) .SelectMany(a => a.Attributes) - .OfType() - .Where(attribute => - { - var attributeSymbol = semanticModel.GetSymbolInfo(attribute).Symbol as IMethodSymbol; - if (attributeSymbol?.ContainingType == null) - { - return false; - } - - var attributeType = attributeSymbol.ContainingType; - if (attributeType.Name is not Constants.RouteFilterAttributeName or Constants.RouteFilterAttributeShortName) - { - return false; - } - - return true; - })) + .OfType()) { - if (attribute.Name is not GenericNameSyntax genericNameSyntax || genericNameSyntax.TypeArgumentList.Arguments.Count != 1) + var attributeSymbol = semanticModel.GetSymbolInfo(attribute).Symbol as IMethodSymbol; + AddRouteFilters(semanticModel, attribute, attributeSymbol, extensionsBuilder, usings); + if (openApiSupport) { - continue; - } + AddAttributeExtension( + attribute, + attributeSymbol, + extensionsBuilder, + Constants.EndpointNameAttributeName, + Constants.EndpointNameAttributeShortName, + Constants.WithNameExtension); - var routeFilterTypeSyntax = genericNameSyntax.TypeArgumentList.Arguments[0]; - if (semanticModel.GetSymbolInfo(routeFilterTypeSyntax).Symbol is not ITypeSymbol routeFilterTypeSymbol) - { - continue; - } + AddAttributeExtension( + attribute, + attributeSymbol, + extensionsBuilder, + Constants.EndpointSummaryAttributeName, + Constants.EndpointSummaryAttributeShortName, + Constants.WithSummaryExtension); - var routeFilterType = routeFilterTypeSymbol.ToDisplayString(); - var namespaceIndex = routeFilterType.LastIndexOf("."); - if (namespaceIndex > 0) - { - var routeFilterNamespace = routeFilterType.Substring(0, namespaceIndex); - routeFilterType = routeFilterType.Substring(namespaceIndex + 1); - usings.Add(routeFilterNamespace); - } + AddAttributeExtension( + attribute, + attributeSymbol, + extensionsBuilder, + Constants.EndpointDescriptionAttributeName, + Constants.EndpointDescriptionAttributeShortName, + Constants.WithDescriptionExtension); - routeFilterSb.Append('\n') - .Append(@$".AddEndpointFilter<{routeFilterType}>()"); + AddAuthorizeExtension(attribute, attributeSymbol, extensionsBuilder); + } + } + + if (openApiSupport) + { + var methodSymbol = semanticModel.GetDeclaredSymbol(methodDeclarationSyntax); + AddProducesResponseTypeExtension(methodSymbol, extensionsBuilder); + extensionsBuilder.AppendLine($"\n.{Constants.WithOpenApiExtension}()"); } /* @@ -255,32 +263,143 @@ public class UseRoutesGenerator : IIncrementalGenerator var variables = string.Join(", ", methodDeclarationSyntax.ParameterList.Parameters .Select(param => param.Identifier.Text)); + var newMethodName = $"{classDeclarationSyntax.Identifier}{methodDeclarationSyntax.Identifier}"; /* * Generate an async lambda for Task or a synchronous lambda for IResult returns. If neither are matched, do not generate any method */ var returnTypeSymbol = semanticModel.GetSymbolInfo(methodDeclarationSyntax.ReturnType).Symbol as ITypeSymbol; - return returnTypeSymbol?.ToDisplayString() switch + var returnType = returnTypeSymbol?.ToDisplayString(); + var isAsync = returnType switch { - "System.Threading.Tasks.Task" => @$" - builder.Map{type}(""{pattern}"", async (HttpContext httpContext, {classDeclarationSyntax.Identifier} route{(parameters.Length > 0 ? $", {parameters}" : "")}) => - {{ - var cancellationToken = httpContext.RequestAborted; - return await route.{methodDeclarationSyntax.Identifier}({variables}); - }}){routeFilterSb};", - "Microsoft.AspNetCore.Http.IResult" => @$" - builder.Map{type}(""{pattern}"", (HttpContext httpContext, {classDeclarationSyntax.Identifier} route{(parameters.Length > 0 ? $", {parameters}" : "")}) => - {{ - var cancellationToken = httpContext.RequestAborted; - return route.{methodDeclarationSyntax.Identifier}({variables}); - }}){routeFilterSb};", - "System.Threading.Tasks.ValueTask" => @$" - builder.Map{type}(""{pattern}"", (HttpContext httpContext, {classDeclarationSyntax.Identifier} route{(parameters.Length > 0 ? $", {parameters}" : "")}) => - {{ - var cancellationToken = httpContext.RequestAborted; - return route.{methodDeclarationSyntax.Identifier}({variables}); - }}){routeFilterSb};", + "System.Threading.Tasks.Task" => (bool?)true, + "System.Threading.Tasks.ValueTask" => (bool?)true, + "Microsoft.AspNetCore.Http.IResult" => (bool?)false, _ => default }; + + if (isAsync is null) + { + return default; + } + + var lambdaBody = @$" + builder.Map{type}(""{pattern}"", static (HttpContext httpContext, {classDeclarationSyntax.Identifier} route{(parameters.Length > 0 ? $", {parameters}" : "")}) => + {{ + var cancellationToken = httpContext.RequestAborted; + return {(isAsync is true ? $"new DeferredResult(route.{methodDeclarationSyntax.Identifier}({variables}))" : $"route.{methodDeclarationSyntax.Identifier}({variables})")}; + }}) + {extensionsBuilder};"; + return lambdaBody; + } + + private static void AddProducesResponseTypeExtension( + IMethodSymbol? methodSymbol, + StringBuilder extensionsBuilder) + { + foreach (var attributeData in methodSymbol?.GetAttributes() ?? []) + { + var attrClassName = attributeData.AttributeClass?.Name; + if (attrClassName is not Constants.ProducesResponseTypeAttributeName or Constants.ProducesResponseTypeAttributeShortName) + { + continue; + } + + var typeArg = (attributeData.NamedArguments.FirstOrDefault(na => na.Key == "Type").Value.Value as ITypeSymbol)?.ToString(); + if (attributeData.ApplicationSyntaxReference?.GetSyntax() is AttributeSyntax attributeSyntax && + attributeSyntax.Name is GenericNameSyntax genericNameSyntax && + genericNameSyntax.TypeArgumentList.Arguments.Count == 1) + { + typeArg = genericNameSyntax.TypeArgumentList.Arguments[0].ToString(); + } + + var statusCodeArg = attributeData.NamedArguments.FirstOrDefault(na => na.Key == "StatusCode").Value.Value as int? ?? 200; + if (typeArg is null) + { + extensionsBuilder.Append($"\n.{Constants.ProducesAttributeShortName}(statusCode: {statusCodeArg})"); + } + else + { + extensionsBuilder.Append($"\n.{Constants.ProducesAttributeShortName}(statusCode: {statusCodeArg}, responseType: typeof({typeArg}))"); + } + } + } + + private static void AddAuthorizeExtension( + AttributeSyntax attributeSyntax, + IMethodSymbol? attributeSymbol, + StringBuilder extensionsBuilder) + { + if (attributeSymbol?.ContainingType is not INamedTypeSymbol attributeType || + attributeType.Name is not Constants.AuthorizeAttributeName or Constants.AuthorizeAttributeShortName) + { + return; + } + + if (attributeSyntax.ArgumentList?.Arguments.Count > 0) + { + var policy = attributeSyntax.ArgumentList.Arguments[0].ToString().Trim('"'); + extensionsBuilder.Append($"\n.{Constants.RequiresAuthorization}(\"{policy}\")"); + } + else + { + extensionsBuilder.Append($"\n.{Constants.RequiresAuthorization}()"); + } + } + + private static void AddAttributeExtension( + AttributeSyntax attributeSyntax, + IMethodSymbol? attributeSymbol, + StringBuilder extensionsBuilder, + string attributeName, + string attributeShortName, + string extensionMethod) + { + if (attributeSymbol?.ContainingType is not INamedTypeSymbol attributeType || + (attributeType.Name != attributeName && attributeType.Name != attributeShortName)) + { + return; + } + + if (attributeSyntax.ArgumentList is null || + attributeSyntax.ArgumentList.Arguments.Count <= 0) + { + return; + } + + var name = attributeSyntax.ArgumentList.Arguments[0].ToString().Trim('"'); + extensionsBuilder.Append($"\n.{extensionMethod}(\"{name}\")"); + } + + private static void AddRouteFilters( + SemanticModel semanticModel, + AttributeSyntax attributeSyntax, + IMethodSymbol? attributeSymbol, + StringBuilder extensionsBuilder, + HashSet usings) + { + if (attributeSymbol?.ContainingType is not INamedTypeSymbol attributeType || + attributeType.Name is not Constants.RouteFilterAttributeName or Constants.RouteFilterAttributeShortName || + attributeSyntax.Name is not GenericNameSyntax genericNameSyntax || genericNameSyntax.TypeArgumentList.Arguments.Count != 1) + { + return; + } + + var routeFilterTypeSyntax = genericNameSyntax.TypeArgumentList.Arguments[0]; + if (semanticModel.GetSymbolInfo(routeFilterTypeSyntax).Symbol is not ITypeSymbol routeFilterTypeSymbol) + { + return; + } + + var routeFilterType = routeFilterTypeSymbol.ToDisplayString(); + var namespaceIndex = routeFilterType.LastIndexOf("."); + if (namespaceIndex > 0) + { + var routeFilterNamespace = routeFilterType.Substring(0, namespaceIndex); + routeFilterType = routeFilterType.Substring(namespaceIndex + 1); + usings.Add(routeFilterNamespace); + } + + extensionsBuilder.Append(@$"\n.AddEndpointFilter<{routeFilterType}>()"); } private static string GetMethodParameters(MethodDeclarationSyntax methodDeclaration) @@ -303,31 +422,6 @@ public class UseRoutesGenerator : IIncrementalGenerator return parametersString; } - public static string? GetRouteFilterTypeName(SemanticModel semanticModel, AttributeSyntax attributeSyntax) - { - var routeFilterTypeArgument = attributeSyntax.ArgumentList?.Arguments - .FirstOrDefault(arg => arg.NameEquals?.Name.Identifier.Text == Constants.RouteFilterTypePropertyName); - - if (routeFilterTypeArgument is null) - { - return default; - } - - if (routeFilterTypeArgument.Expression is not TypeOfExpressionSyntax typeExpression) - { - return default; - } - - var typeSymbol = semanticModel.GetTypeInfo(typeExpression.Type); - if (typeSymbol.Type is INamedTypeSymbol namedTypeSymbol) - { - // Get the fully qualified name (namespace + type name) - return namedTypeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - } - - return default; - } - private static T? GetParentOfType(SyntaxNode syntaxNode) { if (syntaxNode.Parent is null)