-
Notifications
You must be signed in to change notification settings - Fork 4
feat(hosting): the endpoint-contribution hook — modules map HTTP routes (#1655) #1663
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+295
−2
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
36 changes: 36 additions & 0 deletions
36
src/MeshWeaver.Hosting.AspNetCore/MeshEndpointProviderAttribute.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| using Microsoft.AspNetCore.Routing; | ||
|
|
||
| namespace MeshWeaver.Hosting.AspNetCore; | ||
|
|
||
| /// <summary> | ||
| /// The endpoint-contribution hook of the module lane (design #1655): a MODULE assembly carries one | ||
| /// attribute deriving from this, and the host applies its <see cref="EndpointConfigurations"/> at | ||
| /// endpoint-mapping time (<c>app.MapMeshModuleEndpoints()</c>). Delisting the module from | ||
| /// <c>Modules:Assemblies</c> removes its routes wholesale — a 404 instead of a compiled | ||
| /// optional-service 503. | ||
| /// | ||
| /// <para>This is deliberately a SEPARATE attribute from <c>MeshNodeProviderAttribute</c>: | ||
| /// endpoint contributions are HOST-level (they need <see cref="IEndpointRouteBuilder"/>, an | ||
| /// ASP.NET surface the mesh contract must not reference), and they are applied at a different | ||
| /// time — endpoint mapping, after the auth middleware — than the mesh build. A module that | ||
| /// contributes both mesh registrations and endpoints carries both attributes.</para> | ||
| /// | ||
| /// <para><b>Security model</b>: a module is TRUSTED CODE the deployment chose to list — unlike | ||
| /// <c>UiContribution</c> mesh DATA, whose closed vocabulary exists because data must never widen | ||
| /// anything. Two guardrails still apply, in the HOST: every contribution maps inside a group that | ||
| /// defaults to <c>RequireAuthorization()</c> — a route is only anonymous where the module | ||
| /// explicitly says <c>AllowAnonymous()</c> — and route collisions fail the app LOUDLY at startup | ||
| /// (never last-write-wins; a silent skip is the #683 trapdoor class).</para> | ||
| /// </summary> | ||
| [AttributeUsage(AttributeTargets.Assembly)] | ||
| public abstract class MeshEndpointProviderAttribute : Attribute | ||
| { | ||
| /// <summary> | ||
| /// The module's endpoint registrations. Each action receives a route-group builder that | ||
| /// already carries the authenticated-by-default policy; register routes exactly as in a host | ||
| /// (<c>MapGet</c>/<c>MapPost</c>/<c>MapGrpcService</c>/…), opting out per route via | ||
| /// <c>AllowAnonymous()</c> where a route is genuinely public (webhook inboxes, link | ||
| /// previews). | ||
| /// </summary> | ||
| public abstract IEnumerable<Action<IEndpointRouteBuilder>> EndpointConfigurations { get; } | ||
| } |
105 changes: 105 additions & 0 deletions
105
src/MeshWeaver.Hosting.AspNetCore/MeshModuleEndpointExtensions.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,105 @@ | ||
| using System.Reflection; | ||
| using MeshWeaver.Mesh; | ||
| using Microsoft.AspNetCore.Builder; | ||
| using Microsoft.AspNetCore.Http; | ||
| using Microsoft.AspNetCore.Routing; | ||
| using Microsoft.Extensions.DependencyInjection; | ||
| using Microsoft.Extensions.Logging; | ||
|
|
||
| namespace MeshWeaver.Hosting.AspNetCore; | ||
|
|
||
| /// <summary> | ||
| /// Applies every installed module's <see cref="MeshEndpointProviderAttribute"/> contributions — | ||
| /// the host half of the endpoint-contribution hook (design #1655). | ||
| /// </summary> | ||
| public static class MeshModuleEndpointExtensions | ||
| { | ||
| /// <summary> | ||
| /// Maps the endpoint contributions of every installed module (<c>Modules:Assemblies</c> → | ||
| /// <see cref="InstalledModuleAssembly"/>). Call it with the host's other <c>Map*</c> calls — | ||
| /// after authentication/authorization middleware, before any catch-all fallback. | ||
| /// | ||
| /// <para>Each module's routes map inside a group defaulting to | ||
| /// <c>RequireAuthorization()</c>; a route is anonymous only where the module explicitly opts | ||
| /// out. On <c>ApplicationStarted</c> the whole endpoint table is checked for duplicate | ||
| /// (verb, pattern) registrations — a collision throws and takes the app down, because a | ||
| /// silently shadowed route is indistinguishable from a passing one (the #683 class).</para> | ||
| /// </summary> | ||
| public static WebApplication MapMeshModuleEndpoints(this WebApplication app) | ||
| { | ||
| var logger = app.Services.GetRequiredService<ILoggerFactory>() | ||
| .CreateLogger(typeof(MeshModuleEndpointExtensions)); | ||
|
|
||
| var contributed = 0; | ||
| foreach (var module in app.Services.GetServices<InstalledModuleAssembly>()) | ||
| foreach (var attribute in module.Assembly.GetCustomAttributes<MeshEndpointProviderAttribute>()) | ||
| { | ||
| // Authenticated-by-default: the group policy applies to every route the module maps | ||
| // unless the route itself declares AllowAnonymous — a module cannot accidentally | ||
| // publish an open route. | ||
| var group = app.MapGroup(string.Empty).RequireAuthorization(); | ||
| foreach (var configure in attribute.EndpointConfigurations) | ||
| { | ||
| configure(group); | ||
| contributed++; | ||
| } | ||
| logger.LogInformation( | ||
| "Mapped endpoint contributions from module {Module} ({Attribute})", | ||
| module.Assembly.GetName().Name, attribute.GetType().Name); | ||
| } | ||
|
|
||
| if (contributed > 0) | ||
| RegisterCollisionCheck(app, logger); | ||
| return app; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// LOUD duplicate-route detection, run once at <c>ApplicationStarted</c> when the endpoint | ||
| /// table is fully materialized: two endpoints on the same (verb, pattern) — module vs module | ||
| /// or module vs platform — throw with both display names. ASP.NET alone only surfaces the | ||
| /// ambiguity at REQUEST time, which is exactly the silent-until-hit failure the hook must not | ||
| /// introduce. | ||
| /// </summary> | ||
| private static void RegisterCollisionCheck(WebApplication app, ILogger logger) | ||
| { | ||
| app.Lifetime.ApplicationStarted.Register(() => | ||
| { | ||
| var detail = FindRouteCollisions( | ||
| app.Services.GetRequiredService<EndpointDataSource>().Endpoints); | ||
| if (detail is null) | ||
| return; | ||
| logger.LogCritical( | ||
| "Endpoint route collision(s) after module contributions — refusing to serve: {Detail}", detail); | ||
| // Belt and braces: StopApplication() guarantees shutdown even where the runtime | ||
| // swallows a lifetime-callback exception; the throw makes the refusal visible in | ||
| // crash telemetry where it does propagate. Together: logged, stopped, loud. | ||
| app.Lifetime.StopApplication(); | ||
| throw new InvalidOperationException( | ||
| $"Endpoint route collision(s) after module endpoint contributions: {detail}. " | ||
| + "Two registrations on one (verb, pattern) mean one of them silently shadows the " | ||
| + "other — remove or re-route the duplicate; never rely on registration order."); | ||
| }); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// The pure collision predicate: duplicate (verb, pattern) pairs across the endpoint table, | ||
| /// or null when clean. Extracted so the refusal logic is unit-testable without a running host. | ||
| /// </summary> | ||
| internal static string? FindRouteCollisions(IEnumerable<Endpoint> endpoints) | ||
| { | ||
| var duplicates = endpoints | ||
| .OfType<RouteEndpoint>() | ||
| .SelectMany(endpoint => | ||
| (endpoint.Metadata.GetMetadata<HttpMethodMetadata>()?.HttpMethods | ||
| ?? (IReadOnlyList<string>)["*"]) | ||
| .Select(verb => (Verb: verb, Pattern: endpoint.RoutePattern.RawText ?? "", Endpoint: endpoint))) | ||
| .Where(e => e.Pattern.Length > 0) | ||
| .GroupBy(e => (e.Verb, e.Pattern)) | ||
| .Where(g => g.Count() > 1) | ||
| .ToList(); | ||
| return duplicates.Count == 0 | ||
| ? null | ||
| : string.Join("; ", duplicates.Select(g => | ||
| $"{g.Key.Verb} {g.Key.Pattern} ← [{string.Join(" | ", g.Select(e => e.Endpoint.DisplayName))}]")); | ||
| } | ||
| } |
19 changes: 19 additions & 0 deletions
19
src/MeshWeaver.Hosting.AspNetCore/MeshWeaver.Hosting.AspNetCore.csproj
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| <Project Sdk="Microsoft.NET.Sdk"> | ||
|
|
||
| <PropertyGroup> | ||
| <Description>ASP.NET Core host integration for MeshWeaver modules: the endpoint-contribution hook — a module assembly declares HTTP endpoints the host maps at startup (design #1655).</Description> | ||
| </PropertyGroup> | ||
|
|
||
| <ItemGroup> | ||
| <FrameworkReference Include="Microsoft.AspNetCore.App" /> | ||
| </ItemGroup> | ||
|
|
||
| <ItemGroup> | ||
| <InternalsVisibleTo Include="MeshWeaver.Hosting.Monolith.Test" /> | ||
| </ItemGroup> | ||
|
|
||
| <ItemGroup> | ||
| <ProjectReference Include="..\MeshWeaver.Mesh.Contract\MeshWeaver.Mesh.Contract.csproj" /> | ||
| </ItemGroup> | ||
|
|
||
| </Project> |
115 changes: 115 additions & 0 deletions
115
test/MeshWeaver.Hosting.Monolith.Test/ModuleEndpointContributionTest.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,115 @@ | ||
| #pragma warning disable CS1591 | ||
|
|
||
| using System; | ||
| using System.Collections.Generic; | ||
| using System.Linq; | ||
| using MeshWeaver.Hosting.AspNetCore; | ||
| using MeshWeaver.Hosting.Monolith.Test; | ||
| using MeshWeaver.Mesh; | ||
| using Microsoft.AspNetCore.Authorization; | ||
| using Microsoft.AspNetCore.Builder; | ||
| using Microsoft.AspNetCore.Http; | ||
| using Microsoft.AspNetCore.Routing; | ||
| using Microsoft.AspNetCore.Routing.Patterns; | ||
| using Microsoft.Extensions.DependencyInjection; | ||
| using Xunit; | ||
|
|
||
| [assembly: TestModuleEndpoints] | ||
|
|
||
| namespace MeshWeaver.Hosting.Monolith.Test; | ||
|
|
||
| /// <summary> | ||
| /// A test module-endpoint contribution living on THIS assembly — the discovery walks | ||
| /// <see cref="MeshEndpointProviderAttribute"/>s on installed module assemblies, so the test | ||
| /// assembly plays the module. | ||
| /// </summary> | ||
| [AttributeUsage(AttributeTargets.Assembly)] | ||
| public sealed class TestModuleEndpointsAttribute : MeshEndpointProviderAttribute | ||
| { | ||
| public const string SecuredRoute = "/api/test-module/secured"; | ||
| public const string PublicRoute = "/api/test-module/public"; | ||
|
|
||
| public override IEnumerable<Action<IEndpointRouteBuilder>> EndpointConfigurations => | ||
| [ | ||
| endpoints => | ||
| { | ||
| endpoints.MapGet(SecuredRoute, () => Results.Ok("secured")); | ||
| endpoints.MapGet(PublicRoute, () => Results.Ok("public")).AllowAnonymous(); | ||
| }, | ||
| ]; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Pins the endpoint-contribution hook (design #1655): discovery over | ||
| /// <see cref="InstalledModuleAssembly"/>, the authenticated-by-default group with per-route | ||
| /// anonymous opt-out, and the loud (verb, pattern) collision refusal. | ||
| /// </summary> | ||
| public class ModuleEndpointContributionTest | ||
| { | ||
| private static WebApplication BuildAppWithTestModule() | ||
| { | ||
| // The app is BUILT but never started — endpoint metadata is inspectable without Kestrel. | ||
| var builder = WebApplication.CreateBuilder(); | ||
| builder.Services.AddAuthorization(); | ||
| builder.Services.AddSingleton( | ||
| new InstalledModuleAssembly(typeof(ModuleEndpointContributionTest).Assembly)); | ||
| var app = builder.Build(); | ||
| app.MapMeshModuleEndpoints(); | ||
| return app; | ||
| } | ||
|
|
||
| private static IReadOnlyList<RouteEndpoint> ContributedEndpoints(WebApplication app) => | ||
| ((IEndpointRouteBuilder)app).DataSources | ||
| .SelectMany(source => source.Endpoints) | ||
| .OfType<RouteEndpoint>() | ||
| .Where(e => e.RoutePattern.RawText?.Contains("test-module") == true) | ||
| .ToList(); | ||
|
|
||
| [Fact] | ||
| public void ModuleEndpoints_AreDiscovered_AndAuthenticatedByDefault() | ||
| { | ||
| using var app = BuildAppWithTestModule(); | ||
| var endpoints = ContributedEndpoints(app); | ||
| Assert.Equal(2, endpoints.Count); | ||
|
|
||
| var secured = Assert.Single(endpoints, | ||
| e => e.RoutePattern.RawText == TestModuleEndpointsAttribute.SecuredRoute); | ||
| // The group default: authorization metadata present, no anonymous escape. | ||
| Assert.NotNull(secured.Metadata.GetMetadata<IAuthorizeData>()); | ||
| Assert.Null(secured.Metadata.GetMetadata<IAllowAnonymous>()); | ||
|
|
||
| // The explicit per-route opt-out is the ONLY way a contributed route is anonymous. | ||
| var open = Assert.Single(endpoints, | ||
| e => e.RoutePattern.RawText == TestModuleEndpointsAttribute.PublicRoute); | ||
| Assert.NotNull(open.Metadata.GetMetadata<IAllowAnonymous>()); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void RouteCollisions_AreDetected_WithBothPartiesNamed() | ||
| { | ||
| static RouteEndpoint Endpoint(string pattern, string verb, string display) => | ||
| new( | ||
| _ => System.Threading.Tasks.Task.CompletedTask, | ||
| RoutePatternFactory.Parse(pattern), | ||
| order: 0, | ||
| new EndpointMetadataCollection(new HttpMethodMetadata([verb])), | ||
| display); | ||
|
|
||
| // Same pattern, same verb — collision, both display names surfaced. | ||
| var detail = MeshModuleEndpointExtensions.FindRouteCollisions( | ||
| [ | ||
| Endpoint("/api/x", "GET", "platform: X"), | ||
| Endpoint("/api/x", "GET", "module: X"), | ||
| ]); | ||
| Assert.NotNull(detail); | ||
| Assert.Contains("platform: X", detail); | ||
| Assert.Contains("module: X", detail); | ||
|
|
||
| // Same pattern, DIFFERENT verb — legitimate, no collision. | ||
| Assert.Null(MeshModuleEndpointExtensions.FindRouteCollisions( | ||
| [ | ||
| Endpoint("/api/x", "GET", "a"), | ||
| Endpoint("/api/x", "POST", "b"), | ||
| ])); | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.