diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b5dff21d..2feacdee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,7 @@ jobs: dotnet-version: | 8.0.x 9.0.x + 10.0.x - name: Build run: dotnet build src --configuration Release - name: Tests diff --git a/context/context.md b/context/context.md index 5d8ba30f..1041877b 100644 --- a/context/context.md +++ b/context/context.md @@ -87,7 +87,7 @@ endpoints.MapCompositionHandlers() - Groups all registered components by route template (from [HttpGet], [HttpPost], etc.) - For each unique route+method combination, creates a CompositionEndpointBuilder - If CompositionOverControllers is enabled AND a controller already owns the route, - stores those handler types in CompositionOverControllersRoutes instead + stores those handler types and their method metadata in CompositionOverControllersRoutes instead - Registers endpoints via CompositionEndpointDataSource ``` @@ -100,10 +100,11 @@ HTTP Request Enable request body buffering (allows multiple handlers to read body) | v -Model Binding Phase (CompositionEndpointBuilder.BindingArguments) +Model Binding Phase (ComponentsModelBinder) - For each component with [BindModel*] attributes on its Handle/Subscribe method - Uses RequestModelBinder (wraps ASP.NET Core's IModelBinderFactory) - Results stored in IDictionary> + - Shared by both composition endpoints and composition over controllers | v Endpoint Filter Pipeline (ASP.NET Core IEndpointFilter chain, cached after first build) @@ -149,6 +150,7 @@ Controller executes normally and produces a result OnResultExecutionAsync fires - Matches the route against CompositionOverControllersRoutes - If composition handlers exist for this route: + -> Performs model binding via ComponentsModelBinder using stored handler metadata -> Runs the full composition pipeline (same as above) -> Merges the composed view model into: - ViewResult.ViewData.Model (MVC) @@ -158,7 +160,7 @@ OnResultExecutionAsync fires Result executes with composed data ``` -**Limitation:** Model binding arguments are NOT supported in this mode (an explicit `NotSupportedException` is thrown). +Both contract-based (`ICompositionRequestsHandler`) and contract-less composition handlers are supported in this mode. ## Composition Events System @@ -212,6 +214,13 @@ public Task Handle(HttpRequest request) ### 3. Source-Generated (contract-less handlers) Convention: class in `*.CompositionHandlers` namespace, name ends with `CompositionHandler`, method decorated with `[Http*]` attribute, returns `Task`. The source generator creates a wrapper implementing `ICompositionRequestsHandler` with appropriate `BindModel` attributes. +Source generator binding heuristics for method parameters (evaluated in order): +1. Parameter name matches route template placeholder → `BindFromRoute` +2. Parameter has explicit `[FromBody]` → `BindFromBody` +3. Parameter has explicit `[FromForm]` → `BindFromForm` +4. Parameter has explicit `[FromQuery]` or is a simple type → `BindFromQuery` +5. Complex type without explicit binding attribute → `Bind` (multi-source, respects property-level attributes like `[FromRoute]`) + ### Binding Attributes | Attribute | Binding Source | |---|---| @@ -318,7 +327,8 @@ A convention-based alternative to implementing `ICompositionRequestsHandler`. Ru | `ViewModelCompositionOptions.cs` | `ViewModelCompositionOptions` - registration orchestrator | | `EndpointsExtensions.cs` | `MapCompositionHandlers()` - endpoint mapping | | `CompositionEndpointBuilder.cs` | `Build()` - creates the endpoint request delegate | -| `CompositionEndpointBuilder.BindingArguments.cs` | `GetAllComponentsArguments()` - model binding | +| `ComponentsModelBinder.cs` | `BindAll()` - shared model binding for all composition paths | +| `CompositionEndpointBuilder.BindingArguments.cs` | `GetAllComponentsArguments()` - delegates to `ComponentsModelBinder` | | `CompositionEndpointBuilder.CompositionFilters.cs` | Composition filter pipeline builder | | `CompositionEndpointBuilder.EndpointFilters.cs` | Endpoint filter pipeline builder (cached) | | `CompositionEndpointDataSource.cs` | Custom `EndpointDataSource` implementation | @@ -360,7 +370,7 @@ A convention-based alternative to implementing `ICompositionRequestsHandler`. Ru | File | Purpose | |---|---| | `CompositionOverControllersActionFilter.cs` | `IAsyncResultFilter` - intercepts controller results | -| `CompositionOverControllersRoutes.cs` | Registry of routes with composition handlers | +| `CompositionOverControllersRoutes.cs` | Registry of routes with composition handlers and their method metadata | | `CompositionOverControllersOptions.cs` | `IsEnabled`, `UseCaseInsensitiveRouteMatching` | ### Discovery @@ -393,5 +403,5 @@ A convention-based alternative to implementing `ICompositionRequestsHandler`. Ru - `CompositionHandler.cs:38` - Second 404 shortcut - `CompositionHandler.cs:42` - Apply composition filter per-handler, not before whole composition - `CompositionEndpointBuilder.cs:121` - Source-generate convention-based filter invocation context -- `CompositionEndpointBuilder.BindingArguments.cs:31` - Cache RequestModelBinder instance -- `CompositionEndpointBuilder.BindingArguments.cs:38` - Throw if binding failed +- `ComponentsModelBinder.cs:33` - Cache RequestModelBinder instance +- `ComponentsModelBinder.cs:40` - Throw if binding failed diff --git a/docs/composition-over-controllers.md b/docs/composition-over-controllers.md index 39171d70..9cdb4fc3 100644 --- a/docs/composition-over-controllers.md +++ b/docs/composition-over-controllers.md @@ -15,4 +15,6 @@ services.AddViewModelComposition(options => Once composition over controllers is enabled, ServiceComposer will inject a MVC filter to intercept all controllers invocations. If a route matches a regular controller and a set of composition handlers ServiceComposer will invoke the matching handlers after the controller and before the view is rendered. +Composition over controllers supports both regular composition handlers (classes implementing `ICompositionRequestsHandler`) and [contract-less composition handlers](contract-less-composition-requests-handlers.md), including full model binding support for both. + Composition over controllers can be used as a templating engine leveraging the excellent Razor engine. Optionally, it can be used to add ViewModel Composition support to MVC web application without introducing a separate composition gateway. diff --git a/docs/contract-less-composition-requests-handlers.md b/docs/contract-less-composition-requests-handlers.md index 9066a950..3da77a6e 100644 --- a/docs/contract-less-composition-requests-handlers.md +++ b/docs/contract-less-composition-requests-handlers.md @@ -35,7 +35,6 @@ The syntax is nonetheless similar to ASP.NET controller actions. At compilation ### Known limitations - Different from classes implementing `ICompositionRequestsHandler`, contract-less composition handlers, at the moment, support only one `Http*` attribute per method; -- At the moment, contract-less composition handlers cannot be used when using [composition over controllers](composition-over-controllers.md); ## Source generation @@ -70,7 +69,7 @@ namespace Snippets.Contractless.CompositionHandlers.Generated var arguments = ctx.GetArguments(this); var p0_id = ModelBindingArgumentExtensions.Argument(arguments, "id", BindingSource.Path); var p1_c = ModelBindingArgumentExtensions.Argument(arguments, "c", BindingSource.Query); - var p2_ct = ModelBindingArgumentExtensions.Argument(arguments, "ct", BindingSource.Body); + var p2_ct = ModelBindingArgumentExtensions.Argument(arguments, BindingSource.Body); return userHandler.SampleMethod(p0_id, p1_c, p2_ct); } diff --git a/src/ServiceComposer.AspNetCore.SourceGeneration.Tests/ApprovedFiles/AHandlerWithANestedClassCompositionHandler.cs.net8.0.verified.txt b/src/ServiceComposer.AspNetCore.SourceGeneration.Tests/ApprovedFiles/AHandlerWithANestedClassCompositionHandler.cs.net8.0.verified.txt index 6d13d2f4..5b0bf731 100644 --- a/src/ServiceComposer.AspNetCore.SourceGeneration.Tests/ApprovedFiles/AHandlerWithANestedClassCompositionHandler.cs.net8.0.verified.txt +++ b/src/ServiceComposer.AspNetCore.SourceGeneration.Tests/ApprovedFiles/AHandlerWithANestedClassCompositionHandler.cs.net8.0.verified.txt @@ -27,7 +27,7 @@ namespace ServiceComposer.AspNetCore.SourceGeneration.Tests.TestFiles.Compositio var arguments = ctx.GetArguments(this); var p0_id = ModelBindingArgumentExtensions.Argument(arguments, "id", BindingSource.Path); var p1_x = ModelBindingArgumentExtensions.Argument(arguments, "x", BindingSource.Query); - var p2_body = ModelBindingArgumentExtensions.Argument(arguments, "body", BindingSource.Body); + var p2_body = ModelBindingArgumentExtensions.Argument(arguments, BindingSource.Body); return userHandler.Post(p0_id, p1_x, p2_body); } diff --git a/src/ServiceComposer.AspNetCore.SourceGeneration.Tests/ApprovedFiles/AHandlerWithANestedClassCompositionHandler.cs.net9.0.verified.txt b/src/ServiceComposer.AspNetCore.SourceGeneration.Tests/ApprovedFiles/AHandlerWithANestedClassCompositionHandler.cs.net9.0.verified.txt index 6d13d2f4..5b0bf731 100644 --- a/src/ServiceComposer.AspNetCore.SourceGeneration.Tests/ApprovedFiles/AHandlerWithANestedClassCompositionHandler.cs.net9.0.verified.txt +++ b/src/ServiceComposer.AspNetCore.SourceGeneration.Tests/ApprovedFiles/AHandlerWithANestedClassCompositionHandler.cs.net9.0.verified.txt @@ -27,7 +27,7 @@ namespace ServiceComposer.AspNetCore.SourceGeneration.Tests.TestFiles.Compositio var arguments = ctx.GetArguments(this); var p0_id = ModelBindingArgumentExtensions.Argument(arguments, "id", BindingSource.Path); var p1_x = ModelBindingArgumentExtensions.Argument(arguments, "x", BindingSource.Query); - var p2_body = ModelBindingArgumentExtensions.Argument(arguments, "body", BindingSource.Body); + var p2_body = ModelBindingArgumentExtensions.Argument(arguments, BindingSource.Body); return userHandler.Post(p0_id, p1_x, p2_body); } diff --git a/src/ServiceComposer.AspNetCore.SourceGeneration.Tests/ApprovedFiles/WithQueryBindingWithoutAttributeCompositionHandler.cs.net8.0.verified.txt b/src/ServiceComposer.AspNetCore.SourceGeneration.Tests/ApprovedFiles/WithQueryBindingWithoutAttributeCompositionHandler.cs.net8.0.verified.txt index 9209cc8b..e3e39007 100644 --- a/src/ServiceComposer.AspNetCore.SourceGeneration.Tests/ApprovedFiles/WithQueryBindingWithoutAttributeCompositionHandler.cs.net8.0.verified.txt +++ b/src/ServiceComposer.AspNetCore.SourceGeneration.Tests/ApprovedFiles/WithQueryBindingWithoutAttributeCompositionHandler.cs.net8.0.verified.txt @@ -28,7 +28,7 @@ namespace ServiceComposer.AspNetCore.SourceGeneration.Tests.TestFiles.Compositio var p0_v = ModelBindingArgumentExtensions.Argument(arguments, "v", BindingSource.Path); var p1_c = ModelBindingArgumentExtensions.Argument(arguments, "c", BindingSource.Query); var p2_v = ModelBindingArgumentExtensions.Argument(arguments, "v", BindingSource.Query); - var p3_body = ModelBindingArgumentExtensions.Argument(arguments, "body", BindingSource.Body); + var p3_body = ModelBindingArgumentExtensions.Argument(arguments, BindingSource.Body); return userHandler.Post(p0_v, p1_c, p2_v, p3_body); } diff --git a/src/ServiceComposer.AspNetCore.SourceGeneration.Tests/ApprovedFiles/WithQueryBindingWithoutAttributeCompositionHandler.cs.net9.0.verified.txt b/src/ServiceComposer.AspNetCore.SourceGeneration.Tests/ApprovedFiles/WithQueryBindingWithoutAttributeCompositionHandler.cs.net9.0.verified.txt index 9209cc8b..e3e39007 100644 --- a/src/ServiceComposer.AspNetCore.SourceGeneration.Tests/ApprovedFiles/WithQueryBindingWithoutAttributeCompositionHandler.cs.net9.0.verified.txt +++ b/src/ServiceComposer.AspNetCore.SourceGeneration.Tests/ApprovedFiles/WithQueryBindingWithoutAttributeCompositionHandler.cs.net9.0.verified.txt @@ -28,7 +28,7 @@ namespace ServiceComposer.AspNetCore.SourceGeneration.Tests.TestFiles.Compositio var p0_v = ModelBindingArgumentExtensions.Argument(arguments, "v", BindingSource.Path); var p1_c = ModelBindingArgumentExtensions.Argument(arguments, "c", BindingSource.Query); var p2_v = ModelBindingArgumentExtensions.Argument(arguments, "v", BindingSource.Query); - var p3_body = ModelBindingArgumentExtensions.Argument(arguments, "body", BindingSource.Body); + var p3_body = ModelBindingArgumentExtensions.Argument(arguments, BindingSource.Body); return userHandler.Post(p0_v, p1_c, p2_v, p3_body); } diff --git a/src/ServiceComposer.AspNetCore.SourceGeneration/CompositionHandlerWrapperGenerator.cs b/src/ServiceComposer.AspNetCore.SourceGeneration/CompositionHandlerWrapperGenerator.cs index 393b8116..aae91ce8 100644 --- a/src/ServiceComposer.AspNetCore.SourceGeneration/CompositionHandlerWrapperGenerator.cs +++ b/src/ServiceComposer.AspNetCore.SourceGeneration/CompositionHandlerWrapperGenerator.cs @@ -342,7 +342,14 @@ SeparatedSyntaxList parameters var boundParameter = boundParameters[i]; var arg = $"p{i}_{boundParameter.parameterName}"; generatedArgs.Add(arg); - builder.AppendLine($" var {arg} = ModelBindingArgumentExtensions.Argument<{boundParameter.parameterType}>(arguments, \"{boundParameter.parameterName}\", {boundParameter.bindingSource});"); + if (boundParameter.bindingSource is "BindingSource.Body" or "BindingSource.ModelBinding") + { + builder.AppendLine($" var {arg} = ModelBindingArgumentExtensions.Argument<{boundParameter.parameterType}>(arguments, {boundParameter.bindingSource});"); + } + else + { + builder.AppendLine($" var {arg} = ModelBindingArgumentExtensions.Argument<{boundParameter.parameterType}>(arguments, \"{boundParameter.parameterName}\", {boundParameter.bindingSource});"); + } } builder.AppendLine(); @@ -442,8 +449,6 @@ bool TryAppendBindingFromBody(SemanticModel semanticModel, StringBuilder builder ParameterSyntax parameter, string _, List requiredNamespaces, out (string parameterName, string parameterType, string bindingSource) boundParam) { - var typeSymbol = semanticModel.GetTypeInfo(parameter.Type!).Type; - var isSimpleType = IsSimpleType(typeSymbol!); var paramTypeFullName = GetTypeAndNamespaceName(semanticModel, parameter.Type!); requiredNamespaces.Add(paramTypeFullName.nmespaceName!); @@ -457,9 +462,8 @@ bool TryAppendBindingFromBody(SemanticModel semanticModel, StringBuilder builder // TODO can we somehow support the EmptyBodyBehavior? var (attribute, _) = GetAttributeAndArgument(parameter, attributeNames, "EmptyBodyBehavior"); - var addAttribute = attribute is not null || isSimpleType == false; - if (addAttribute) + if (attribute is not null) { builder.AppendLine($" [BindFromBody<{paramTypeFullName.typeName}>()]"); boundParam = (parameter.Identifier.Text, paramTypeFullName.typeName!, bindingSource); @@ -543,6 +547,28 @@ bool TryAppendBindingFromRoute(SemanticModel semanticModel, StringBuilder builde return false; } + bool TryAppendMultiSourceBinding(SemanticModel semanticModel, StringBuilder builder, + ParameterSyntax parameter, List requiredNamespaces, + out (string parameterName, string parameterType, string bindingSource) boundParam) + { + var typeSymbol = semanticModel.GetTypeInfo(parameter.Type!).Type; + var isSimpleType = IsSimpleType(typeSymbol!); + + if (!isSimpleType) + { + var paramTypeFullName = GetTypeAndNamespaceName(semanticModel, parameter.Type!); + requiredNamespaces.Add(paramTypeFullName.nmespaceName!); + + const string bindingSource = "BindingSource.ModelBinding"; + builder.AppendLine($" [Bind<{paramTypeFullName.typeName}>()]"); + boundParam = (parameter.Identifier.Text, paramTypeFullName.typeName!, bindingSource); + return true; + } + + boundParam = ("", "", ""); + return false; + } + static (AttributeSyntax? attribute, AttributeArgumentSyntax? argument) GetAttributeAndArgument( ParameterSyntax parameter, string[] attributeNamesToMatch, string argumentName) { @@ -600,6 +626,12 @@ bool TryAppendBindingFromRoute(SemanticModel semanticModel, StringBuilder builde boundParameters.Add(fromQueryBoundParam); continue; } + + if (TryAppendMultiSourceBinding(semanticModel, builder, param, requiredNamespaces, + out var multiSourceBoundParam)) + { + boundParameters.Add(multiSourceBoundParam); + } } return boundParameters; diff --git a/src/ServiceComposer.AspNetCore.Tests/CompositionHandlers/When_using_composition_over_controllers_POST_with_2_handlers.cs b/src/ServiceComposer.AspNetCore.Tests/CompositionHandlers/When_using_composition_over_controllers_POST_with_2_handlers.cs new file mode 100644 index 00000000..729789ce --- /dev/null +++ b/src/ServiceComposer.AspNetCore.Tests/CompositionHandlers/When_using_composition_over_controllers_POST_with_2_handlers.cs @@ -0,0 +1,220 @@ +using System.Dynamic; +using System.IO; +using System.Net.Http; +using System.Net.Mime; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using ServiceComposer.AspNetCore.Testing; +using Xunit; + +namespace ServiceComposer.AspNetCore.Tests.CompositionHandlers +{ + namespace Controllers + { + [Route("/api/CompositionOverControllerPostUsingCompositionHandlers")] + public class CompositionOverControllerPostController : ControllerBase + { + [HttpPost("{id}")] + public Task Post(int id) + { + return Task.FromResult((object)null); + } + } + } + + public class When_using_composition_over_controllers_POST_with_2_handlers + { + class CaseInsensitiveRoute_TestIntegerHandler : ICompositionRequestsHandler + { + [HttpPost("/api/compositionovercontrollerpost/{id}")] + public async Task Handle(HttpRequest request) + { + request.Body.Position = 0; + using var reader = new StreamReader(request.Body, Encoding.UTF8, leaveOpen: true); + var body = await reader.ReadToEndAsync(); + var content = JObject.Parse(body); + + var vm = request.GetComposedResponseModel(); + vm.ANumber = content?.SelectToken("ANumber")?.Value(); + } + } + + class TestIntegerHandler : ICompositionRequestsHandler + { + [HttpPost("/api/CompositionOverControllerPostUsingCompositionHandlers/{id}")] + public async Task Handle(HttpRequest request) + { + request.Body.Position = 0; + using var reader = new StreamReader(request.Body, Encoding.UTF8, leaveOpen: true); + var body = await reader.ReadToEndAsync(); + var content = JObject.Parse(body); + + var vm = request.GetComposedResponseModel(); + vm.ANumber = content?.SelectToken("ANumber")?.Value(); + } + } + + class TestStringHandler : ICompositionRequestsHandler + { + [HttpPost("/api/CompositionOverControllerPostUsingCompositionHandlers/{id}")] + public async Task Handle(HttpRequest request) + { + request.Body.Position = 0; + using var reader = new StreamReader(request.Body, Encoding.UTF8, leaveOpen: true ); + var body = await reader.ReadToEndAsync(); + var content = JObject.Parse(body); + + var vm = request.GetComposedResponseModel(); + vm.AString = content?.SelectToken("AString")?.Value(); + } + } + + [Fact] + public async Task Returns_expected_response() + { + var expectedString = "this is a string value"; + var expectedNumber = 32; + + // Arrange + var client = new SelfContainedWebApplicationFactoryWithWebHost + ( + configureServices: services => + { + services.AddViewModelComposition(options => + { + options.AssemblyScanner.Disable(); + options.RegisterCompositionHandler(); + options.RegisterCompositionHandler(); + options.EnableCompositionOverControllers(); + }); + services.AddRouting(); + services.AddControllers() + .AddNewtonsoftJson(); + }, + configure: app => + { + app.UseRouting(); + app.UseEndpoints(builder => + { + builder.MapControllers(); + builder.MapCompositionHandlers(); + }); + } + ).CreateClient(); + + dynamic model = new ExpandoObject(); + model.AString = expectedString; + model.ANumber = expectedNumber; + + var json = (string) JsonConvert.SerializeObject(model); + var stringContent = new StringContent(json, Encoding.UTF8, MediaTypeNames.Application.Json); + stringContent.Headers.ContentLength = json.Length; + + // Act + var response = await client.PostAsync("/api/CompositionOverControllerPostUsingCompositionHandlers/1", stringContent); + + // Assert + Assert.True(response.IsSuccessStatusCode); + + var responseString = await response.Content.ReadAsStringAsync(); + var responseObj = JObject.Parse(responseString); + + Assert.Equal(expectedString, responseObj?.SelectToken("AString")?.Value()); + Assert.Equal(expectedNumber, responseObj?.SelectToken("ANumber")?.Value()); + } + + // [Fact] + // public async Task Returns_expected_response_with_case_insensitive_routes() + // { + // // Arrange + // var client = new SelfContainedWebApplicationFactoryWithWebHost + // ( + // configureServices: services => + // { + // services.AddViewModelComposition(options => + // { + // options.AssemblyScanner.Disable(); + // options.RegisterCompositionHandler(); + // options.RegisterCompositionHandler(); + // options.EnableCompositionOverControllers(useCaseInsensitiveRouteMatching: true); + // }); + // services.AddRouting(); + // services.AddControllers() + // .AddNewtonsoftJson(); + // }, + // configure: app => + // { + // app.UseRouting(); + // app.UseEndpoints(builder => + // { + // builder.MapControllers(); + // builder.MapCompositionHandlers(); + // }); + // } + // ).CreateClient(); + // + // // Act + // var response = await client.GetAsync("/api/compositionovercontroller/1"); + // + // // Assert + // Assert.True(response.IsSuccessStatusCode); + // + // var responseString = await response.Content.ReadAsStringAsync(); + // var responseObj = JObject.Parse(responseString); + // + // Assert.Equal("sample", responseObj?.SelectToken("AString")?.Value()); + // Assert.Equal(1, responseObj?.SelectToken("ANumber")?.Value()); + // } + // + // [Fact] + // public async Task Fails_if_composition_over_controllers_is_disabled() + // { + // // Arrange + // var client = new SelfContainedWebApplicationFactoryWithWebHost + // ( + // configureServices: services => + // { + // services.AddViewModelComposition(options => + // { + // options.AssemblyScanner.Disable(); + // options.RegisterCompositionHandler(); + // options.RegisterCompositionHandler(); + // }); + // services.AddRouting(); + // services.AddControllers() + // .AddNewtonsoftJson(); + // }, + // configure: app => + // { + // app.UseRouting(); + // app.UseEndpoints(builder => + // { + // builder.MapControllers(); + // builder.MapCompositionHandlers(); + // }); + // } + // ).CreateClient(); + // + // Exception capturedException = null; + // try + // { + // // Act + // var response = await client.GetAsync("/api/CompositionOverController/1"); + // } + // catch (Exception e) + // { + // capturedException = e; + // } + // + // // Assert + // Assert.NotNull(capturedException); + // Assert.Equal("Microsoft.AspNetCore.Routing.Matching.AmbiguousMatchException", capturedException.GetType().FullName); + // } + } +} 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 new file mode 100644 index 00000000..41dc61ba --- /dev/null +++ b/src/ServiceComposer.AspNetCore.Tests/CompositionHandlers/When_using_composition_over_controllers_get_with_2_handlers.cs @@ -0,0 +1,204 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Newtonsoft.Json.Linq; +using ServiceComposer.AspNetCore.Testing; +using Xunit; + +namespace ServiceComposer.AspNetCore.Tests.CompositionHandlers +{ + namespace Controllers + { + [Route("/api/CompositionOverControllerUsingCompositionHandlers")] + public class SampleController : ControllerBase + { + [HttpGet("{id}")] + public Task Get(int id) + { + return Task.FromResult((object)null); + } + } + } + + public class When_using_composition_over_controllers_get_with_2_handlers + { + public class Model + { + [FromRoute]public int id { get; set; } + } + public class CaseInsensitiveRouteTestGetIntegerCompositionHandler(IHttpContextAccessor httpContextAccessor) + { + [HttpGet("/api/compositionovercontrollerusingcompositionhandlers/{id}")] + public Task Handle(Model model) + { + var vm = httpContextAccessor.HttpContext.Request.GetComposedResponseModel(); + vm.ANumber = model.id; + + return Task.CompletedTask; + } + } + + public class CaseSensitiveRouteTestGetIntegerCompositionHandler(IHttpContextAccessor httpContextAccessor) + { + [HttpGet("/api/CompositionOverControllerUsingCompositionHandlers/{id}")] + public Task Handle(Model model) + { + var vm = httpContextAccessor.HttpContext.Request.GetComposedResponseModel(); + vm.ANumber = model.id; + + return Task.CompletedTask; + } + } + + public class TestGetStringCompositionHandler(IHttpContextAccessor httpContextAccessor) + { + [HttpGet("/api/CompositionOverControllerUsingCompositionHandlers/{id}")] + public Task Handle() + { + var vm = httpContextAccessor.HttpContext.Request.GetComposedResponseModel(); + vm.AString = "sample"; + return Task.CompletedTask; + } + } + + [Fact] + public async Task Returns_expected_response() + { + // Arrange + var client = new SelfContainedWebApplicationFactoryWithWebHost + ( + configureServices: services => + { + services.AddViewModelComposition(options => + { + options.AssemblyScanner.Disable(); + options.RegisterCompositionHandler(); + options.RegisterCompositionHandler(); + options.RegisterCompositionHandler(); + options.RegisterCompositionHandler(); + options.EnableCompositionOverControllers(); + }); + services.AddHttpContextAccessor(); + services.AddRouting(); + services.AddControllers() + .AddNewtonsoftJson(); + }, + configure: app => + { + app.UseRouting(); + app.UseEndpoints(builder => + { + builder.MapControllers(); + builder.MapCompositionHandlers(); + }); + } + ).CreateClient(); + + // Act + var response = await client.GetAsync("/api/compositionovercontrollerusingcompositionhandlers/1"); + + // Assert + Assert.True(response.IsSuccessStatusCode); + + var responseString = await response.Content.ReadAsStringAsync(); + var responseObj = JObject.Parse(responseString); + + Assert.Equal("sample", responseObj?.SelectToken("AString")?.Value()); + Assert.Equal(1, responseObj?.SelectToken("ANumber")?.Value()); + } + + [Fact] + public async Task Returns_expected_response_with_case_insensitive_routes() + { + // Arrange + var client = new SelfContainedWebApplicationFactoryWithWebHost + ( + configureServices: services => + { + services.AddViewModelComposition(options => + { + options.AssemblyScanner.Disable(); + options.RegisterCompositionHandler(); + options.RegisterCompositionHandler(); + options.RegisterCompositionHandler(); + options.RegisterCompositionHandler(); + options.EnableCompositionOverControllers(useCaseInsensitiveRouteMatching: true); + }); + services.AddHttpContextAccessor(); + services.AddRouting(); + services.AddControllers() + .AddNewtonsoftJson(); + }, + configure: app => + { + app.UseRouting(); + app.UseEndpoints(builder => + { + builder.MapControllers(); + builder.MapCompositionHandlers(); + }); + } + ).CreateClient(); + + // Act + var response = await client.GetAsync("/api/compositionovercontrollerUsingCompositionHandlers/1"); + + // Assert + Assert.True(response.IsSuccessStatusCode); + + var responseString = await response.Content.ReadAsStringAsync(); + var responseObj = JObject.Parse(responseString); + + Assert.Equal("sample", responseObj?.SelectToken("AString")?.Value()); + Assert.Equal(1, responseObj?.SelectToken("ANumber")?.Value()); + } + + [Fact] + public async Task Fails_if_composition_over_controllers_is_disabled() + { + // Arrange + var client = new SelfContainedWebApplicationFactoryWithWebHost + ( + configureServices: services => + { + services.AddViewModelComposition(options => + { + options.AssemblyScanner.Disable(); + options.RegisterCompositionHandler(); + options.RegisterCompositionHandler(); + }); + services.AddRouting(); + services.AddControllers() + .AddNewtonsoftJson(); + }, + configure: app => + { + app.UseRouting(); + app.UseEndpoints(builder => + { + builder.MapControllers(); + builder.MapCompositionHandlers(); + }); + } + ).CreateClient(); + + Exception capturedException = null; + try + { + // Act + _ = await client.GetAsync("/api/CompositionOverControllerUsingCompositionHandlers/1"); + } + catch (Exception e) + { + capturedException = e; + } + + // Assert + Assert.NotNull(capturedException); + Assert.Equal("Microsoft.AspNetCore.Routing.Matching.AmbiguousMatchException", capturedException.GetType().FullName); + } + } +} diff --git a/src/ServiceComposer.AspNetCore.Tests/ServiceComposer.AspNetCore.Tests.csproj b/src/ServiceComposer.AspNetCore.Tests/ServiceComposer.AspNetCore.Tests.csproj index 183c1ccf..d0ae0cbe 100644 --- a/src/ServiceComposer.AspNetCore.Tests/ServiceComposer.AspNetCore.Tests.csproj +++ b/src/ServiceComposer.AspNetCore.Tests/ServiceComposer.AspNetCore.Tests.csproj @@ -2,7 +2,7 @@ false - net8.0;net9.0 + net8.0;net10.0 @@ -29,7 +29,7 @@ - + diff --git a/src/ServiceComposer.AspNetCore/ComponentsModelBinder.cs b/src/ServiceComposer.AspNetCore/ComponentsModelBinder.cs new file mode 100644 index 00000000..0a446aa8 --- /dev/null +++ b/src/ServiceComposer.AspNetCore/ComponentsModelBinder.cs @@ -0,0 +1,47 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace ServiceComposer.AspNetCore; + +static class ComponentsModelBinder +{ + internal static async Task>> BindAll( + IEnumerable<(Type ComponentType, IList Metadata)> componentsMetadata, + HttpContext context) + { + var result = new Dictionary>(); + foreach (var componentMetadata in componentsMetadata) + { + var modelAttributes = componentMetadata.Metadata.OfType(); + + // A component can have more than one Http* attribute + // If that's the case we don't want to have more than one + // arguments lists. Instead, we're reusing an existing one. + if (!result.TryGetValue(componentMetadata.ComponentType, out var arguments)) + { + arguments = new List(); + result.Add(componentMetadata.ComponentType, arguments); + } + + foreach (var modelAttribute in modelAttributes) + { + // TODO: shall we cache the instance? We cannot access it earlier otherwise we need model binding support for every request even if it's not needed by user code + var binder = context.RequestServices.GetRequiredService(); + var bindingResult = await binder.TryBind( + modelAttribute.Type, + context.Request, + modelAttribute.ModelName ?? "", + modelAttribute.BindingSource); + //TODO: throw if binding failed + arguments.Add(new ModelBindingArgument(modelAttribute.ModelName!, bindingResult.Model, modelAttribute.BindingSource)); + } + } + + return result; + } +} diff --git a/src/ServiceComposer.AspNetCore/CompositionContext.cs b/src/ServiceComposer.AspNetCore/CompositionContext.cs index 1b222490..351baaad 100644 --- a/src/ServiceComposer.AspNetCore/CompositionContext.cs +++ b/src/ServiceComposer.AspNetCore/CompositionContext.cs @@ -13,8 +13,7 @@ class CompositionContext( string requestId, HttpRequest httpRequest, CompositionMetadataRegistry metadataRegistry, - IDictionary> componentsArguments, - bool usingCompositionOverControllers = false) + IDictionary> componentsArguments) : ICompositionContext, ICompositionEventsPublisher { readonly ConcurrentDictionary>> _compositionEventsSubscriptions = new(); @@ -55,11 +54,6 @@ Task EventHandler(TEvent evt, HttpRequest req) IList? GetArguments(Type owningComponentType) { - if (usingCompositionOverControllers) - { - throw new NotSupportedException("Model binding arguments are unsupported when using composition over controllers."); - } - componentsArguments.TryGetValue(owningComponentType, out var arguments); return arguments; } diff --git a/src/ServiceComposer.AspNetCore/CompositionEndpointBuilder.BindingArguments.cs b/src/ServiceComposer.AspNetCore/CompositionEndpointBuilder.BindingArguments.cs index 256e2aed..a2cd879f 100644 --- a/src/ServiceComposer.AspNetCore/CompositionEndpointBuilder.BindingArguments.cs +++ b/src/ServiceComposer.AspNetCore/CompositionEndpointBuilder.BindingArguments.cs @@ -1,45 +1,15 @@ #nullable enable using System; using System.Collections.Generic; -using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; namespace ServiceComposer.AspNetCore; partial class CompositionEndpointBuilder { - async Task>> GetAllComponentsArguments(HttpContext context) + Task>> GetAllComponentsArguments(HttpContext context) { - var result = new Dictionary>(); - foreach (var componentMetadata in ComponentsMetadata) - { - var modelAttributes = componentMetadata.Metadata.OfType(); - - // A component can have more than one Http* attribute - // If that's the case we don't want to have more than one - // arguments lists. Instead, we're reusing an existing one. - if (!result.TryGetValue(componentMetadata.ComponentType, out var arguments)) - { - arguments = new List(); - result.Add(componentMetadata.ComponentType, arguments); - } - - foreach (var modelAttribute in modelAttributes) - { - // TODO: shall we cache the instance? We cannot access it earlier otherwise we need model binding support for every request even if it's not needed by user code - var binder = context.RequestServices.GetRequiredService(); - var bindingResult = await binder.TryBind( - modelAttribute.Type, - context.Request, - modelAttribute.ModelName ?? "", - modelAttribute.BindingSource); - //TODO: throw if binding failed - arguments.Add(new ModelBindingArgument(modelAttribute.ModelName!, bindingResult.Model, modelAttribute.BindingSource)); - } - } - - return result; + return ComponentsModelBinder.BindAll(ComponentsMetadata, context); } -} \ No newline at end of file +} diff --git a/src/ServiceComposer.AspNetCore/CompositionOverControllersActionFilter.cs b/src/ServiceComposer.AspNetCore/CompositionOverControllersActionFilter.cs index 502168cf..cf84b964 100644 --- a/src/ServiceComposer.AspNetCore/CompositionOverControllersActionFilter.cs +++ b/src/ServiceComposer.AspNetCore/CompositionOverControllersActionFilter.cs @@ -1,5 +1,3 @@ -using System; -using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Threading.Tasks; @@ -30,26 +28,27 @@ public async Task OnResultExecutionAsync(ResultExecutingContext context, ResultE var rawTemplate = _compositionOverControllersOptions.UseCaseInsensitiveRouteMatching ? endpoint.RoutePattern.RawText.ToLowerInvariant() : endpoint.RoutePattern.RawText; - var handlerTypes = _compositionOverControllersRoutes.HandlersForRoute(rawTemplate, context.HttpContext.Request.Method); + var handlers = _compositionOverControllersRoutes.HandlersForRoute(rawTemplate, context.HttpContext.Request.Method); - if (handlerTypes.Any()) + if (handlers.Any()) { // We need the body to be seekable otherwise if more than one // composition handler tries to bind a model to the body // it'll fail and only the first one succeeds context.HttpContext.Request.EnableBuffering(); - + + var argumentsByComponent = await ComponentsModelBinder.BindAll(handlers, context.HttpContext); + var requestId = context.HttpContext.EnsureRequestIdIsSetup(); var compositionContext = new CompositionContext ( requestId, context.HttpContext.Request, context.HttpContext.RequestServices.GetRequiredService(), - //arguments binding is unsupported when using composition over controllers - new Dictionary>(), - usingCompositionOverControllers: true + argumentsByComponent ); + var handlerTypes = handlers.Select(h => h.ComponentType).ToArray(); var viewModel = await CompositionHandler.HandleComposableRequest(context.HttpContext, compositionContext, handlerTypes); switch (context.Result) { diff --git a/src/ServiceComposer.AspNetCore/CompositionOverControllersRoutes.cs b/src/ServiceComposer.AspNetCore/CompositionOverControllersRoutes.cs index 5e4d7f7a..6695f653 100644 --- a/src/ServiceComposer.AspNetCore/CompositionOverControllersRoutes.cs +++ b/src/ServiceComposer.AspNetCore/CompositionOverControllersRoutes.cs @@ -1,24 +1,19 @@ -using System; +using System; using System.Collections.Generic; namespace ServiceComposer.AspNetCore { class CompositionOverControllersRoutes { - readonly Type[] empty = Type.EmptyTypes; - readonly Dictionary> routes = new(); + static readonly (Type ComponentType, IList Metadata)[] empty = []; + readonly Dictionary Metadata)[]>> routes = new(); - public void AddGetComponentsSource(Dictionary compositionOverControllerGetComponents) + public void AddComponentsSource(string httpMethod, Dictionary Metadata)[]> components) { - routes["get"] = compositionOverControllerGetComponents ?? throw new ArgumentNullException(nameof(compositionOverControllerGetComponents)); + routes[httpMethod.ToLowerInvariant()] = components ?? throw new ArgumentNullException(nameof(components)); } - public void AddPostComponentsSource(Dictionary compositionOverControllerPostComponents) - { - routes["post"] = compositionOverControllerPostComponents ?? throw new ArgumentNullException(nameof(compositionOverControllerPostComponents)); - } - - public Type[] HandlersForRoute(string routePatternRawText, string requestMethod) + public (Type ComponentType, IList Metadata)[] HandlersForRoute(string routePatternRawText, string requestMethod) { var results = empty; requestMethod = requestMethod.ToLowerInvariant(); @@ -34,4 +29,4 @@ public Type[] HandlersForRoute(string routePatternRawText, string requestMethod) return results; } } -} \ No newline at end of file +} diff --git a/src/ServiceComposer.AspNetCore/EndpointsExtensions.cs b/src/ServiceComposer.AspNetCore/EndpointsExtensions.cs index 3a1d2ae8..b61376ff 100644 --- a/src/ServiceComposer.AspNetCore/EndpointsExtensions.cs +++ b/src/ServiceComposer.AspNetCore/EndpointsExtensions.cs @@ -15,8 +15,8 @@ namespace ServiceComposer.AspNetCore { public static partial class EndpointsExtensions { - static readonly Dictionary compositionOverControllerGetComponents = new(); - static readonly Dictionary compositionOverControllerPostComponents = new(); + static readonly Dictionary Metadata)[]> compositionOverControllerGetComponents = new(); + static readonly Dictionary Metadata)[]> compositionOverControllerPostComponents = new(); public static IEndpointConventionBuilder MapCompositionHandlers(this IEndpointRouteBuilder endpoints) { @@ -33,8 +33,8 @@ public static IEndpointConventionBuilder MapCompositionHandlers(this IEndpointRo { var compositionOverControllersRoutes = endpoints.ServiceProvider.GetRequiredService(); - compositionOverControllersRoutes.AddGetComponentsSource(compositionOverControllerGetComponents); - compositionOverControllersRoutes.AddPostComponentsSource(compositionOverControllerPostComponents); + compositionOverControllersRoutes.AddComponentsSource("get", compositionOverControllerGetComponents); + compositionOverControllersRoutes.AddComponentsSource("post", compositionOverControllerPostComponents); } var compositionMetadataRegistry = @@ -92,8 +92,7 @@ static void MapGetComponents(CompositionMetadataRegistry compositionMetadataRegi componentsGroup, dataSources, compositionOverControllersOptions.UseCaseInsensitiveRouteMatching, out _)) { - var componentTypes = componentsGroup.Select(c => c.ComponentType).ToArray(); - compositionOverControllerGetComponents[componentsGroup.Key] = componentTypes; + compositionOverControllerGetComponents[componentsGroup.Key] = ExtractComponentsWithMetadata(componentsGroup); } else { @@ -118,8 +117,7 @@ static void MapPostComponents(CompositionMetadataRegistry compositionMetadataReg componentsGroup, dataSources, compositionOverControllersOptions.UseCaseInsensitiveRouteMatching, out _)) { - var componentTypes = componentsGroup.Select(c => c.ComponentType).ToArray(); - compositionOverControllerPostComponents[componentsGroup.Key] = componentTypes; + compositionOverControllerPostComponents[componentsGroup.Key] = ExtractComponentsWithMetadata(componentsGroup); } else { @@ -222,6 +220,14 @@ static bool ThereIsAlreadyAnEndpointForTheSameTemplate( return false; } + static (Type ComponentType, IList Metadata)[] ExtractComponentsWithMetadata( + IGrouping componentsGroup) + { + return componentsGroup + .Select(c => (c.ComponentType, (IList)c.Method.GetCustomAttributes(inherit: true))) + .ToArray(); + } + static CompositionEndpointBuilder CreateCompositionEndpointBuilder( IGrouping componentsGroup, HttpMethodMetadata methodMetadata, diff --git a/src/ServiceComposer.AspNetCore/ViewModelCompositionOptions.cs b/src/ServiceComposer.AspNetCore/ViewModelCompositionOptions.cs index 734becbf..add57c2f 100644 --- a/src/ServiceComposer.AspNetCore/ViewModelCompositionOptions.cs +++ b/src/ServiceComposer.AspNetCore/ViewModelCompositionOptions.cs @@ -296,7 +296,7 @@ void RegisterCompositionComponents(Type type) if (!isContractlessCompositionHandler) { // We don't want (yet?) to register contract-less composition handlers - // in the metadata registry because they they are not a first-class citizen + // in the metadata registry because they are not a first-class citizen // in the ASP.Net endpoint _compositionMetadataRegistry.AddComponent(type); }