Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -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";
}
}
74 changes: 74 additions & 0 deletions src/Http/Wolverine.Http.Tests/http_endpoint_discovery_filter.cs
Original file line number Diff line number Diff line change
@@ -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<HttpGraph> DiscoverEndpointsAsync(Action<WolverineHttpOptions>? 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<WolverineHttpOptions>().Endpoints!;
}
}
43 changes: 40 additions & 3 deletions src/Http/Wolverine.Http/HttpChainSource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,17 @@ internal class HttpChainSource
private readonly ActionMethodFilter _methodFilters = new();
private readonly CompositeFilter<Type> _typeFilters = new();

public HttpChainSource(IEnumerable<Assembly> 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<Assembly> 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) ||
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion src/Http/Wolverine.Http/HttpGraph.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ILogger<HttpGraph>>();

// Cold-start fast path (GH-2925): in TypeLoadMode.Static, consume the pre-generated
Expand Down
29 changes: 29 additions & 0 deletions src/Http/Wolverine.Http/WolverineHttpOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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; }

/// <summary>
/// Additive, opt-in customization of the type filtering used to discover Wolverine HTTP endpoints
/// from the scanned assemblies. This is the HTTP counterpart to
/// <see cref="Wolverine.Configuration.HandlerDiscovery.CustomizeHandlerDiscovery" />: use
/// <c>q.Excludes</c> to drop endpoint types — e.g.
/// <c>opts.CustomizeHttpEndpointDiscovery(q =&gt; q.Excludes.InNamespace("MyApp.Excluded"))</c> — 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
/// <c>[WolverineIgnore]</c> on the type is the only lever. Rules added through <c>q.Includes</c> are
/// additive: they broaden discovery beyond the built-in <c>*Endpoint(s)</c> /
/// <c>[WolverineHttpMethod]</c> 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.
/// </summary>
/// <param name="configure">Configures the excludes/includes applied during endpoint discovery.</param>
/// <exception cref="ArgumentNullException"></exception>
public void CustomizeHttpEndpointDiscovery(Action<TypeQuery> configure)
{
ArgumentNullException.ThrowIfNull(configure);
EndpointDiscovery ??= new TypeQuery(TypeClassification.All);
configure(EndpointDiscovery);
}

/// <summary>
/// Configure built in tenant id detection strategies
/// </summary>
Expand Down
Loading