diff --git a/src/Http/Wolverine.Http.Tests.DifferentAssembly/DiscoveryFilterEndpoints.cs b/src/Http/Wolverine.Http.Tests.DifferentAssembly/DiscoveryFilterEndpoints.cs new file mode 100644 index 000000000..c16556cf3 --- /dev/null +++ b/src/Http/Wolverine.Http.Tests.DifferentAssembly/DiscoveryFilterEndpoints.cs @@ -0,0 +1,22 @@ +// Fixtures for http_endpoint_discovery_filter: two HTTP endpoints in two namespaces of one scanned +// assembly. Both follow the plain "*Endpoint" naming convention, so both are discovered by default; +// the test then excludes one namespace to show WolverineHttpOptions.CustomizeHttpEndpointDiscovery +// drops the endpoints under it. + +namespace Wolverine.Http.Tests.DifferentAssembly.DiscoveryFilter.Included +{ + public static class IncludedPingEndpoint + { + [WolverineGet("/discovery-filter/included")] + public static string Get() => "included"; + } +} + +namespace Wolverine.Http.Tests.DifferentAssembly.DiscoveryFilter.Excluded +{ + public static class ExcludedPongEndpoint + { + [WolverineGet("/discovery-filter/excluded")] + public static string Get() => "excluded"; + } +} diff --git a/src/Http/Wolverine.Http.Tests/http_endpoint_discovery_filter.cs b/src/Http/Wolverine.Http.Tests/http_endpoint_discovery_filter.cs new file mode 100644 index 000000000..cece72fc3 --- /dev/null +++ b/src/Http/Wolverine.Http.Tests/http_endpoint_discovery_filter.cs @@ -0,0 +1,74 @@ +using Marten; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Wolverine.Http.Tests.DifferentAssembly.Validation; +using Wolverine.Marten; + +namespace Wolverine.Http.Tests; + +// HTTP endpoint discovery honours the same namespace splits that HandlerDiscovery offers via +// CustomizeHandlerDiscovery(q => q.Excludes.InNamespace(...)) — the handler-side counterpart tracked +// in GH-3371. WolverineHttpOptions.CustomizeHttpEndpointDiscovery feeds the excludes that +// HttpChainSource owns, so an endpoint in an excluded namespace of a scanned assembly is dropped. +// The two facts pinned here: with no filter both namespaces are discovered; with an exclusion the +// excluded namespace is dropped while its sibling still resolves. +public class http_endpoint_discovery_filter +{ + private const string IncludedRoute = "/discovery-filter/included"; + private const string ExcludedRoute = "/discovery-filter/excluded"; + private const string ExcludedNamespace = "Wolverine.Http.Tests.DifferentAssembly.DiscoveryFilter.Excluded"; + + [Fact] + public async Task by_default_both_namespaces_are_discovered() + { + var endpoints = await DiscoverEndpointsAsync(); + + endpoints.ChainFor("GET", IncludedRoute).ShouldNotBeNull(); + endpoints.ChainFor("GET", ExcludedRoute).ShouldNotBeNull(); + } + + [Fact] + public async Task excluded_namespace_endpoint_is_not_registered_when_filtered() + { + var endpoints = await DiscoverEndpointsAsync(opts => + opts.CustomizeHttpEndpointDiscovery(q => q.Excludes.InNamespace(ExcludedNamespace))); + + // The sibling namespace still resolves... + endpoints.ChainFor("GET", IncludedRoute).ShouldNotBeNull(); + + // ...but the excluded-namespace endpoint, which the default discovered above, is gone. + endpoints.ChainFor("GET", ExcludedRoute).ShouldBeNull(); + } + + // Boots a minimal, database-less host whose endpoint discovery is pinned to the small, isolated + // "DifferentAssembly" (the same bootstrap generate_openapi_without_database uses), then returns the + // built HttpGraph without ever starting the host. No app.StartAsync() and the Marten connection is + // unreachable, so nothing here touches a database. + private static async Task DiscoverEndpointsAsync(Action? configure = null) + { + var builder = WebApplication.CreateBuilder(); + + builder.Services + .AddMarten(opts => + { + opts.Connection( + "Host=localhost;Port=9999;Database=does_not_exist;Username=nobody;Password=nobody;Timeout=2;Command Timeout=2"); + }) + .IntegrateWithWolverine(); + + builder.Host.UseWolverine(opts => + { + opts.ApplicationAssembly = typeof(Validated2Endpoint).Assembly; + opts.Policies.AutoApplyTransactions(); + opts.Policies.UseDurableLocalQueues(); + }); + + builder.Services.AddWolverineHttp(); + + await using var app = builder.Build(); + app.MapWolverineEndpoints(configure); + + return app.Services.GetRequiredService().Endpoints!; + } +} diff --git a/src/Http/Wolverine.Http/HttpChainSource.cs b/src/Http/Wolverine.Http/HttpChainSource.cs index c19e6b88a..56ea8d10b 100644 --- a/src/Http/Wolverine.Http/HttpChainSource.cs +++ b/src/Http/Wolverine.Http/HttpChainSource.cs @@ -14,9 +14,17 @@ internal class HttpChainSource private readonly ActionMethodFilter _methodFilters = new(); private readonly CompositeFilter _typeFilters = new(); - public HttpChainSource(IEnumerable assemblies) + // Opt-in, additive customization supplied by WolverineHttpOptions.CustomizeHttpEndpointDiscovery. + // Null unless configured; when set, its Excludes drop endpoint types (so HTTP endpoints honour the + // same namespace splits as HandlerDiscovery filters) and its Includes broaden discovery. GH-3371. + private readonly TypeQuery? _userDiscovery; + private readonly bool _hasUserIncludes; + + public HttpChainSource(IEnumerable assemblies, TypeQuery? userDiscovery = null) { _assemblies = assemblies.ToList(); + _userDiscovery = userDiscovery; + _hasUserIncludes = userDiscovery is not null && userDiscovery.Includes.Any(); _typeFilters.Includes += type => type.Name.EndsWith("Endpoint", StringComparison.OrdinalIgnoreCase) || @@ -36,14 +44,43 @@ internal MethodCall[] FindActions() // _typeFilters, so discovery semantics are unchanged. TypeClassification.All keeps every // non-trimmed type (e.g. static endpoint classes) that the previous scan considered. var query = new TypeQuery(TypeClassification.All); - query.Includes.WithCondition("Wolverine HTTP endpoint type", - x => _typeFilters.Matches(x) && x.IsPublic && x.GetGenericArguments().Length == 0); + query.Includes.WithCondition("Wolverine HTTP endpoint type", isEndpointType); return query.Find(_assemblies) .Distinct() .SelectMany(actionsFromType).ToArray(); } + // The built-in endpoint predicate, plus the opt-in CustomizeHttpEndpointDiscovery filtering. With no + // user discovery configured (_userDiscovery == null, _hasUserIncludes == false) a type qualifies on + // the built-in convention alone: public, non-generic, and matched by _typeFilters (name ends in + // "Endpoint(s)" or carries a [WolverineHttpMethod]). + private bool isEndpointType(Type x) + { + if (!x.IsPublic || x.GetGenericArguments().Length != 0) + { + return false; + } + + // Opt-in exclusions are subtractive: an otherwise-qualifying endpoint type that a user rule matches + // (e.g. q.Excludes.InNamespace(...)) is dropped, so HTTP endpoints can be split across hosts the + // same way HandlerDiscovery filters split message handlers. + if (_userDiscovery is not null && _userDiscovery.Excludes.Matches(x)) + { + return false; + } + + if (_typeFilters.Matches(x)) + { + return true; + } + + // Opt-in inclusions are additive: they broaden discovery beyond the built-in "*Endpoint(s)" / + // [WolverineHttpMethod] convention. An included type still only contributes methods that carry a + // Wolverine HTTP verb attribute (see actionsFromType / _methodFilters). + return _hasUserIncludes && _userDiscovery!.Includes.Matches(x); + } + // Static-mode counterpart to FindActions(): the endpoint types were already discovered and // captured into the generated HttpEndpointRegistry at codegen write time, so we apply the normal // endpoint-method selection to exactly those types instead of scanning assemblies. See GH-2925. diff --git a/src/Http/Wolverine.Http/HttpGraph.cs b/src/Http/Wolverine.Http/HttpGraph.cs index d3c87131d..5c301c0e6 100644 --- a/src/Http/Wolverine.Http/HttpGraph.cs +++ b/src/Http/Wolverine.Http/HttpGraph.cs @@ -122,7 +122,7 @@ public OptionsDescription ToDescription() public void DiscoverEndpoints(WolverineHttpOptions wolverineHttpOptions) { - var source = new HttpChainSource(_options.Assemblies); + var source = new HttpChainSource(_options.Assemblies, wolverineHttpOptions.EndpointDiscovery); var logger = Container.GetInstance>(); // Cold-start fast path (GH-2925): in TypeLoadMode.Static, consume the pre-generated diff --git a/src/Http/Wolverine.Http/WolverineHttpOptions.cs b/src/Http/Wolverine.Http/WolverineHttpOptions.cs index 2cd925558..ca2c045f9 100644 --- a/src/Http/Wolverine.Http/WolverineHttpOptions.cs +++ b/src/Http/Wolverine.Http/WolverineHttpOptions.cs @@ -2,6 +2,7 @@ using JasperFx.CodeGeneration.Frames; using JasperFx.Core; using JasperFx.Core.Reflection; +using JasperFx.Core.TypeScanning; using Microsoft.AspNetCore.Builder; using Wolverine.Http.Antiforgery; using Microsoft.AspNetCore.Http; @@ -275,6 +276,34 @@ public void RoutePrefix(string prefix, string forEndpointsInNamespace) NamespacePrefixes.Add((prefix.Trim('/'), forEndpointsInNamespace)); } + // Null unless CustomizeHttpEndpointDiscovery() was called. HttpChainSource layers its Excludes + // (subtractive) and Includes (additive) on top of the built-in endpoint convention; while it stays + // null the discovery predicate applies only that built-in convention. + internal TypeQuery? EndpointDiscovery { get; private set; } + + /// + /// Additive, opt-in customization of the type filtering used to discover Wolverine HTTP endpoints + /// from the scanned assemblies. This is the HTTP counterpart to + /// : use + /// q.Excludes to drop endpoint types — e.g. + /// opts.CustomizeHttpEndpointDiscovery(q => q.Excludes.InNamespace("MyApp.Excluded")) — so that + /// HTTP endpoints can be split across hosts the same way message handlers already can. Without this, + /// an HTTP endpoint in an excluded namespace of a scanned assembly still registers, and + /// [WolverineIgnore] on the type is the only lever. Rules added through q.Includes are + /// additive: they broaden discovery beyond the built-in *Endpoint(s) / + /// [WolverineHttpMethod] convention (an included type still only contributes methods carrying a + /// Wolverine HTTP verb attribute). When this method is never called, discovery applies only the + /// built-in endpoint convention. + /// + /// Configures the excludes/includes applied during endpoint discovery. + /// + public void CustomizeHttpEndpointDiscovery(Action configure) + { + ArgumentNullException.ThrowIfNull(configure); + EndpointDiscovery ??= new TypeQuery(TypeClassification.All); + configure(EndpointDiscovery); + } + /// /// Configure built in tenant id detection strategies ///