diff --git a/docs/contract-less-composition-requests-handlers.md b/docs/contract-less-composition-requests-handlers.md index 3da77a6e..e35a45d4 100644 --- a/docs/contract-less-composition-requests-handlers.md +++ b/docs/contract-less-composition-requests-handlers.md @@ -7,7 +7,7 @@ Contract-less composition handlers allow to write composition handlers using a s ```cs -namespace Snippets.Contractless.CompositionHandlers; +[CompositionHandler] class SampleCompositionHandler { [HttpGet("/sample/{id}")] @@ -22,11 +22,10 @@ class SampleCompositionHandler Compared to what is available today, when using classes that implement `ICompositionRequestsHandler`, contract-less composition request handlers allow grouping handlers belonging to the same logical context, rather than being forced to artificially split them into multiple classes due to the need to implement an interface. -The syntax is nonetheless similar to ASP.NET controller actions. At compilation time, ServiceComposer identifies contract-less composition request handlers by matching classes and methods against a set of conventions: +The syntax is nonetheless similar to ASP.NET controller actions. At compilation time, ServiceComposer identifies contract-less composition request handlers by the presence of the `[CompositionHandler]` attribute on the class: -- Contract-less composition request handlers must be defined in a `CompositionHandlers` namespace or a namespace ending with `.CompositionHandlers`. -- Contract-less composition request handlers class names must be suffixed with `CompositionHandler` -- The contract-less composition request handler class can contain one or methods; these methods must: +- Contract-less composition request handlers must be decorated with the `[CompositionHandler]` attribute. +- The contract-less composition request handler class can contain one or more methods; these methods must: - Be either `public` or `internal` - Return a `Task` - Be decorated with one, and only one, `Http*` attribute (known limitation for now) @@ -42,21 +41,19 @@ Given a class like the above one, a C# source generator will create a class like ```csharp // -using CompositionHandlers; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ModelBinding; using ServiceComposer.AspNetCore; -using Snippets.Contractless.CompositionHandlers; using System; using System.ComponentModel; using System.Threading.Tasks; #pragma warning disable SC0001 -namespace Snippets.Contractless.CompositionHandlers.Generated +namespace YourNamespace.Generated { [EditorBrowsable(EditorBrowsableState.Never)] - class SampleCompositionHandler_SampleMethod_int_id_string_aValue_Snippets_Contractless_CompositionHandlers_ComplexType_ct(Snippets.Contractless.CompositionHandlers.SampleCompositionHandler userHandler) + class SampleCompositionHandler_SampleMethod_int_id_string_aValue_ComplexType_ct(SampleCompositionHandler userHandler) : ICompositionRequestsHandler { [HttpGetAttribute("/sample/{id}")] @@ -78,4 +75,4 @@ namespace Snippets.Contractless.CompositionHandlers.Generated #pragma warning restore SC0001 ``` -Both classes will be registered in DI, allowing the injection of dependencies into the user contract-less composition handler. If the user contract-less composition handler defines more than one method matching the abovementioned conventions, one class for each method will be generated. The generated class name will guarantee uniqueness. +Both classes will be registered in DI, allowing the injection of dependencies into the user contract-less composition handler. If the user contract-less composition handler defines more than one method matching the above requirements, one class for each method will be generated. The generated class name will guarantee uniqueness. diff --git a/src/ServiceComposer.AspNetCore.SourceGeneration.Tests/TestFiles.CompositionHandlers/AHandlerWithANestedClassCompositionHandler.cs b/src/ServiceComposer.AspNetCore.SourceGeneration.Tests/TestFiles.CompositionHandlers/AHandlerWithANestedClassCompositionHandler.cs index 9b79bccb..dce7cd6b 100644 --- a/src/ServiceComposer.AspNetCore.SourceGeneration.Tests/TestFiles.CompositionHandlers/AHandlerWithANestedClassCompositionHandler.cs +++ b/src/ServiceComposer.AspNetCore.SourceGeneration.Tests/TestFiles.CompositionHandlers/AHandlerWithANestedClassCompositionHandler.cs @@ -3,6 +3,7 @@ namespace ServiceComposer.AspNetCore.SourceGeneration.Tests.TestFiles.CompositionHandlers; +[CompositionHandler] class AHandlerWithANestedClassCompositionHandler { public class BodyClass diff --git a/src/ServiceComposer.AspNetCore.SourceGeneration.Tests/TestFiles.CompositionHandlers/ClassWithInternalMethodCompositionHandler.cs b/src/ServiceComposer.AspNetCore.SourceGeneration.Tests/TestFiles.CompositionHandlers/ClassWithInternalMethodCompositionHandler.cs index e608fd29..ce1fa175 100644 --- a/src/ServiceComposer.AspNetCore.SourceGeneration.Tests/TestFiles.CompositionHandlers/ClassWithInternalMethodCompositionHandler.cs +++ b/src/ServiceComposer.AspNetCore.SourceGeneration.Tests/TestFiles.CompositionHandlers/ClassWithInternalMethodCompositionHandler.cs @@ -3,6 +3,7 @@ namespace ServiceComposer.AspNetCore.SourceGeneration.Tests.TestFiles.CompositionHandlers; +[CompositionHandler] class ClassWithInternalMethodCompositionHandler { [Authorize] diff --git a/src/ServiceComposer.AspNetCore.SourceGeneration.Tests/TestFiles.CompositionHandlers/ContainerWithNestedClass.NestedClassCompositionHandler.cs b/src/ServiceComposer.AspNetCore.SourceGeneration.Tests/TestFiles.CompositionHandlers/ContainerWithNestedClass.NestedClassCompositionHandler.cs index 8e0c49e4..ef179531 100644 --- a/src/ServiceComposer.AspNetCore.SourceGeneration.Tests/TestFiles.CompositionHandlers/ContainerWithNestedClass.NestedClassCompositionHandler.cs +++ b/src/ServiceComposer.AspNetCore.SourceGeneration.Tests/TestFiles.CompositionHandlers/ContainerWithNestedClass.NestedClassCompositionHandler.cs @@ -4,6 +4,7 @@ namespace ServiceComposer.AspNetCore.SourceGeneration.Tests.TestFiles.Compositio public class ContainerWithNestedClass { + [CompositionHandler] public class NestedClassCompositionHandler { [ResponseCache(Duration = 10, Location = ResponseCacheLocation.Client), HttpPost("/sample/{v}")] diff --git a/src/ServiceComposer.AspNetCore.SourceGeneration.Tests/TestFiles.CompositionHandlers/WithQueryBindingWithoutAttributeCompositionHandler.cs b/src/ServiceComposer.AspNetCore.SourceGeneration.Tests/TestFiles.CompositionHandlers/WithQueryBindingWithoutAttributeCompositionHandler.cs index e24465c0..f594383d 100644 --- a/src/ServiceComposer.AspNetCore.SourceGeneration.Tests/TestFiles.CompositionHandlers/WithQueryBindingWithoutAttributeCompositionHandler.cs +++ b/src/ServiceComposer.AspNetCore.SourceGeneration.Tests/TestFiles.CompositionHandlers/WithQueryBindingWithoutAttributeCompositionHandler.cs @@ -2,6 +2,7 @@ namespace ServiceComposer.AspNetCore.SourceGeneration.Tests.TestFiles.CompositionHandlers; +[CompositionHandler] class WithQueryBindingWithoutAttributeCompositionHandler { [ResponseCache(Duration = 10, Location = ResponseCacheLocation.Client), HttpPost("/sample/{v}")] diff --git a/src/ServiceComposer.AspNetCore.SourceGeneration.Tests/Tester.cs b/src/ServiceComposer.AspNetCore.SourceGeneration.Tests/Tester.cs index 79d5013c..973b21dc 100644 --- a/src/ServiceComposer.AspNetCore.SourceGeneration.Tests/Tester.cs +++ b/src/ServiceComposer.AspNetCore.SourceGeneration.Tests/Tester.cs @@ -34,7 +34,8 @@ static Compilation CreateCompilation(string source) references: [ MetadataReference.CreateFromFile(typeof(object).Assembly.Location), - MetadataReference.CreateFromFile(typeof(Enumerable).Assembly.Location) + MetadataReference.CreateFromFile(typeof(Enumerable).Assembly.Location), + MetadataReference.CreateFromFile(typeof(CompositionHandlerAttribute).Assembly.Location) ], options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); diff --git a/src/ServiceComposer.AspNetCore.SourceGeneration/CompositionHandlerWrapperGenerator.cs b/src/ServiceComposer.AspNetCore.SourceGeneration/CompositionHandlerWrapperGenerator.cs index aae91ce8..cafdc811 100644 --- a/src/ServiceComposer.AspNetCore.SourceGeneration/CompositionHandlerWrapperGenerator.cs +++ b/src/ServiceComposer.AspNetCore.SourceGeneration/CompositionHandlerWrapperGenerator.cs @@ -36,10 +36,10 @@ public class CompositionHandlerWrapperGenerator : IIncrementalGenerator public void Initialize(IncrementalGeneratorInitializationContext context) { - // Create a provider that finds all methods with HTTP attributes + // Create a provider that finds methods in classes with [CompositionHandler] attribute var methodProvider = context.SyntaxProvider .CreateSyntaxProvider( - predicate: static (node, _) => IsMethodWithAttributes(node), + predicate: static (node, _) => IsCompositionHandlerMethod(node), transform: (ctx, _) => GetCompositionHandlerMethod(ctx)) .Where(static m => m is not null) .Select(static (m, _) => m!); @@ -52,10 +52,33 @@ public void Initialize(IncrementalGeneratorInitializationContext context) (spc, source) => Execute(spc, source.Left, source.Right)); } - static bool IsMethodWithAttributes(SyntaxNode node) + static bool IsCompositionHandlerMethod(SyntaxNode node) { - return node is MethodDeclarationSyntax { AttributeLists.Count: > 0 } method - && !method.SyntaxTree.FilePath.EndsWith(".g.cs"); + // Fast syntax-only check: method with attributes in a class that has CompositionHandler attribute + if (node is not MethodDeclarationSyntax { AttributeLists.Count: > 0 } method) + return false; + + if (method.SyntaxTree.FilePath.EndsWith(".g.cs")) + return false; + + // Check if the containing class has an attribute that looks like CompositionHandler + if (method.Parent is not ClassDeclarationSyntax classDeclaration) + return false; + + // Quick syntax check for CompositionHandler attribute on the class + foreach (var attributeList in classDeclaration.AttributeLists) + { + foreach (var attribute in attributeList.Attributes) + { + var attributeName = attribute.Name.ToString(); + if (attributeName is "CompositionHandler" or "CompositionHandlerAttribute") + { + return true; + } + } + } + + return false; } CompositionHandlerMethodInfo? GetCompositionHandlerMethod(GeneratorSyntaxContext context) @@ -74,16 +97,11 @@ static bool IsMethodWithAttributes(SyntaxNode node) var userClassNamespace = GetNamespace(methodDeclaration); var userClassesHierarchy = GetUserClassesHierarchy(methodDeclaration); - // TODO How are conventions shared with ServiceComposer? - // somehow this conventions must be shared with ServiceComposer - // that uses them to register user types in the IoC container - var namespaceMatchesConventions = userClassNamespace != null ? - userClassNamespace == "CompositionHandlers" || userClassNamespace.EndsWith(".CompositionHandlers") : false; - var classNameMatchesConventions = userClassesHierarchy.Last().EndsWith("CompositionHandler"); + // Predicate already verified the class has [CompositionHandler] attribute var isTaskReturnType = methodDeclaration.ReturnType.ToString() == "Task"; var isMethodPublic = methodDeclaration.Modifiers.Any(m => m.Text != "private"); - if (isMethodPublic && namespaceMatchesConventions && classNameMatchesConventions && isTaskReturnType) + if (isMethodPublic && isTaskReturnType) { return new CompositionHandlerMethodInfo( methodDeclaration, diff --git a/src/ServiceComposer.AspNetCore.Tests/API/APIApprovals.Approve_API.verified.txt b/src/ServiceComposer.AspNetCore.Tests/API/APIApprovals.Approve_API.verified.txt index a64115ca..84e5fa18 100644 --- a/src/ServiceComposer.AspNetCore.Tests/API/APIApprovals.Approve_API.verified.txt +++ b/src/ServiceComposer.AspNetCore.Tests/API/APIApprovals.Approve_API.verified.txt @@ -53,6 +53,11 @@ namespace ServiceComposer.AspNetCore } public delegate System.Threading.Tasks.Task CompositionEventHandler(TEvent @event, Microsoft.AspNetCore.Http.HttpRequest httpRequest); public static class CompositionHandler { } + [System.AttributeUsage(System.AttributeTargets.Class, AllowMultiple=false, Inherited=false)] + public sealed class CompositionHandlerAttribute : System.Attribute + { + public CompositionHandlerAttribute() { } + } [System.AttributeUsage(System.AttributeTargets.Class | System.AttributeTargets.Method)] public abstract class CompositionRequestFilterAttribute : System.Attribute, ServiceComposer.AspNetCore.ICompositionRequestFilter { diff --git a/src/ServiceComposer.AspNetCore.Tests/CompositionHandlers/Get_with_2_handlers.cs b/src/ServiceComposer.AspNetCore.Tests/CompositionHandlers/Get_with_2_handlers.cs index 89edb373..2022c436 100644 --- a/src/ServiceComposer.AspNetCore.Tests/CompositionHandlers/Get_with_2_handlers.cs +++ b/src/ServiceComposer.AspNetCore.Tests/CompositionHandlers/Get_with_2_handlers.cs @@ -11,6 +11,7 @@ namespace ServiceComposer.AspNetCore.Tests.CompositionHandlers { public class Get_with_2_handlers { + [CompositionHandler] public class TestGetIntegerCompositionHandler(IHttpContextAccessor httpContextAccessor) { [HttpGet("/sample/{id}")] @@ -24,6 +25,7 @@ public Task SomeMethod(int id) } } + [CompositionHandler] public class TestGetStringCompositionHandler(IHttpContextAccessor httpContextAccessor) { [HttpGet("/sample/{id}")] diff --git a/src/ServiceComposer.AspNetCore.Tests/CompositionHandlers/Get_with_2_handlers_output_formatter_file_stream_result.cs b/src/ServiceComposer.AspNetCore.Tests/CompositionHandlers/Get_with_2_handlers_output_formatter_file_stream_result.cs index c0fec78a..d85aeddf 100644 --- a/src/ServiceComposer.AspNetCore.Tests/CompositionHandlers/Get_with_2_handlers_output_formatter_file_stream_result.cs +++ b/src/ServiceComposer.AspNetCore.Tests/CompositionHandlers/Get_with_2_handlers_output_formatter_file_stream_result.cs @@ -15,6 +15,7 @@ public class Get_with_2_handlers_output_formatter_file_stream_result { static readonly string expected_string_content = Guid.NewGuid().ToString(); + [CompositionHandler] public class TestGetStringCompositionHandler(IHttpContextAccessor httpContextAccessor) { [HttpGet("/sample/using-body-stream")] diff --git a/src/ServiceComposer.AspNetCore.Tests/CompositionHandlers/When_using_composition_over_controllers_get_with_2_handlers.cs b/src/ServiceComposer.AspNetCore.Tests/CompositionHandlers/When_using_composition_over_controllers_get_with_2_handlers.cs index 128ea31f..3570648f 100644 --- a/src/ServiceComposer.AspNetCore.Tests/CompositionHandlers/When_using_composition_over_controllers_get_with_2_handlers.cs +++ b/src/ServiceComposer.AspNetCore.Tests/CompositionHandlers/When_using_composition_over_controllers_get_with_2_handlers.cs @@ -35,6 +35,7 @@ public record AnEvent } + [CompositionHandler] public class CaseInsensitiveRouteTestGetIntegerCompositionHandler(IHttpContextAccessor httpContextAccessor) { [HttpGet("/api/compositionovercontrollerusingcompositionhandlers/{id}")] @@ -47,6 +48,7 @@ public Task Handle(Model model) } } + [CompositionHandler] public class CaseSensitiveRouteTestGetIntegerCompositionHandler(IHttpContextAccessor httpContextAccessor) { [HttpGet("/api/CompositionOverControllerUsingCompositionHandlers/{id}")] @@ -59,6 +61,7 @@ public Task Handle(Model model) } } + [CompositionHandler] public class TestGetStringCompositionHandler(IHttpContextAccessor httpContextAccessor) { [HttpGet("/api/CompositionOverControllerUsingCompositionHandlers/{id}")] diff --git a/src/ServiceComposer.AspNetCore.Tests/CompositionHandlers/When_using_custom_services_registration_handlers.cs b/src/ServiceComposer.AspNetCore.Tests/CompositionHandlers/When_using_custom_services_registration_handlers.cs index 42410199..09fd200d 100644 --- a/src/ServiceComposer.AspNetCore.Tests/CompositionHandlers/When_using_custom_services_registration_handlers.cs +++ b/src/ServiceComposer.AspNetCore.Tests/CompositionHandlers/When_using_custom_services_registration_handlers.cs @@ -9,6 +9,7 @@ namespace ServiceComposer.AspNetCore.Tests.CompositionHandlers { public class When_using_custom_services_registration_handlers { + [CompositionHandler] public class TestNoOpCompositionHandler { [HttpGet("/sample/{id}")] diff --git a/src/ServiceComposer.AspNetCore/CompositionHandlerAttribute.cs b/src/ServiceComposer.AspNetCore/CompositionHandlerAttribute.cs new file mode 100644 index 00000000..398055c2 --- /dev/null +++ b/src/ServiceComposer.AspNetCore/CompositionHandlerAttribute.cs @@ -0,0 +1,13 @@ +using System; + +namespace ServiceComposer.AspNetCore; + +/// +/// Marks a class as a composition handler for contract-less composition. +/// Classes decorated with this attribute will be discovered by the source generator +/// and have wrapper classes generated for their HTTP-decorated methods. +/// +[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)] +public sealed class CompositionHandlerAttribute : Attribute +{ +} diff --git a/src/ServiceComposer.AspNetCore/ViewModelCompositionOptions.cs b/src/ServiceComposer.AspNetCore/ViewModelCompositionOptions.cs index add57c2f..e028a693 100644 --- a/src/ServiceComposer.AspNetCore/ViewModelCompositionOptions.cs +++ b/src/ServiceComposer.AspNetCore/ViewModelCompositionOptions.cs @@ -326,9 +326,7 @@ static bool IsContractlessCompositionHandler(Type type) var typeInfo = type.GetTypeInfo(); return !typeInfo.IsInterface && !typeInfo.IsAbstract - && type.Namespace != null - && (type.Namespace == "CompositionHandlers" || type.Namespace!.EndsWith(".CompositionHandlers") - && type.Name.EndsWith("CompositionHandler")); + && type.GetCustomAttributes(typeof(CompositionHandlerAttribute), false).Length > 0; } void RegisterEndpointScopedViewModelFactory(Type viewModelFactoryType) diff --git a/src/Snippets/Contractless.CompositionHandlers/SampleCompositionHandler.cs b/src/Snippets/Contractless.CompositionHandlers/SampleCompositionHandler.cs index 44dbd45c..0ac73b96 100644 --- a/src/Snippets/Contractless.CompositionHandlers/SampleCompositionHandler.cs +++ b/src/Snippets/Contractless.CompositionHandlers/SampleCompositionHandler.cs @@ -1,8 +1,11 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; +using ServiceComposer.AspNetCore; -#region contract-less-handler-sample namespace Snippets.Contractless.CompositionHandlers; + +#region contract-less-handler-sample +[CompositionHandler] class SampleCompositionHandler { [HttpGet("/sample/{id}")] diff --git a/src/TestClassLibraryWithHandlers/CompositionHandlers/TestContractLessCompositionHandler.cs b/src/TestClassLibraryWithHandlers/CompositionHandlers/TestContractLessCompositionHandler.cs index 2991ddcd..897582a5 100644 --- a/src/TestClassLibraryWithHandlers/CompositionHandlers/TestContractLessCompositionHandler.cs +++ b/src/TestClassLibraryWithHandlers/CompositionHandlers/TestContractLessCompositionHandler.cs @@ -5,6 +5,7 @@ namespace TestClassLibraryWithHandlers.CompositionHandlers; +[CompositionHandler] public class TestContractLessCompositionHandler(IHttpContextAccessor contextAccessor) { [HttpGet("/contract-less-handler/{id}")]