From aead4d55425dcb177718e3eb6f6ef813fb71fa53 Mon Sep 17 00:00:00 2001 From: Jezz Santos Date: Sun, 8 Oct 2023 21:46:39 +1300 Subject: [PATCH] Added unit tests Generators and Analyzers --- .../IWebRequest.AspNet.cs | 12 + .../IWebRequest.cs | 8 +- src/SaaStack.sln | 9 + src/SaaStack.sln.DotSettings | 4 + .../MissingDocsAnalyzerSpec.cs | 110 ++-- .../Tools.Analyzers.Core.UnitTests.csproj | 1 - src/Tools.Analyzers.Core/IWebRequest.cs | 19 - .../Tools.Analyzers.Core.csproj | 7 +- .../MinimalApiMediatRGeneratorSpec.cs | 486 ++++++++++++++++++ .../Tools.Generators.WebApi.UnitTests.csproj | 20 + .../WebApiAssemblyVisitorSpec.cs | 433 ++++++++++++++++ .../Extensions/SymbolExtensions.cs | 93 ++++ .../MinimalApiMediatRGenerator.cs | 14 +- src/Tools.Generators.WebApi/README.md | 4 +- .../Tools.Generators.WebApi.csproj | 9 + .../WebApiAssemblyVisitor.cs | 148 ++---- 16 files changed, 1212 insertions(+), 165 deletions(-) create mode 100644 src/Infrastructure.WebApi.Interfaces/IWebRequest.AspNet.cs delete mode 100644 src/Tools.Analyzers.Core/IWebRequest.cs create mode 100644 src/Tools.Generators.WebApi.UnitTests/MinimalApiMediatRGeneratorSpec.cs create mode 100644 src/Tools.Generators.WebApi.UnitTests/Tools.Generators.WebApi.UnitTests.csproj create mode 100644 src/Tools.Generators.WebApi.UnitTests/WebApiAssemblyVisitorSpec.cs create mode 100644 src/Tools.Generators.WebApi/Extensions/SymbolExtensions.cs diff --git a/src/Infrastructure.WebApi.Interfaces/IWebRequest.AspNet.cs b/src/Infrastructure.WebApi.Interfaces/IWebRequest.AspNet.cs new file mode 100644 index 00000000..7368acb6 --- /dev/null +++ b/src/Infrastructure.WebApi.Interfaces/IWebRequest.AspNet.cs @@ -0,0 +1,12 @@ +using MediatR; +using Microsoft.AspNetCore.Http; + +namespace Infrastructure.WebApi.Interfaces; + +/// +/// Defines a incoming REST request and response. +/// Note: is required for the MediatR handlers to be wired up +/// +public partial interface IWebRequest : IRequest +{ +} diff --git a/src/Infrastructure.WebApi.Interfaces/IWebRequest.cs b/src/Infrastructure.WebApi.Interfaces/IWebRequest.cs index 192e0bb5..40fb458b 100644 --- a/src/Infrastructure.WebApi.Interfaces/IWebRequest.cs +++ b/src/Infrastructure.WebApi.Interfaces/IWebRequest.cs @@ -1,13 +1,11 @@ -using MediatR; -using Microsoft.AspNetCore.Http; - namespace Infrastructure.WebApi.Interfaces; /// /// Defines a incoming REST request and response. -/// Note: is required for the MediatR handlers to be wired up +/// Note: we have split this interface definition so it can be reused in Roslyn components /// -public interface IWebRequest : IRequest +// ReSharper disable once PartialTypeWithSinglePart +public partial interface IWebRequest { } diff --git a/src/SaaStack.sln b/src/SaaStack.sln index 265bdf20..b697d0b8 100644 --- a/src/SaaStack.sln +++ b/src/SaaStack.sln @@ -124,6 +124,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tools.Templates", "Tools.Te EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Application.Common.UnitTests", "Application.Common.UnitTests\Application.Common.UnitTests.csproj", "{A67A4EA1-58CF-41ED-AEED-591D4A8A0633}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tools.Generators.WebApi.UnitTests", "Tools.Generators.WebApi.UnitTests\Tools.Generators.WebApi.UnitTests.csproj", "{64762CCC-834E-47C9-AFAD-B88DB80E3FF8}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -311,6 +313,12 @@ Global {A67A4EA1-58CF-41ED-AEED-591D4A8A0633}.Release|Any CPU.Build.0 = Release|Any CPU {A67A4EA1-58CF-41ED-AEED-591D4A8A0633}.ReleaseForDeploy|Any CPU.ActiveCfg = ReleaseForDeploy|Any CPU {A67A4EA1-58CF-41ED-AEED-591D4A8A0633}.ReleaseForDeploy|Any CPU.Build.0 = ReleaseForDeploy|Any CPU + {64762CCC-834E-47C9-AFAD-B88DB80E3FF8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {64762CCC-834E-47C9-AFAD-B88DB80E3FF8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {64762CCC-834E-47C9-AFAD-B88DB80E3FF8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {64762CCC-834E-47C9-AFAD-B88DB80E3FF8}.Release|Any CPU.Build.0 = Release|Any CPU + {64762CCC-834E-47C9-AFAD-B88DB80E3FF8}.ReleaseForDeploy|Any CPU.ActiveCfg = ReleaseForDeploy|Any CPU + {64762CCC-834E-47C9-AFAD-B88DB80E3FF8}.ReleaseForDeploy|Any CPU.Build.0 = ReleaseForDeploy|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {F5C77A86-38AF-40E4-82FC-617E624B2754} = {508E7DA4-4DF2-4201-955D-CCF70C41AD05} @@ -368,5 +376,6 @@ Global {AC380EA5-16A1-4713-99B4-F259F5397F30} = {4B1A213C-36A7-41A7-BFC7-B3CFF5795912} {CDA1C120-6847-4486-863D-875E47291A50} = {BAE0D6F2-6920-4B02-9F30-D71B04B7170D} {A67A4EA1-58CF-41ED-AEED-591D4A8A0633} = {5B6DE1D9-649A-47EE-A565-0B641B7838FF} + {64762CCC-834E-47C9-AFAD-B88DB80E3FF8} = {A25A3BA8-5602-4825-9595-2CF96B166920} EndGlobalSection EndGlobal diff --git a/src/SaaStack.sln.DotSettings b/src/SaaStack.sln.DotSettings index 4d67d724..5a19b6c6 100644 --- a/src/SaaStack.sln.DotSettings +++ b/src/SaaStack.sln.DotSettings @@ -371,6 +371,7 @@ public void When$condition$_Then$outcome$() True True True + True True True True @@ -378,6 +379,7 @@ public void When$condition$_Then$outcome$() True True True + True True True True @@ -387,6 +389,8 @@ public void When$condition$_Then$outcome$() True True True + True + True True True True diff --git a/src/Tools.Analyzers.Core.UnitTests/MissingDocsAnalyzerSpec.cs b/src/Tools.Analyzers.Core.UnitTests/MissingDocsAnalyzerSpec.cs index 79bd4047..88ece382 100644 --- a/src/Tools.Analyzers.Core.UnitTests/MissingDocsAnalyzerSpec.cs +++ b/src/Tools.Analyzers.Core.UnitTests/MissingDocsAnalyzerSpec.cs @@ -14,7 +14,8 @@ public class GivenRuleSas001 [Fact] public async Task WhenInJetbrainsAnnotationsNamespace_ThenNoAlert() { - const string input = @"namespace JetBrains.Annotations; + const string input = @" +namespace JetBrains.Annotations; public class AClass { }"; @@ -25,7 +26,8 @@ public class AClass [Fact] public async Task WhenInApiHost1Namespace_ThenNoAlert() { - const string input = @"namespace ApiHost1; + const string input = @" +namespace ApiHost1; public class AClass { }"; @@ -52,107 +54,118 @@ public async Task WhenInternalDelegate_ThenAlerts() [Fact] public async Task WhenPublicInterface_ThenAlerts() { - const string input = @"public interface AnInterface + const string input = @" +public interface AnInterface { }"; - await Verify.DiagnosticExists(MissingDocsAnalyzer.Sas001, input, 1, 18, "AnInterface"); + await Verify.DiagnosticExists(MissingDocsAnalyzer.Sas001, input, 2, 18, "AnInterface"); } [Fact] public async Task WhenInternalInterface_ThenAlerts() { - const string input = @"internal interface AnInterface + const string input = @" +internal interface AnInterface { }"; - await Verify.DiagnosticExists(MissingDocsAnalyzer.Sas001, input, 1, 20, "AnInterface"); + await Verify.DiagnosticExists(MissingDocsAnalyzer.Sas001, input, 2, 20, "AnInterface"); } [Fact] public async Task WhenPublicEnum_ThenAlerts() { - const string input = @"public enum AnEnum + const string input = @" +public enum AnEnum { }"; - await Verify.DiagnosticExists(MissingDocsAnalyzer.Sas001, input, 1, 13, "AnEnum"); + await Verify.DiagnosticExists(MissingDocsAnalyzer.Sas001, input, 2, 13, "AnEnum"); } [Fact] public async Task WhenInternalEnum_ThenAlerts() { - const string input = @"internal enum AnEnum + const string input = @" +internal enum AnEnum { }"; - await Verify.DiagnosticExists(MissingDocsAnalyzer.Sas001, input, 1, 15, "AnEnum"); + await Verify.DiagnosticExists(MissingDocsAnalyzer.Sas001, input, 2, 15, "AnEnum"); } [Fact] public async Task WhenPublicStruct_ThenAlerts() { - const string input = @"public struct AStruct + const string input = @" +public struct AStruct { }"; - await Verify.DiagnosticExists(MissingDocsAnalyzer.Sas001, input, 1, 15, "AStruct"); + await Verify.DiagnosticExists(MissingDocsAnalyzer.Sas001, input, 2, 15, "AStruct"); } [Fact] public async Task WhenInternalStruct_ThenAlerts() { - const string input = @"internal struct AStruct + const string input = @" +internal struct AStruct { }"; - await Verify.DiagnosticExists(MissingDocsAnalyzer.Sas001, input, 1, 17, "AStruct"); + await Verify.DiagnosticExists(MissingDocsAnalyzer.Sas001, input, 2, 17, "AStruct"); } [Fact] public async Task WhenPublicReadOnlyStruct_ThenAlerts() { - const string input = @"public readonly struct AStruct + const string input = @" +public readonly struct AStruct { }"; - await Verify.DiagnosticExists(MissingDocsAnalyzer.Sas001, input, 1, 24, "AStruct"); + await Verify.DiagnosticExists(MissingDocsAnalyzer.Sas001, input, 2, 24, "AStruct"); } [Fact] public async Task WhenInternalReadOnlyStruct_ThenAlerts() { - const string input = @"internal readonly struct AStruct + const string input = @" +internal readonly struct AStruct { }"; - await Verify.DiagnosticExists(MissingDocsAnalyzer.Sas001, input, 1, 26, "AStruct"); + await Verify.DiagnosticExists(MissingDocsAnalyzer.Sas001, input, 2, 26, "AStruct"); } [Fact] public async Task WhenPublicRecord_ThenAlerts() { - const string input = @"public record ARecord() + const string input = @" +public record ARecord() { }"; - await Verify.DiagnosticExists(MissingDocsAnalyzer.Sas001, input, 1, 15, "ARecord"); + await Verify.DiagnosticExists(MissingDocsAnalyzer.Sas001, input, 2, 15, "ARecord"); } [Fact] public async Task WhenInternalRecord_ThenAlerts() { - const string input = @"internal record ARecord + const string input = @" +internal record ARecord { }"; - await Verify.DiagnosticExists(MissingDocsAnalyzer.Sas001, input, 1, 17, "ARecord"); + await Verify.DiagnosticExists(MissingDocsAnalyzer.Sas001, input, 2, 17, "ARecord"); } [Fact] public async Task WhenPublicStaticClass_ThenNoAlert() { - const string input = @"public static class AClass + const string input = @" +public static class AClass { } "; @@ -163,7 +176,8 @@ public async Task WhenPublicStaticClass_ThenNoAlert() [Fact] public async Task WhenInternalStaticClass_ThenNoAlert() { - const string input = @"internal static class AClass + const string input = @" +internal static class AClass { } "; @@ -174,7 +188,8 @@ public async Task WhenInternalStaticClass_ThenNoAlert() [Fact] public async Task WhenNestedPublicStaticClass_ThenNoAlert() { - const string input = @"public static class AClass1 + const string input = @" +public static class AClass1 { public static class AClass2 { @@ -242,23 +257,25 @@ private class AClass2 [Fact] public async Task WhenPublicClassNoSummary_ThenAlerts() { - const string input = @"public class AClass + const string input = @" +public class AClass { } "; - await Verify.DiagnosticExists(MissingDocsAnalyzer.Sas001, input, 1, 14, "AClass"); + await Verify.DiagnosticExists(MissingDocsAnalyzer.Sas001, input, 2, 14, "AClass"); } [Fact] public async Task WhenInternalClassNoSummary_ThenAlerts() { - const string input = @"internal class AClass + const string input = @" +internal class AClass { } "; - await Verify.DiagnosticExists(MissingDocsAnalyzer.Sas001, input, 1, 16, "AClass"); + await Verify.DiagnosticExists(MissingDocsAnalyzer.Sas001, input, 2, 16, "AClass"); } [Fact] @@ -404,67 +421,73 @@ public static void AMethod2(this string value){} [Fact] public async Task WhenPublicStaticMethod_ThenAlerts() { - const string input = @"public static class AClass + const string input = @" +public static class AClass { public static void AMethod(){} } "; - await Verify.DiagnosticExists(MissingDocsAnalyzer.Sas002, input, 3, 24, "AMethod"); + await Verify.DiagnosticExists(MissingDocsAnalyzer.Sas002, input, 4, 24, "AMethod"); } [Fact] public async Task WhenInternalStaticMethod_ThenAlerts() { - const string input = @"public static class AClass + const string input = @" +public static class AClass { internal static void AMethod(){} } "; - await Verify.DiagnosticExists(MissingDocsAnalyzer.Sas002, input, 3, 26, "AMethod"); + await Verify.DiagnosticExists(MissingDocsAnalyzer.Sas002, input, 4, 26, "AMethod"); } [Fact] public async Task WhenPublicStaticMethodWithParams_ThenAlerts() { - const string input = @"public static class AClass + const string input = @" +public static class AClass { public static void AMethod(string value){} } "; - await Verify.DiagnosticExists(MissingDocsAnalyzer.Sas002, input, 3, 24, "AMethod"); + await Verify.DiagnosticExists(MissingDocsAnalyzer.Sas002, input, 4, 24, "AMethod"); } [Fact] public async Task WhenInternalStaticMethodWithParams_ThenAlerts() { - const string input = @"public static class AClass + const string input = @" +public static class AClass { internal static void AMethod(string value){} } "; - await Verify.DiagnosticExists(MissingDocsAnalyzer.Sas002, input, 3, 26, "AMethod"); + await Verify.DiagnosticExists(MissingDocsAnalyzer.Sas002, input, 4, 26, "AMethod"); } [Fact] public async Task WhenInternalExtension_ThenAlerts() { - const string input = @"public static class AClass + const string input = @" +public static class AClass { internal static void AMethod(this string value){} } "; - await Verify.DiagnosticExists(MissingDocsAnalyzer.Sas002, input, 3, 26, "AMethod"); + await Verify.DiagnosticExists(MissingDocsAnalyzer.Sas002, input, 4, 26, "AMethod"); } [Fact] public async Task WhenPrivateExtension_ThenNoAlerts() { - const string input = @"public static class AClass + const string input = @" +public static class AClass { private static void AMethod(this string value){} } @@ -476,13 +499,14 @@ private static void AMethod(this string value){} [Fact] public async Task WhenPublicExtensionHasNoSummary_ThenAlerts() { - const string input = @"public static class AClass + const string input = @" +public static class AClass { public static void AMethod(this string value){} } "; - await Verify.DiagnosticExists(MissingDocsAnalyzer.Sas002, input, 3, 24, "AMethod"); + await Verify.DiagnosticExists(MissingDocsAnalyzer.Sas002, input, 4, 24, "AMethod"); } [Fact] @@ -494,7 +518,7 @@ public static class AClass /// /// avalue /// - private static void AMethod(this string value){} + public static void AMethod(this string value){} } "; diff --git a/src/Tools.Analyzers.Core.UnitTests/Tools.Analyzers.Core.UnitTests.csproj b/src/Tools.Analyzers.Core.UnitTests/Tools.Analyzers.Core.UnitTests.csproj index b230edd9..f63581cc 100644 --- a/src/Tools.Analyzers.Core.UnitTests/Tools.Analyzers.Core.UnitTests.csproj +++ b/src/Tools.Analyzers.Core.UnitTests/Tools.Analyzers.Core.UnitTests.csproj @@ -10,7 +10,6 @@ - diff --git a/src/Tools.Analyzers.Core/IWebRequest.cs b/src/Tools.Analyzers.Core/IWebRequest.cs deleted file mode 100644 index 25dcca75..00000000 --- a/src/Tools.Analyzers.Core/IWebRequest.cs +++ /dev/null @@ -1,19 +0,0 @@ -// ReSharper disable once CheckNamespace - -namespace Infrastructure.WebApi.Interfaces; - -/// -/// Defines a incoming REST request and response. -/// -public interface IWebRequest -{ -} - -/// -/// Defines a incoming REST request and response. -/// -// ReSharper disable once UnusedTypeParameter -public interface IWebRequest : IWebRequest - where TResponse : IWebResponse -{ -} \ No newline at end of file diff --git a/src/Tools.Analyzers.Core/Tools.Analyzers.Core.csproj b/src/Tools.Analyzers.Core/Tools.Analyzers.Core.csproj index fe23c27a..31477182 100644 --- a/src/Tools.Analyzers.Core/Tools.Analyzers.Core.csproj +++ b/src/Tools.Analyzers.Core/Tools.Analyzers.Core.csproj @@ -50,6 +50,9 @@ Reference\Infrastructure.WebApi.Interfaces\IWebApiService.cs + + Reference\Infrastructure.WebApi.Interfaces\IWebRequest.cs + Reference\Infrastructure.WebApi.Interfaces\IWebResponse.cs @@ -60,10 +63,10 @@ Reference\Infrastructure.WebApi.Interfaces\WebApiRouteAttribute.cs - Reference\Infrastruture.WebApi.Interfaces\EmptyResponse.cs + Reference\Infrastructure.WebApi.Interfaces\EmptyResponse.cs - Reference\Infrastruture.WebApi.Common\ApiResult.cs + Reference\Infrastructure.WebApi.Common\ApiResult.cs Reference\Common\Result.cs diff --git a/src/Tools.Generators.WebApi.UnitTests/MinimalApiMediatRGeneratorSpec.cs b/src/Tools.Generators.WebApi.UnitTests/MinimalApiMediatRGeneratorSpec.cs new file mode 100644 index 00000000..f2da83df --- /dev/null +++ b/src/Tools.Generators.WebApi.UnitTests/MinimalApiMediatRGeneratorSpec.cs @@ -0,0 +1,486 @@ +extern alias Generators; +using System.Reflection; +using FluentAssertions; +using JetBrains.Annotations; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Xunit; +using MinimalApiMediatRGenerator = Generators::Tools.Generators.WebApi.MinimalApiMediatRGenerator; + +namespace Tools.Generators.WebApi.UnitTests; + +[UsedImplicitly] +public class MinimalApiMediatRGeneratorSpec +{ + private static CSharpCompilation CreateCompilation(string sourceCode) + { + var assemblyPath = Path.GetDirectoryName(typeof(object).Assembly.Location)!; + + var compilation = CSharpCompilation.Create("compilation", + new[] + { + CSharpSyntaxTree.ParseText(sourceCode) + }, + new[] + { + MetadataReference.CreateFromFile(typeof(MinimalApiMediatRGenerator).Assembly.Location), + MetadataReference.CreateFromFile(typeof(Binder).GetTypeInfo().Assembly.Location), + MetadataReference.CreateFromFile(Path.Combine(assemblyPath, + "System.Runtime.dll")) //HACK: this is required to make custom attributes work + }, + new CSharpCompilationOptions(OutputKind.ConsoleApplication)); + + return compilation; + } + + [Trait("Category", "Unit")] + public class GivenAServiceCLass + { + private GeneratorDriver _driver; + + public GivenAServiceCLass() + { + var generator = new MinimalApiMediatRGenerator(); + _driver = CSharpGeneratorDriver.Create(generator); + } + + [Fact] + public void WhenDefinesNoMethods_ThenGenerates() + { + var compilation = CreateCompilation(""" + using System; + using Infrastructure.WebApi.Interfaces; + + namespace ANamespace; + + public class AResponse : IWebResponse + { + } + public class ARequest : IWebRequest + { + } + public class AServiceClass : IWebApiService + { + } + """); + + var result = Generate(compilation); + + result.Should().Be( + """ + // + using System; + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Builder; + using Infrastructure.WebApi.Common; + + namespace compilation + { + public static class MinimalApiRegistration + { + public static void RegisterRoutes(this global::Microsoft.AspNetCore.Builder.WebApplication app) + { + + } + } + } + + + """); + } + + [Fact] + public void WhenDefinesAMethodWithNakedReturnType_ThenGenerates() + { + var compilation = CreateCompilation(""" + using System; + using Infrastructure.WebApi.Interfaces; + + namespace ANamespace; + + public class AResponse : IWebResponse + { + } + public class ARequest : IWebRequest + { + } + public class AServiceClass : IWebApiService + { + [WebApiRoute("aroute", WebApiOperation.Get)] + public string AMethod(ARequest request) + { + return ""; + } + } + """); + + var result = Generate(compilation); + + result.Should().Be( + """ + // + using System; + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Builder; + using Infrastructure.WebApi.Interfaces; + using Infrastructure.WebApi.Common; + + namespace compilation + { + public static class MinimalApiRegistration + { + public static void RegisterRoutes(this global::Microsoft.AspNetCore.Builder.WebApplication app) + { + var aserviceclassGroup = app.MapGroup(string.Empty) + .WithGroupName("AServiceClass") + .AddEndpointFilter() + .AddEndpointFilter(); + aserviceclassGroup.MapGet("aroute", + async (global::MediatR.IMediator mediator, [global::Microsoft.AspNetCore.Http.AsParameters] global::ANamespace.ARequest request) => + await mediator.Send(request, global::System.Threading.CancellationToken.None)); + + } + } + } + + namespace ANamespace.AServiceClassMediatRHandlers + { + public class AMethod_ARequest_Handler : global::MediatR.IRequestHandler + { + public async Task Handle(global::ANamespace.ARequest request, global::System.Threading.CancellationToken cancellationToken) + { + await Task.CompletedTask; + var api = new global::ANamespace.AServiceClass(); + var result = api.AMethod(request); + return result.HandleApiResult(global::Infrastructure.WebApi.Interfaces.WebApiOperation.Get); + } + } + + } + + + """); + } + + [Fact] + public void WhenDefinesAMethodWithAsyncTaskReturnTypeAndNoCancellationToken_ThenGenerates() + { + var compilation = CreateCompilation(""" + using System; + using Infrastructure.WebApi.Interfaces; + + namespace ANamespace; + + public class AResponse : IWebResponse + { + } + public class ARequest : IWebRequest + { + } + public class AServiceClass : IWebApiService + { + [WebApiRoute("aroute", WebApiOperation.Get)] + public async Task AMethod(ARequest request) + { + return ""; + } + } + """); + + var result = Generate(compilation); + + result.Should().Be( + """ + // + using System; + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Builder; + using Infrastructure.WebApi.Interfaces; + using Infrastructure.WebApi.Common; + + namespace compilation + { + public static class MinimalApiRegistration + { + public static void RegisterRoutes(this global::Microsoft.AspNetCore.Builder.WebApplication app) + { + var aserviceclassGroup = app.MapGroup(string.Empty) + .WithGroupName("AServiceClass") + .AddEndpointFilter() + .AddEndpointFilter(); + aserviceclassGroup.MapGet("aroute", + async (global::MediatR.IMediator mediator, [global::Microsoft.AspNetCore.Http.AsParameters] global::ANamespace.ARequest request) => + await mediator.Send(request, global::System.Threading.CancellationToken.None)); + + } + } + } + + namespace ANamespace.AServiceClassMediatRHandlers + { + public class AMethod_ARequest_Handler : global::MediatR.IRequestHandler + { + public async Task Handle(global::ANamespace.ARequest request, global::System.Threading.CancellationToken cancellationToken) + { + var api = new global::ANamespace.AServiceClass(); + var result = await api.AMethod(request); + return result.HandleApiResult(global::Infrastructure.WebApi.Interfaces.WebApiOperation.Get); + } + } + + } + + + """); + } + + [Fact] + public void WhenDefinesAMethodWithAsyncTaskReturnTypeAndCancellationToken_ThenGenerates() + { + var compilation = CreateCompilation(""" + using System; + using System.Threading; + using Infrastructure.WebApi.Interfaces; + + namespace ANamespace; + + public class AResponse : IWebResponse + { + } + public class ARequest : IWebRequest + { + } + public class AServiceClass : IWebApiService + { + [WebApiRoute("aroute", WebApiOperation.Get)] + public async Task AMethod(ARequest request, CancellationToken cancellationToken) + { + return ""; + } + } + """); + + var result = Generate(compilation); + + result.Should().Be( + """ + // + using System.Threading; + using System; + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Builder; + using Infrastructure.WebApi.Interfaces; + using Infrastructure.WebApi.Common; + + namespace compilation + { + public static class MinimalApiRegistration + { + public static void RegisterRoutes(this global::Microsoft.AspNetCore.Builder.WebApplication app) + { + var aserviceclassGroup = app.MapGroup(string.Empty) + .WithGroupName("AServiceClass") + .AddEndpointFilter() + .AddEndpointFilter(); + aserviceclassGroup.MapGet("aroute", + async (global::MediatR.IMediator mediator, [global::Microsoft.AspNetCore.Http.AsParameters] global::ANamespace.ARequest request) => + await mediator.Send(request, global::System.Threading.CancellationToken.None)); + + } + } + } + + namespace ANamespace.AServiceClassMediatRHandlers + { + public class AMethod_ARequest_Handler : global::MediatR.IRequestHandler + { + public async Task Handle(global::ANamespace.ARequest request, global::System.Threading.CancellationToken cancellationToken) + { + var api = new global::ANamespace.AServiceClass(); + var result = await api.AMethod(request, cancellationToken); + return result.HandleApiResult(global::Infrastructure.WebApi.Interfaces.WebApiOperation.Get); + } + } + + } + + + """); + } + + [Fact] + public void WhenDefinesAMethodAndTestingOnly_ThenGenerates() + { + var compilation = CreateCompilation(""" + using System; + using System.Threading; + using Infrastructure.WebApi.Interfaces; + + namespace ANamespace; + + public class AResponse : IWebResponse + { + } + public class ARequest : IWebRequest + { + } + public class AServiceClass : IWebApiService + { + [WebApiRoute("aroute", WebApiOperation.Get, true)] + public async Task AMethod(ARequest request, CancellationToken cancellationToken) + { + return ""; + } + } + """); + + var result = Generate(compilation); + + result.Should().Be( + """ + // + using System.Threading; + using System; + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Builder; + using Infrastructure.WebApi.Interfaces; + using Infrastructure.WebApi.Common; + + namespace compilation + { + public static class MinimalApiRegistration + { + public static void RegisterRoutes(this global::Microsoft.AspNetCore.Builder.WebApplication app) + { + var aserviceclassGroup = app.MapGroup(string.Empty) + .WithGroupName("AServiceClass") + .AddEndpointFilter() + .AddEndpointFilter(); + #if TESTINGONLY + aserviceclassGroup.MapGet("aroute", + async (global::MediatR.IMediator mediator, [global::Microsoft.AspNetCore.Http.AsParameters] global::ANamespace.ARequest request) => + await mediator.Send(request, global::System.Threading.CancellationToken.None)); + #endif + + } + } + } + + namespace ANamespace.AServiceClassMediatRHandlers + { + #if TESTINGONLY + public class AMethod_ARequest_Handler : global::MediatR.IRequestHandler + { + public async Task Handle(global::ANamespace.ARequest request, global::System.Threading.CancellationToken cancellationToken) + { + var api = new global::ANamespace.AServiceClass(); + var result = await api.AMethod(request, cancellationToken); + return result.HandleApiResult(global::Infrastructure.WebApi.Interfaces.WebApiOperation.Get); + } + } + #endif + + } + + + """); + } + + [Fact] + public void WhenDefinesAMethodAndClassConstructor_ThenGenerates() + { + var compilation = CreateCompilation(""" + using System; + using System.Threading; + using Application.Interfaces; + using Infrastructure.WebApi.Interfaces; + + namespace ANamespace; + + public class AResponse : IWebResponse + { + } + public class ARequest : IWebRequest + { + } + public class AServiceClass : IWebApiService + { + private readonly ICallerContext _context; + + public CarsApi(ICallerContext context) + { + _context = context; + _carsApplication = carsApplication; + } + + [WebApiRoute("aroute", WebApiOperation.Get)] + public async Task AMethod(ARequest request, CancellationToken cancellationToken) + { + return ""; + } + } + """); + + var result = Generate(compilation); + + result.Should().Be( + """ + // + using System.Threading; + using System; + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Builder; + using Infrastructure.WebApi.Interfaces; + using Infrastructure.WebApi.Common; + using Application.Interfaces; + + namespace compilation + { + public static class MinimalApiRegistration + { + public static void RegisterRoutes(this global::Microsoft.AspNetCore.Builder.WebApplication app) + { + var aserviceclassGroup = app.MapGroup(string.Empty) + .WithGroupName("AServiceClass") + .AddEndpointFilter() + .AddEndpointFilter(); + aserviceclassGroup.MapGet("aroute", + async (global::MediatR.IMediator mediator, [global::Microsoft.AspNetCore.Http.AsParameters] global::ANamespace.ARequest request) => + await mediator.Send(request, global::System.Threading.CancellationToken.None)); + + } + } + } + + namespace ANamespace.AServiceClassMediatRHandlers + { + public class AMethod_ARequest_Handler : global::MediatR.IRequestHandler + { + private readonly global::.ICallerContext _context; + + public AMethod_ARequest_Handler(global::.ICallerContext context) + { + this._context = context; + } + + public async Task Handle(global::ANamespace.ARequest request, global::System.Threading.CancellationToken cancellationToken) + { + var api = new global::ANamespace.AServiceClass(this._context); + var result = await api.AMethod(request, cancellationToken); + return result.HandleApiResult(global::Infrastructure.WebApi.Interfaces.WebApiOperation.Get); + } + } + + } + + + """); + } + + private string Generate(CSharpCompilation compilation) + { + _driver = _driver.RunGeneratorsAndUpdateCompilation(compilation, out var _, out var _); + return _driver.GetRunResult().Results[0].GeneratedSources[0].SourceText.ToString(); + } + } +} \ No newline at end of file diff --git a/src/Tools.Generators.WebApi.UnitTests/Tools.Generators.WebApi.UnitTests.csproj b/src/Tools.Generators.WebApi.UnitTests/Tools.Generators.WebApi.UnitTests.csproj new file mode 100644 index 00000000..8800428f --- /dev/null +++ b/src/Tools.Generators.WebApi.UnitTests/Tools.Generators.WebApi.UnitTests.csproj @@ -0,0 +1,20 @@ + + + + net7.0 + true + + + + + + + + + + + + + + + diff --git a/src/Tools.Generators.WebApi.UnitTests/WebApiAssemblyVisitorSpec.cs b/src/Tools.Generators.WebApi.UnitTests/WebApiAssemblyVisitorSpec.cs new file mode 100644 index 00000000..d26cc474 --- /dev/null +++ b/src/Tools.Generators.WebApi.UnitTests/WebApiAssemblyVisitorSpec.cs @@ -0,0 +1,433 @@ +extern alias Generators; +using System.Collections.Immutable; +using System.Reflection; +using FluentAssertions; +using Generators::Infrastructure.WebApi.Interfaces; +using JetBrains.Annotations; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Moq; +using Xunit; +using WebApiAssemblyVisitor = Generators::Tools.Generators.WebApi.WebApiAssemblyVisitor; + +namespace Tools.Generators.WebApi.UnitTests; + +[UsedImplicitly] +public class WebApiAssemblyVisitorSpec +{ + private const string CompilationSourceCode = ""; + + private static CSharpCompilation CreateCompilation(string sourceCode) + { + var assemblyPath = Path.GetDirectoryName(typeof(object).Assembly.Location)!; + + var compilation = CSharpCompilation.Create("compilation", + new[] + { + CSharpSyntaxTree.ParseText(sourceCode) + }, + new[] + { + MetadataReference.CreateFromFile(typeof(WebApiAssemblyVisitor).Assembly.Location), + MetadataReference.CreateFromFile(typeof(Binder).GetTypeInfo().Assembly.Location), + MetadataReference.CreateFromFile(Path.Combine(assemblyPath, + "System.Runtime.dll")) //HACK: this is required to make custom attributes work + }, + new CSharpCompilationOptions(OutputKind.ConsoleApplication)); + + return compilation; + } + + [Trait("Category", "Unit")] + public class GivenAnyClass + { + private readonly WebApiAssemblyVisitor _visitor; + + public GivenAnyClass() + { + var compilation = CreateCompilation(CompilationSourceCode); + _visitor = new WebApiAssemblyVisitor(CancellationToken.None, compilation); + } + + [Fact] + public void WhenVisitAssembly_ThenVisitsGlobalNamespace() + { + var globalNamespace = new Mock(); + var assembly = new Mock(); + assembly.Setup(ass => ass.GlobalNamespace).Returns(globalNamespace.Object); + _visitor.VisitAssembly(assembly.Object); + + globalNamespace.Verify(gns => gns.Accept(_visitor)); + _visitor.OperationRegistrations.Should().BeEmpty(); + } + + [Fact] + public void WhenVisitNamespaceAndIgnoredNamespace_ThenStopsVisiting() + { + var @namespace = new Mock(); + @namespace.Setup(ns => ns.Name).Returns(WebApiAssemblyVisitor.IgnoredNamespaces[0]); + + _visitor.VisitNamespace(@namespace.Object); + + @namespace.Verify(gns => gns.GetMembers(), Times.Never); + _visitor.OperationRegistrations.Should().BeEmpty(); + } + + [Fact] + public void WhenVisitNamespaceAndNoTypes_ThenStopsVisiting() + { + var @namespace = new Mock(); + @namespace.Setup(ns => ns.Name).Returns("anamespace"); + @namespace.Setup(ns => ns.GetMembers()).Returns(new List()); + + _visitor.VisitNamespace(@namespace.Object); + + @namespace.Verify(gns => gns.GetMembers()); + _visitor.OperationRegistrations.Should().BeEmpty(); + } + + [Fact] + public void WhenVisitNamespaceThatHasTypes_ThenVisitsTypes() + { + var @namespace = new Mock(); + @namespace.Setup(ns => ns.Name).Returns("anamespace"); + var type = new Mock(); + @namespace.Setup(ns => ns.GetMembers()).Returns(new List + { + type.Object + }); + + _visitor.VisitNamespace(@namespace.Object); + + @namespace.Verify(gns => gns.GetMembers()); + type.Verify(t => t.Accept(_visitor)); + _visitor.OperationRegistrations.Should().BeEmpty(); + } + + [Fact] + public void WhenVisitNamedTypeAndNotServiceClass_ThenStopsVisiting() + { + var type = new Mock(); + type.Setup(t => t.GetTypeMembers()).Returns(ImmutableArray.Empty); + type.Setup(t => t.TypeKind).Returns(TypeKind.Interface); + + _visitor.VisitNamedType(type.Object); + + type.Verify(t => t.GetTypeMembers()); + _visitor.OperationRegistrations.Should().BeEmpty(); + } + + [Fact] + public void WhenVisitNamedTypeAndIsClassButNotPublic_ThenCreatesNoRegistrations() + { + var type = new Mock(); + type.Setup(t => t.GetTypeMembers()).Returns(ImmutableArray.Empty); + type.Setup(t => t.TypeKind).Returns(TypeKind.Class); + type.Setup(t => t.DeclaredAccessibility).Returns(Accessibility.Private); + + _visitor.VisitNamedType(type.Object); + + type.Verify(t => t.GetTypeMembers()); + _visitor.OperationRegistrations.Should().BeEmpty(); + } + + [Fact] + public void WhenVisitNamedTypeAndIsPublicClassButAlsoAbstract_ThenCreatesNoRegistrations() + { + var type = new Mock(); + type.Setup(t => t.GetTypeMembers()).Returns(ImmutableArray.Empty); + type.Setup(t => t.TypeKind).Returns(TypeKind.Class); + type.Setup(t => t.DeclaredAccessibility).Returns(Accessibility.Public); + type.Setup(t => t.IsAbstract).Returns(true); + + _visitor.VisitNamedType(type.Object); + + type.Verify(t => t.GetTypeMembers()); + _visitor.OperationRegistrations.Should().BeEmpty(); + } + + [Fact] + public void WhenVisitNamedTypeAndIsPublicClassButAlsoStatic_ThenCreatesNoRegistrations() + { + var type = new Mock(); + type.Setup(t => t.GetTypeMembers()).Returns(ImmutableArray.Empty); + type.Setup(t => t.TypeKind).Returns(TypeKind.Class); + type.Setup(t => t.DeclaredAccessibility).Returns(Accessibility.Public); + type.Setup(t => t.IsStatic).Returns(true); + + _visitor.VisitNamedType(type.Object); + + type.Verify(t => t.GetTypeMembers()); + _visitor.OperationRegistrations.Should().BeEmpty(); + } + + [Fact] + public void WhenVisitNamedTypeAndIsPublicClassButNotAnyBaseType_ThenCreatesNoRegistrations() + { + var type = new Mock(); + type.Setup(t => t.GetTypeMembers()).Returns(ImmutableArray.Empty); + type.Setup(t => t.TypeKind).Returns(TypeKind.Class); + type.Setup(t => t.DeclaredAccessibility).Returns(Accessibility.Public); + type.Setup(t => t.IsStatic).Returns(false); + type.Setup(t => t.IsAbstract).Returns(false); + type.Setup(t => t.AllInterfaces).Returns(ImmutableArray.Empty); + + _visitor.VisitNamedType(type.Object); + + type.Verify(t => t.GetTypeMembers()); + _visitor.OperationRegistrations.Should().BeEmpty(); + } + + [Fact] + public void WhenVisitNamedTypeAndIsPublicClassButWrongBaseType_ThenCreatesNoRegistrations() + { + var type = new Mock(); + type.Setup(t => t.GetTypeMembers()).Returns(ImmutableArray.Empty); + type.Setup(t => t.TypeKind).Returns(TypeKind.Class); + type.Setup(t => t.DeclaredAccessibility).Returns(Accessibility.Public); + var classBaseType = new Mock(); + type.Setup(t => t.IsStatic).Returns(false); + type.Setup(t => t.IsAbstract).Returns(false); + type.Setup(t => t.AllInterfaces).Returns(ImmutableArray.Create(classBaseType.Object)); + + _visitor.VisitNamedType(type.Object); + + type.Verify(t => t.GetTypeMembers()); + _visitor.OperationRegistrations.Should().BeEmpty(); + } + } + + [Trait("Category", "Unit")] + public class GivenAServiceClass + { + private readonly CSharpCompilation _compilation; + private readonly WebApiAssemblyVisitor _visitor; + + public GivenAServiceClass() + { + _compilation = CreateCompilation(CompilationSourceCode); + _visitor = new WebApiAssemblyVisitor(CancellationToken.None, _compilation); + } + + [Fact] + public void WhenVisitNamedTypeAndNoMethods_ThenCreatesNoRegistrations() + { + var type = SetupServiceClass(_compilation); + + _visitor.VisitNamedType(type.Object); + + type.Verify(t => t.GetTypeMembers()); + _visitor.OperationRegistrations.Should().BeEmpty(); + } + + [Fact] + public void WhenVisitNamedTypeAndOnlyPrivateMethod_ThenCreatesNoRegistrations() + { + var type = SetupServiceClass(_compilation); + var method = new Mock(); + method.Setup(m => m.DeclaredAccessibility).Returns(Accessibility.Private); + + _visitor.VisitNamedType(type.Object); + + type.Verify(t => t.GetTypeMembers()); + _visitor.OperationRegistrations.Should().BeEmpty(); + } + + [Fact] + public void WhenVisitNamedTypeAndOnlyPublicStaticMethod_ThenCreatesNoRegistrations() + { + var type = SetupServiceClass(_compilation); + var method = new Mock(); + method.Setup(m => m.DeclaredAccessibility).Returns(Accessibility.Public); + method.Setup(m => m.IsStatic).Returns(true); + + _visitor.VisitNamedType(type.Object); + + type.Verify(t => t.GetTypeMembers()); + _visitor.OperationRegistrations.Should().BeEmpty(); + } + + [Fact] + public void WhenVisitNamedTypeAndVoidReturnType_ThenCreatesNoRegistrations() + { + var voidType = _compilation.GetTypeByMetadataName(typeof(void).FullName!)!; + var type = SetupServiceClass(_compilation); + var method = new Mock(); + method.Setup(m => m.DeclaredAccessibility).Returns(Accessibility.Public); + method.Setup(m => m.IsStatic).Returns(false); + method.Setup(m => m.ReturnType).Returns(voidType); + type.Setup(t => t.GetMembers()).Returns(ImmutableArray.Create(method.Object)); + + _visitor.VisitNamedType(type.Object); + + type.Verify(t => t.GetTypeMembers()); + _visitor.OperationRegistrations.Should().BeEmpty(); + } + + [Fact] + public void WhenVisitNamedTypeAndHasNoParameters_ThenCreatesNoRegistrations() + { + var taskType = _compilation.GetTypeByMetadataName(typeof(Task<>).FullName!)!; + var type = SetupServiceClass(_compilation); + var method = new Mock(); + method.Setup(m => m.DeclaredAccessibility).Returns(Accessibility.Public); + method.Setup(m => m.IsStatic).Returns(false); + method.Setup(m => m.ReturnType).Returns(taskType); + method.Setup(m => m.Parameters).Returns(ImmutableArray.Create()); + type.Setup(t => t.GetMembers()).Returns(ImmutableArray.Create(method.Object)); + + _visitor.VisitNamedType(type.Object); + + type.Verify(t => t.GetTypeMembers()); + _visitor.OperationRegistrations.Should().BeEmpty(); + } + + [Fact] + public void WhenVisitNamedTypeAndHasWrongFirstParameter_ThenCreatesNoRegistrations() + { + var taskType = _compilation.GetTypeByMetadataName(typeof(Task<>).FullName!)!; + var type = SetupServiceClass(_compilation); + var method = new Mock(); + method.Setup(m => m.DeclaredAccessibility).Returns(Accessibility.Public); + method.Setup(m => m.IsStatic).Returns(false); + method.Setup(m => m.ReturnType).Returns(taskType); + var parameter = new Mock(); + var classBaseType = new Mock(); + parameter.Setup(p => p.Type.AllInterfaces).Returns(ImmutableArray.Create(classBaseType.Object)); + method.Setup(m => m.Parameters).Returns(ImmutableArray.Create(parameter.Object)); + type.Setup(t => t.GetMembers()).Returns(ImmutableArray.Create(method.Object)); + + _visitor.VisitNamedType(type.Object); + + type.Verify(t => t.GetTypeMembers()); + _visitor.OperationRegistrations.Should().BeEmpty(); + } + + [Fact] + public void WhenVisitNamedTypeAndHasWrongSecondParameter_ThenCreatesNoRegistrations() + { + var requestType = _compilation.GetTypeByMetadataName(typeof(IWebRequest).FullName!)!; + var stringType = _compilation.GetTypeByMetadataName(typeof(string).FullName!)!; + var taskType = _compilation.GetTypeByMetadataName(typeof(Task<>).FullName!)!; + var type = SetupServiceClass(_compilation); + var method = new Mock(); + method.Setup(m => m.DeclaredAccessibility).Returns(Accessibility.Public); + method.Setup(m => m.IsStatic).Returns(false); + method.Setup(m => m.ReturnType).Returns(taskType); + var firstParameter = new Mock(); + firstParameter.Setup(p => p.Type.AllInterfaces).Returns(ImmutableArray.Create(requestType)); + var secondParameter = new Mock(); + secondParameter.Setup(p => p.Type).Returns(stringType); + method.Setup(m => m.Parameters) + .Returns(ImmutableArray.Create(firstParameter.Object, secondParameter.Object)); + type.Setup(t => t.GetMembers()).Returns(ImmutableArray.Create(method.Object)); + + _visitor.VisitNamedType(type.Object); + + type.Verify(t => t.GetTypeMembers()); + _visitor.OperationRegistrations.Should().BeEmpty(); + } + + [Fact] + public void WhenVisitNamedTypeAndHasNoAttributes_ThenCreatesNoRegistrations() + { + var requestType = _compilation.GetTypeByMetadataName(typeof(IWebRequest).FullName!)!; + var cancellationTokenType = _compilation.GetTypeByMetadataName(typeof(CancellationToken).FullName!)!; + var taskType = _compilation.GetTypeByMetadataName(typeof(Task<>).FullName!)!; + var type = SetupServiceClass(_compilation); + var method = new Mock(); + method.Setup(m => m.DeclaredAccessibility).Returns(Accessibility.Public); + method.Setup(m => m.IsStatic).Returns(false); + method.Setup(m => m.ReturnType).Returns(taskType); + var firstParameter = new Mock(); + firstParameter.Setup(p => p.Type.AllInterfaces).Returns(ImmutableArray.Create(requestType)); + var secondParameter = new Mock(); + secondParameter.Setup(p => p.Type).Returns(cancellationTokenType); + method.Setup(m => m.Parameters) + .Returns(ImmutableArray.Create(firstParameter.Object, secondParameter.Object)); + method.Setup(t => t.GetAttributes()).Returns(ImmutableArray.Create()); + type.Setup(t => t.GetMembers()).Returns(ImmutableArray.Create(method.Object)); + + _visitor.VisitNamedType(type.Object); + + type.Verify(t => t.GetTypeMembers()); + _visitor.OperationRegistrations.Should().BeEmpty(); + } + + [Trait("Category", "Unit")] + public class GivenAServiceOperation + { + [Fact] + public void WhenVisitNamedTypeAndHasAttribute_ThenCreatesRegistration() + { + var compilation = CreateCompilation(""" + using System; + using Infrastructure.WebApi.Interfaces; + + namespace ANamespace; + + public class AResponse : IWebResponse + { + } + public class ARequest : IWebRequest + { + } + public class AServiceClass : Infrastructure.WebApi.Interfaces.IWebApiService + { + [WebApiRoute("aroute", WebApiOperation.Get)] + public string AMethod(ARequest request) + { + return ""; + } + } + """); + + var serviceClass = compilation.GetTypeByMetadataName("ANamespace.AServiceClass")!; + var visitor = new WebApiAssemblyVisitor(CancellationToken.None, compilation); + + visitor.VisitNamedType(serviceClass); + + visitor.OperationRegistrations.Count.Should().Be(1); + var registration = visitor.OperationRegistrations.First(); + registration.Class.Constructors.Count().Should().Be(1); + registration.Class.Constructors.First().CtorParameters.Count().Should().Be(0); + registration.Class.Constructors.First().IsInjectionCtor.Should().BeFalse(); + registration.Class.Constructors.First().MethodBody.Should().BeEmpty(); + registration.Class.TypeName.Name.Should().Be("AServiceClass"); + registration.Class.TypeName.Namespace.Should().Be("ANamespace"); + registration.Class.TypeName.FullName.Should().Be("ANamespace.AServiceClass"); + registration.Class.UsingNamespaces.Count().Should().Be(2); + registration.MethodBody.Should().Be($" {{{Environment.NewLine}" + + $" return \"\";{Environment.NewLine}" + + $" }}{Environment.NewLine}"); + registration.MethodName.Should().Be("AMethod"); + registration.OperationType.Should().Be(WebApiOperation.Get); + registration.RoutePath.Should().Be("aroute"); + registration.IsTestingOnly.Should().BeFalse(); + registration.RequestDtoType.Name.Should().Be("ARequest"); + registration.RequestDtoType.Namespace.Should().Be("ANamespace"); + registration.ResponseDtoType.Name.Should().Be("AResponse"); + registration.ResponseDtoType.Namespace.Should().Be("ANamespace"); + } + } + + private static Mock SetupServiceClass(CSharpCompilation compilation) + { + var serviceClassBaseInterface = compilation.GetTypeByMetadataName(typeof(IWebApiService).FullName!)!; + var type = new Mock(); + type.Setup(t => t.GetTypeMembers()).Returns(ImmutableArray.Empty); + type.Setup(t => t.TypeKind).Returns(TypeKind.Class); + type.Setup(t => t.DeclaredAccessibility).Returns(Accessibility.Public); + type.Setup(t => t.IsStatic).Returns(false); + type.Setup(t => t.IsAbstract).Returns(false); + type.Setup(t => t.AllInterfaces).Returns(ImmutableArray.Create(serviceClassBaseInterface)); + var @namespace = new Mock(); + @namespace.As().Setup(ns => ns.ToDisplayString(It.IsAny())) + .Returns("adisplaystring"); + type.Setup(t => t.ContainingNamespace).Returns(@namespace.Object); + type.Setup(t => t.Name).Returns("aname"); + + return type; + } + } +} \ No newline at end of file diff --git a/src/Tools.Generators.WebApi/Extensions/SymbolExtensions.cs b/src/Tools.Generators.WebApi/Extensions/SymbolExtensions.cs new file mode 100644 index 00000000..e2bb4bcd --- /dev/null +++ b/src/Tools.Generators.WebApi/Extensions/SymbolExtensions.cs @@ -0,0 +1,93 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Tools.Generators.WebApi.Extensions; + +public static class SymbolExtensions +{ + public static AttributeData? GetAttribute(this ISymbol symbol, INamedTypeSymbol attributeType) + { + return symbol.GetAttributes() + .FirstOrDefault(attribute => attribute.AttributeClass!.IsTypeOf(attributeType)); + } + + public static INamedTypeSymbol? GetBaseType(this ITypeSymbol symbol, INamedTypeSymbol baseType) + { + return symbol.AllInterfaces.FirstOrDefault(@interface => @interface.IsTypeOf(baseType)); + } + + public static string GetMethodBody(this ISymbol method) + { + var syntaxReference = method.DeclaringSyntaxReferences.FirstOrDefault(); + + var syntax = syntaxReference?.GetSyntax(); + if (syntax is MethodDeclarationSyntax methodDeclarationSyntax) + { + return methodDeclarationSyntax.Body?.ToFullString() ?? string.Empty; + } + + return string.Empty; + } + + public static IEnumerable GetUsingNamespaces(this INamedTypeSymbol symbol) + { + var syntaxReference = symbol.DeclaringSyntaxReferences.IsDefaultOrEmpty + ? null + : symbol.DeclaringSyntaxReferences.FirstOrDefault(); + if (syntaxReference is null) + { + return Enumerable.Empty(); + } + + var usingSyntaxes = syntaxReference.SyntaxTree.GetRoot() + .DescendantNodes() + .OfType(); + + return usingSyntaxes.Select(us => us.Name!.ToString()) + .Distinct() + .OrderDescending() + .ToList(); + } + + public static bool IsClass(this ITypeSymbol type) + { + return type.TypeKind == TypeKind.Class; + } + + public static bool IsConcreteInstanceClass(this INamedTypeSymbol symbol) + { + return symbol is { IsAbstract: false, IsStatic: false }; + } + + public static bool IsDerivedFrom(this ITypeSymbol symbol, INamedTypeSymbol baseType) + { + ArgumentNullException.ThrowIfNull(baseType); + return symbol.AllInterfaces.Any(@interface => @interface.IsTypeOf(baseType)); + } + + public static bool IsParameterless(this IMethodSymbol symbol) + { + return symbol.Parameters.Length == 0; + } + + public static bool IsPublicInstance(this IMethodSymbol symbol) + { + return symbol is { IsStatic: false, DeclaredAccessibility: Accessibility.Public }; + } + + public static bool IsPublicOrInternalClass(this INamedTypeSymbol symbol) + { + var accessibility = symbol.DeclaredAccessibility; + return accessibility is Accessibility.Public or Accessibility.Internal; + } + + public static bool IsPublicOrInternalInstanceMethod(this IMethodSymbol symbol) + { + return symbol is { IsStatic: false, DeclaredAccessibility: Accessibility.Public or Accessibility.Internal }; + } + + public static bool IsTypeOf(this ISymbol symbol, INamedTypeSymbol baseType) + { + return SymbolEqualityComparer.Default.Equals(symbol.OriginalDefinition, baseType); + } +} \ No newline at end of file diff --git a/src/Tools.Generators.WebApi/MinimalApiMediatRGenerator.cs b/src/Tools.Generators.WebApi/MinimalApiMediatRGenerator.cs index 68b49402..3120959f 100644 --- a/src/Tools.Generators.WebApi/MinimalApiMediatRGenerator.cs +++ b/src/Tools.Generators.WebApi/MinimalApiMediatRGenerator.cs @@ -168,11 +168,23 @@ private static void BuildHandlerClasses( handlerClasses.AppendLine($" public async Task" + $" Handle(global::{registration.RequestDtoType.FullName} request, global::System.Threading.CancellationToken cancellationToken)"); handlerClasses.AppendLine(" {"); + if (!registration.IsAsync) + { + handlerClasses.AppendLine( + " await Task.CompletedTask;"); + } + var callingParameters = BuildInjectedParameters(registration.Class.Constructors.ToList()); handlerClasses.AppendLine( $" var api = new global::{registration.Class.TypeName.FullName}({callingParameters});"); + var asyncAwait = registration.IsAsync + ? "await " + : string.Empty; + var hasCancellationToken = registration.HasCancellationToken + ? ", cancellationToken" + : string.Empty; handlerClasses.AppendLine( - $" var result = await api.{registration.MethodName}(request, cancellationToken);"); + $" var result = {asyncAwait}api.{registration.MethodName}(request{hasCancellationToken});"); handlerClasses.AppendLine( $" return result.HandleApiResult(global::Infrastructure.WebApi.Interfaces.WebApiOperation.{registration.OperationType});"); handlerClasses.AppendLine(" }"); diff --git a/src/Tools.Generators.WebApi/README.md b/src/Tools.Generators.WebApi/README.md index bbd522d7..3882222b 100644 --- a/src/Tools.Generators.WebApi/README.md +++ b/src/Tools.Generators.WebApi/README.md @@ -20,7 +20,9 @@ We have had to hardcode certain other types to avoid referencing AspNetCore, and > None of this is ideal. But until we can figure the magic needed to build and run this Source Generator if it uses these types, this may be the best workaround we have for now. -# Debugging Generator +# Debugging Generators + +You can debug the analyzers easily from the unit tests. You can debug your source generator by setting a breakpoint in the code, and then running the `SourceGenerator-Development` run configuration from the `ApiHost1` project with the debugger. (found in the `launchSettings.json` file in any executable project). diff --git a/src/Tools.Generators.WebApi/Tools.Generators.WebApi.csproj b/src/Tools.Generators.WebApi/Tools.Generators.WebApi.csproj index a816920b..b244b945 100644 --- a/src/Tools.Generators.WebApi/Tools.Generators.WebApi.csproj +++ b/src/Tools.Generators.WebApi/Tools.Generators.WebApi.csproj @@ -18,6 +18,9 @@ Reference\Infrastructure.WebApi.Interfaces\IWebResponse.cs + + Reference\Infrastructure.WebApi.Interfaces\IWebRequest.cs + Reference\Infrastructure.WebApi.Interfaces\WebApiOperation.cs @@ -25,4 +28,10 @@ Reference\Infrastructure.WebApi.Interfaces\WebApiRouteAttribute.cs + + + + <_Parameter1>$(AssemblyName).UnitTests + + diff --git a/src/Tools.Generators.WebApi/WebApiAssemblyVisitor.cs b/src/Tools.Generators.WebApi/WebApiAssemblyVisitor.cs index b4190b16..53e5142f 100644 --- a/src/Tools.Generators.WebApi/WebApiAssemblyVisitor.cs +++ b/src/Tools.Generators.WebApi/WebApiAssemblyVisitor.cs @@ -1,6 +1,5 @@ using Infrastructure.WebApi.Interfaces; using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp.Syntax; using Tools.Generators.WebApi.Extensions; namespace Tools.Generators.WebApi; @@ -13,20 +12,20 @@ namespace Tools.Generators.WebApi; /// 2. That are public or internal /// Where the methods represent service operations, that are: /// 1. Have any method name -/// 2. They return the type +/// 2. They return type that is not void /// 3. They have a request dto type derived from as their first parameter /// 4. They may have a as their second parameter, but no other parameters /// 5. Are decorated with the attribute, and have both a route and operation /// public class WebApiAssemblyVisitor : SymbolVisitor { - private static readonly string[] IgnoredNamespaces = + internal static readonly string[] IgnoredNamespaces = { "System", "Microsoft", "MediatR", "MessagePack", "NerdBank*" }; private readonly CancellationToken _cancellationToken; private readonly INamedTypeSymbol _cancellationTokenSymbol; private readonly INamedTypeSymbol _serviceInterfaceSymbol; - private readonly INamedTypeSymbol _webHandlerReturnTypeSymbol; + private readonly INamedTypeSymbol _voidSymbol; private readonly INamedTypeSymbol _webRequestInterfaceSymbol; private readonly INamedTypeSymbol _webRequestResponseInterfaceSymbol; private readonly INamedTypeSymbol _webRouteAttributeSymbol; @@ -35,16 +34,11 @@ public WebApiAssemblyVisitor(CancellationToken cancellationToken, Compilation co { _cancellationToken = cancellationToken; _serviceInterfaceSymbol = compilation.GetTypeByMetadataName(typeof(IWebApiService).FullName!)!; - _webRequestInterfaceSymbol = - compilation.GetTypeByMetadataName( - "Infrastructure.WebApi.Interfaces.IWebRequest") - !; //HACK: we cannot reference the real type here, as it causes runtime issues. See the README.md for more details - _webRequestResponseInterfaceSymbol = - compilation.GetTypeByMetadataName("Infrastructure.WebApi.Interfaces.IWebRequest`1") - !; //HACK: we cannot reference the real type here, as it causes runtime issues. See the README.md for more details + _webRequestInterfaceSymbol = compilation.GetTypeByMetadataName(typeof(IWebRequest).FullName!)!; + _webRequestResponseInterfaceSymbol = compilation.GetTypeByMetadataName(typeof(IWebRequest<>).FullName!)!; _webRouteAttributeSymbol = compilation.GetTypeByMetadataName(typeof(WebApiRouteAttribute).FullName!)!; _cancellationTokenSymbol = compilation.GetTypeByMetadataName(typeof(CancellationToken).FullName!)!; - _webHandlerReturnTypeSymbol = compilation.GetTypeByMetadataName(typeof(Task<>).FullName!)!; + _voidSymbol = compilation.GetTypeByMetadataName(typeof(void).FullName!)!; } public List OperationRegistrations { get; } = new(); @@ -52,6 +46,7 @@ public WebApiAssemblyVisitor(CancellationToken cancellationToken, Compilation co public override void VisitAssembly(IAssemblySymbol symbol) { _cancellationToken.ThrowIfCancellationRequested(); + symbol.GlobalNamespace.Accept(this); } @@ -66,7 +61,7 @@ public override void VisitNamedType(INamedTypeSymbol symbol) foreach (var nestedType in symbol.GetTypeMembers()) { - if (IsNotClass(nestedType)) + if (!nestedType.IsClass()) { continue; } @@ -79,44 +74,34 @@ public override void VisitNamedType(INamedTypeSymbol symbol) bool IsServiceClass() { - if (IsNotClass(symbol)) + if (!symbol.IsClass()) { return false; } - var accessibility = symbol.DeclaredAccessibility; - if (accessibility != Accessibility.Public && accessibility != Accessibility.Internal) + if (!symbol.IsPublicOrInternalClass()) { return false; } - if (symbol is not { IsAbstract: false, IsStatic: false }) + if (!symbol.IsConcreteInstanceClass()) { return false; } - if (IsIncorrectDerivedType(symbol)) + if (!symbol.IsDerivedFrom(_serviceInterfaceSymbol)) { return false; } return true; } - - bool IsIncorrectDerivedType(INamedTypeSymbol @class) - { - return !@class.AllInterfaces.Any(@interface => - SymbolEqualityComparer.Default.Equals(@interface, _serviceInterfaceSymbol)); - } - - bool IsNotClass(ITypeSymbol type) - { - return type.TypeKind != TypeKind.Class; - } } public override void VisitNamespace(INamespaceSymbol symbol) { + _cancellationToken.ThrowIfCancellationRequested(); + if (IsIgnoredNamespace()) { return; @@ -124,7 +109,6 @@ public override void VisitNamespace(INamespaceSymbol symbol) foreach (var namespaceOrType in symbol.GetMembers()) { - _cancellationToken.ThrowIfCancellationRequested(); namespaceOrType.Accept(this); } @@ -163,7 +147,7 @@ bool IsIgnoredNamespace() private void AddRegistration(INamedTypeSymbol symbol) { - var usingNamespaces = GetUsingNamespaces(); + var usingNamespaces = symbol.GetUsingNamespaces(); var constructors = GetConstructors(); var serviceName = GetServiceName(); var classRegistration = new ApiServiceClassRegistration @@ -174,16 +158,14 @@ private void AddRegistration(INamedTypeSymbol symbol) }; var methods = GetServiceOperationMethods(); - foreach (var method in methods) { - var routeAttribute = GetRouteAttribute(method); - if (routeAttribute is null) + if (!HasRouteAttribute(method, out var routeAttribute)) { continue; } - var attributeParameters = routeAttribute.ConstructorArguments; + var attributeParameters = routeAttribute!.ConstructorArguments; var routePath = attributeParameters[0].Value!.ToString()!; var operationType = attributeParameters.Length >= 2 ? FromOperationVerb(attributeParameters[1].Value!.ToString()!) @@ -197,8 +179,10 @@ private void AddRegistration(INamedTypeSymbol symbol) var responseType = GetResponseType(method.Parameters[0].Type); var responseTypeName = responseType.Name; var responseTypeNamespace = responseType.ContainingNamespace.ToDisplayString(); - var requestMethodBody = GetMethodBody(method); - var requestMethodName = method.Name; + var methodBody = method.GetMethodBody(); + var methodName = method.Name; + var isAsync = method.IsAsync; + var hasCancellationToken = method.Parameters.Length == 2; OperationRegistrations.Add(new ServiceOperationRegistration { @@ -207,8 +191,10 @@ private void AddRegistration(INamedTypeSymbol symbol) ResponseDtoType = new TypeName(responseTypeNamespace, responseTypeName), OperationType = operationType, IsTestingOnly = isTestingOnly, - MethodName = requestMethodName, - MethodBody = requestMethodBody, + IsAsync = isAsync, + HasCancellationToken = hasCancellationToken, + MethodName = methodName, + MethodBody = methodBody, RoutePath = routePath }); } @@ -220,19 +206,6 @@ TypeName GetServiceName() return new TypeName(symbol.ContainingNamespace.ToDisplayString(), symbol.Name); } - static string GetMethodBody(ISymbol method) - { - var syntaxReference = method.DeclaringSyntaxReferences.FirstOrDefault(); - - var syntax = syntaxReference?.GetSyntax(); - if (syntax is MethodDeclarationSyntax methodDeclarationSyntax) - { - return methodDeclarationSyntax.Body?.ToFullString() ?? string.Empty; - } - - return string.Empty; - } - static WebApiOperation FromOperationVerb(string? operation) { if (operation is null) @@ -246,9 +219,7 @@ static WebApiOperation FromOperationVerb(string? operation) // We assume that the request type derives from IWebRequest ITypeSymbol GetResponseType(ITypeSymbol requestType) { - var requestInterface = requestType.AllInterfaces.FirstOrDefault(@interface => - SymbolEqualityComparer.Default.Equals(@interface.OriginalDefinition, - _webRequestResponseInterfaceSymbol)); + var requestInterface = requestType.GetBaseType(_webRequestResponseInterfaceSymbol); if (requestInterface is null) { return requestType; @@ -261,21 +232,23 @@ List GetConstructors() { var ctors = new List(); var isInjectionCtor = false; + if (symbol.InstanceConstructors.IsDefaultOrEmpty) + { + return new List(); + } + foreach (var constructor in symbol.InstanceConstructors.OrderByDescending( method => method.Parameters.Length)) { if (!isInjectionCtor) { - if (constructor is - { IsStatic: false, DeclaredAccessibility: Accessibility.Public, Parameters.Length: > 0 }) + if (constructor.IsPublicInstance() && !constructor.IsParameterless()) { isInjectionCtor = true; } } - var body = constructor.DeclaringSyntaxReferences.FirstOrDefault() - ?.GetSyntax() - .ToFullString(); + var body = constructor.GetMethodBody(); ctors.Add(new Constructor { IsInjectionCtor = isInjectionCtor, @@ -298,7 +271,12 @@ List GetServiceOperationMethods() .OfType() .Where(method => { - if (IsIncorrectReturnType(method)) + if (!method.IsPublicOrInternalInstanceMethod()) + { + return false; + } + + if (!IsCorrectReturnType(method)) { return false; } @@ -313,36 +291,10 @@ List GetServiceOperationMethods() .ToList(); } - List GetUsingNamespaces() - { - var syntaxReference = symbol.DeclaringSyntaxReferences.FirstOrDefault(); - if (syntaxReference is null) - { - return new List(); - } - - var usingSyntaxes = syntaxReference.SyntaxTree.GetRoot() - .DescendantNodes() - .OfType(); - - return usingSyntaxes.Select(us => us.Name!.ToString()) - .Distinct() - .OrderDescending() - .ToList(); - } - - AttributeData? GetRouteAttribute(ISymbol method) - { - return method.GetAttributes() - .FirstOrDefault(attribute => - SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, _webRouteAttributeSymbol)); - } - - // We assume that the return type is a Task - bool IsIncorrectReturnType(IMethodSymbol method) + // We assume that the return type is anything but void + bool IsCorrectReturnType(IMethodSymbol method) { - return method.ReturnType.AllInterfaces.Any(@interface => - SymbolEqualityComparer.Default.Equals(@interface.OriginalDefinition, _webHandlerReturnTypeSymbol)); + return !method.ReturnType.IsTypeOf(_voidSymbol); } // We assume that the method one or two params: @@ -357,8 +309,7 @@ bool HasWrongSetOfParameters(IMethodSymbol method) } var firstParameter = parameters[0]; - if (!firstParameter.Type.AllInterfaces.Any(@interface => - SymbolEqualityComparer.Default.Equals(@interface.OriginalDefinition, _webRequestInterfaceSymbol))) + if (!firstParameter.Type.IsDerivedFrom(_webRequestInterfaceSymbol)) { return true; } @@ -366,7 +317,7 @@ bool HasWrongSetOfParameters(IMethodSymbol method) if (parameters.Length == 2) { var secondParameter = parameters[1]; - if (!SymbolEqualityComparer.Default.Equals(secondParameter.Type, _cancellationTokenSymbol)) + if (!secondParameter.Type.IsTypeOf(_cancellationTokenSymbol)) { return true; } @@ -374,12 +325,23 @@ bool HasWrongSetOfParameters(IMethodSymbol method) return false; } + + // We assume it is decorated with a WebRouteAttribute + bool HasRouteAttribute(IMethodSymbol method, out AttributeData? routeAttribute) + { + routeAttribute = method.GetAttribute(_webRouteAttributeSymbol); + return routeAttribute is not null; + } } public record ServiceOperationRegistration { public required ApiServiceClassRegistration Class { get; init; } + public required bool HasCancellationToken { get; init; } + + public required bool IsAsync { get; init; } + public required bool IsTestingOnly { get; init; } public string? MethodBody { get; set; }