Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 7 additions & 10 deletions docs/contract-less-composition-requests-handlers.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Contract-less composition handlers allow to write composition handlers using a s
<!-- snippet: contract-less-handler-sample -->
<a id='snippet-contract-less-handler-sample'></a>
```cs
namespace Snippets.Contractless.CompositionHandlers;
[CompositionHandler]
class SampleCompositionHandler
{
[HttpGet("/sample/{id}")]
Expand All @@ -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)
Expand All @@ -42,21 +41,19 @@ Given a class like the above one, a C# source generator will create a class like

```csharp
// <auto-generated/>
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}")]
Expand All @@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

namespace ServiceComposer.AspNetCore.SourceGeneration.Tests.TestFiles.CompositionHandlers;

[CompositionHandler]
class AHandlerWithANestedClassCompositionHandler
{
public class BodyClass
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

namespace ServiceComposer.AspNetCore.SourceGeneration.Tests.TestFiles.CompositionHandlers;

[CompositionHandler]
class ClassWithInternalMethodCompositionHandler
{
[Authorize]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}")]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace ServiceComposer.AspNetCore.SourceGeneration.Tests.TestFiles.CompositionHandlers;

[CompositionHandler]
class WithQueryBindingWithoutAttributeCompositionHandler
{
[ResponseCache(Duration = 10, Location = ResponseCacheLocation.Client), HttpPost("/sample/{v}")]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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!);
Expand All @@ -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)
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ namespace ServiceComposer.AspNetCore
}
public delegate System.Threading.Tasks.Task CompositionEventHandler<in TEvent>(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
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ namespace ServiceComposer.AspNetCore.Tests.CompositionHandlers
{
public class Get_with_2_handlers
{
[CompositionHandler]
public class TestGetIntegerCompositionHandler(IHttpContextAccessor httpContextAccessor)
{
[HttpGet("/sample/{id}")]
Expand All @@ -24,6 +25,7 @@ public Task SomeMethod(int id)
}
}

[CompositionHandler]
public class TestGetStringCompositionHandler(IHttpContextAccessor httpContextAccessor)
{
[HttpGet("/sample/{id}")]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ public record AnEvent

}

[CompositionHandler]
public class CaseInsensitiveRouteTestGetIntegerCompositionHandler(IHttpContextAccessor httpContextAccessor)
{
[HttpGet("/api/compositionovercontrollerusingcompositionhandlers/{id}")]
Expand All @@ -47,6 +48,7 @@ public Task Handle(Model model)
}
}

[CompositionHandler]
public class CaseSensitiveRouteTestGetIntegerCompositionHandler(IHttpContextAccessor httpContextAccessor)
{
[HttpGet("/api/CompositionOverControllerUsingCompositionHandlers/{id}")]
Expand All @@ -59,6 +61,7 @@ public Task Handle(Model model)
}
}

[CompositionHandler]
public class TestGetStringCompositionHandler(IHttpContextAccessor httpContextAccessor)
{
[HttpGet("/api/CompositionOverControllerUsingCompositionHandlers/{id}")]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ namespace ServiceComposer.AspNetCore.Tests.CompositionHandlers
{
public class When_using_custom_services_registration_handlers
{
[CompositionHandler]
public class TestNoOpCompositionHandler
{
[HttpGet("/sample/{id}")]
Expand Down
13 changes: 13 additions & 0 deletions src/ServiceComposer.AspNetCore/CompositionHandlerAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using System;

namespace ServiceComposer.AspNetCore;

/// <summary>
/// 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.
/// </summary>
[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
public sealed class CompositionHandlerAttribute : Attribute
{
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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}")]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

namespace TestClassLibraryWithHandlers.CompositionHandlers;

[CompositionHandler]
public class TestContractLessCompositionHandler(IHttpContextAccessor contextAccessor)
{
[HttpGet("/contract-less-handler/{id}")]
Expand Down