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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 17 additions & 7 deletions context/context.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand All @@ -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<Type, IList<ModelBindingArgument>>
- Shared by both composition endpoints and composition over controllers
|
v
Endpoint Filter Pipeline (ASP.NET Core IEndpointFilter chain, cached after first build)
Expand Down Expand Up @@ -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)
Expand All @@ -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

Expand Down Expand Up @@ -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<T>`
2. Parameter has explicit `[FromBody]` → `BindFromBody<T>`
3. Parameter has explicit `[FromForm]` → `BindFromForm<T>`
4. Parameter has explicit `[FromQuery]` or is a simple type → `BindFromQuery<T>`
5. Complex type without explicit binding attribute → `Bind<T>` (multi-source, respects property-level attributes like `[FromRoute]`)

### Binding Attributes
| Attribute | Binding Source |
|---|---|
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
2 changes: 2 additions & 0 deletions docs/composition-over-controllers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
3 changes: 1 addition & 2 deletions docs/contract-less-composition-requests-handlers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -70,7 +69,7 @@ namespace Snippets.Contractless.CompositionHandlers.Generated
var arguments = ctx.GetArguments(this);
var p0_id = ModelBindingArgumentExtensions.Argument<Int32>(arguments, "id", BindingSource.Path);
var p1_c = ModelBindingArgumentExtensions.Argument<String>(arguments, "c", BindingSource.Query);
var p2_ct = ModelBindingArgumentExtensions.Argument<ComplexType>(arguments, "ct", BindingSource.Body);
var p2_ct = ModelBindingArgumentExtensions.Argument<ComplexType>(arguments, BindingSource.Body);

return userHandler.SampleMethod(p0_id, p1_c, p2_ct);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ namespace ServiceComposer.AspNetCore.SourceGeneration.Tests.TestFiles.Compositio
var arguments = ctx.GetArguments(this);
var p0_id = ModelBindingArgumentExtensions.Argument<Int32>(arguments, "id", BindingSource.Path);
var p1_x = ModelBindingArgumentExtensions.Argument<Int32>(arguments, "x", BindingSource.Query);
var p2_body = ModelBindingArgumentExtensions.Argument<AHandlerWithANestedClassCompositionHandler.BodyClass>(arguments, "body", BindingSource.Body);
var p2_body = ModelBindingArgumentExtensions.Argument<AHandlerWithANestedClassCompositionHandler.BodyClass>(arguments, BindingSource.Body);

return userHandler.Post(p0_id, p1_x, p2_body);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ namespace ServiceComposer.AspNetCore.SourceGeneration.Tests.TestFiles.Compositio
var arguments = ctx.GetArguments(this);
var p0_id = ModelBindingArgumentExtensions.Argument<Int32>(arguments, "id", BindingSource.Path);
var p1_x = ModelBindingArgumentExtensions.Argument<Int32>(arguments, "x", BindingSource.Query);
var p2_body = ModelBindingArgumentExtensions.Argument<AHandlerWithANestedClassCompositionHandler.BodyClass>(arguments, "body", BindingSource.Body);
var p2_body = ModelBindingArgumentExtensions.Argument<AHandlerWithANestedClassCompositionHandler.BodyClass>(arguments, BindingSource.Body);

return userHandler.Post(p0_id, p1_x, p2_body);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ namespace ServiceComposer.AspNetCore.SourceGeneration.Tests.TestFiles.Compositio
var p0_v = ModelBindingArgumentExtensions.Argument<Int32>(arguments, "v", BindingSource.Path);
var p1_c = ModelBindingArgumentExtensions.Argument<Int32>(arguments, "c", BindingSource.Query);
var p2_v = ModelBindingArgumentExtensions.Argument<String>(arguments, "v", BindingSource.Query);
var p3_body = ModelBindingArgumentExtensions.Argument<WithQueryBindingWithoutAttributeBodyClass>(arguments, "body", BindingSource.Body);
var p3_body = ModelBindingArgumentExtensions.Argument<WithQueryBindingWithoutAttributeBodyClass>(arguments, BindingSource.Body);

return userHandler.Post(p0_v, p1_c, p2_v, p3_body);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ namespace ServiceComposer.AspNetCore.SourceGeneration.Tests.TestFiles.Compositio
var p0_v = ModelBindingArgumentExtensions.Argument<Int32>(arguments, "v", BindingSource.Path);
var p1_c = ModelBindingArgumentExtensions.Argument<Int32>(arguments, "c", BindingSource.Query);
var p2_v = ModelBindingArgumentExtensions.Argument<String>(arguments, "v", BindingSource.Query);
var p3_body = ModelBindingArgumentExtensions.Argument<WithQueryBindingWithoutAttributeBodyClass>(arguments, "body", BindingSource.Body);
var p3_body = ModelBindingArgumentExtensions.Argument<WithQueryBindingWithoutAttributeBodyClass>(arguments, BindingSource.Body);

return userHandler.Post(p0_v, p1_c, p2_v, p3_body);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -342,7 +342,14 @@ SeparatedSyntaxList<ParameterSyntax> 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();
Expand Down Expand Up @@ -442,8 +449,6 @@ bool TryAppendBindingFromBody(SemanticModel semanticModel, StringBuilder builder
ParameterSyntax parameter, string _, List<string> 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!);

Expand All @@ -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);
Expand Down Expand Up @@ -543,6 +547,28 @@ bool TryAppendBindingFromRoute(SemanticModel semanticModel, StringBuilder builde
return false;
}

bool TryAppendMultiSourceBinding(SemanticModel semanticModel, StringBuilder builder,
ParameterSyntax parameter, List<string> 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)
{
Expand Down Expand Up @@ -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;
Expand Down
Loading