Skip to content

Commit 689b066

Browse files
authored
Merge pull request #51 from AuthKits/feat/middleware
feat: first class plugin middleware pipeline (HTTP + gRPC)
2 parents 674a344 + 68d94e7 commit 689b066

28 files changed

Lines changed: 1526 additions & 85 deletions

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,4 @@ src/Plugins/Solutions/DevTools/manifest.json
2525
issues/
2626
*.DotSettings.user
2727
Docs/package-lock.json
28+
TestResults

‎Dockerfile‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,14 +47,14 @@ FROM build AS publish
4747
WORKDIR /src
4848
RUN dotnet publish "src/Host/Host.csproj" -c Release -o /app/publish
4949
RUN dotnet publish "src/Plugins/Solutions/DevTokens/DevTokens.csproj" -c Release -o /app/publish/plugins/DevTokens
50-
COPY src/Plugins/Solutions/DevTokens/manifest.json /app/publish/plugins/DevTokens/manifest.json
50+
RUN if [ -f src/Plugins/Solutions/DevTokens/manifest.json ]; then cp src/Plugins/Solutions/DevTokens/manifest.json /app/publish/plugins/DevTokens/manifest.json; else echo "DevTokens manifest.json not in context, skipping"; fi
5151

5252
COPY --from=ui /ui/dist/ui.html src/Plugins/Solutions/DevTools/UI/dist/ui.html
5353
RUN dotnet publish "src/Plugins/Solutions/DevTools/DevTools.csproj" -c Release -o /app/publish/plugins/DevTools
54-
COPY src/Plugins/Solutions/DevTools/manifest.json /app/publish/plugins/DevTools/manifest.json
54+
RUN if [ -f src/Plugins/Solutions/DevTools/manifest.json ]; then cp src/Plugins/Solutions/DevTools/manifest.json /app/publish/plugins/DevTools/manifest.json; else echo "DevTools manifest.json not in context, skipping"; fi
5555

5656
RUN dotnet publish "src/Plugins/Solutions/ExamplePlugin/ExamplePlugin.csproj" -c Release -o /app/publish/plugins/ExamplePlugin
57-
COPY src/Plugins/Solutions/ExamplePlugin/manifest.json /app/publish/plugins/ExamplePlugin/manifest.json
57+
RUN if [ -f src/Plugins/Solutions/ExamplePlugin/manifest.json ]; then cp src/Plugins/Solutions/ExamplePlugin/manifest.json /app/publish/plugins/ExamplePlugin/manifest.json; else echo "ExamplePlugin manifest.json not in context, skipping"; fi
5858

5959
FROM base AS final
6060
WORKDIR /app

‎src/Host/Configuration/Grpc/GrpcConfiguration.cs‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
using Host.Grpc;
2+
using Host.Plugins.Configuration;
3+
using Host.Plugins.Loading;
4+
using Microsoft.Extensions.Logging;
25

36
namespace Host.Configuration.Grpc;
47

@@ -18,17 +21,25 @@ namespace Host.Configuration.Grpc;
1821
public static class GrpcConfiguration
1922
{
2023
/// <summary>
21-
/// Registers gRPC services with the dependency injection container.
24+
/// Registers gRPC services with the dependency injection container,
25+
/// including plugin contributed interceptors.
2226
/// </summary>
2327
/// <param name="services">The service collection used to register gRPC services.</param>
28+
/// <param name="plugins">The plugins loaded during application startup.</param>
29+
/// <param name="logger">Logger for gRPC composition diagnostics.</param>
2430
/// <returns>The configured <see cref="IServiceCollection"/> instance.</returns>
25-
public static IServiceCollection AddGrpcServices(this IServiceCollection services)
31+
public static IServiceCollection AddGrpcServices(
32+
this IServiceCollection services,
33+
IReadOnlyList<LoadedPlugin> plugins,
34+
ILogger logger)
2635
{
2736
services.AddGrpc(options =>
2837
{
2938
//options.Interceptors.Add<ExceptionHandlingInterceptor>();
3039
});
3140

41+
services.AddPluginGrpcInterceptors(plugins, logger);
42+
3243
return services;
3344
}
3445

‎src/Host/Configuration/Pipeline/AppMiddlewareConfiguration.cs‎

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,7 @@
22
using Host.Plugins.Configuration;
33
using Host.Restful.Middleware.Exceptions;
44
using Host.Security.Middleware;
5-
using AuthKit.Plugins.Abstractions;
6-
using AuthKit.Plugins.Abstractions.Contracts;
7-
using AuthKit.Plugins.Abstractions.Contracts.PluginContract;
5+
using AuthKit.Plugins.Abstractions.Pipeline;
86

97
namespace Host.Configuration.Pipeline;
108

@@ -14,7 +12,7 @@ namespace Host.Configuration.Pipeline;
1412
/// </summary>
1513
/// <remarks>
1614
/// <para>
17-
/// Configures routing, validation and exception handling, plugin-provided
15+
/// Configures routing, validation and exception handling, plugin provided
1816
/// middleware, and authentication and authorization.
1917
/// </para>
2018
/// <para>
@@ -40,25 +38,43 @@ public static WebApplication ConfigureMiddleware(
4038
IReadOnlyList<LoadedPlugin> plugins)
4139
{
4240
PluginApplicationConfiguration.ConfigureApplications(app, plugins);
43-
PluginApplicationConfiguration.ConfigurePipeline(app, plugins, PluginPipelinePosition.BeforeRouting);
41+
ConfigurePluginSlot(PluginPipelinePosition.BeforeRouting, PipelinePosition.BeforeRouting);
4442
app.UseRouting();
45-
PluginApplicationConfiguration.ConfigurePipeline(app, plugins, PluginPipelinePosition.AfterRouting);
43+
ConfigurePluginSlot(PluginPipelinePosition.AfterRouting, PipelinePosition.AfterRouting);
4644

4745
app.UseMiddleware<ValidationExceptionMiddleware>();
4846
app.UseMiddleware<ExceptionHandlingMiddleware>();
4947

5048
PluginApplicationConfiguration.ConfigureLegacyMiddleware(app, plugins);
51-
PluginApplicationConfiguration.ConfigurePipeline(app, plugins, PluginPipelinePosition.BeforeAuthentication);
49+
ConfigurePluginSlot(PluginPipelinePosition.BeforeAuthentication, PipelinePosition.BeforeAuthentication);
5250

5351
app.UseMiddleware<ApiKeyCredentialExtractor>();
5452

5553
app.UseAuthentication();
56-
PluginApplicationConfiguration.ConfigurePipeline(app, plugins, PluginPipelinePosition.AfterAuthentication);
57-
PluginApplicationConfiguration.ConfigurePipeline(app, plugins, PluginPipelinePosition.BeforeAuthorization);
54+
ConfigurePluginSlot(PluginPipelinePosition.AfterAuthentication);
55+
ConfigurePluginSlot(PluginPipelinePosition.BeforeAuthorization);
5856
app.UseAuthorization();
59-
PluginApplicationConfiguration.ConfigurePipeline(app, plugins, PluginPipelinePosition.AfterAuthorization);
60-
PluginApplicationConfiguration.ConfigurePipeline(app, plugins, PluginPipelinePosition.BeforeEndpoints);
57+
ConfigurePluginSlot(PluginPipelinePosition.AfterAuthorization, PipelinePosition.AfterAuthorization);
58+
ConfigurePluginSlot(PluginPipelinePosition.BeforeEndpoints, PipelinePosition.BeforeEndpoints);
59+
60+
// AfterEndpointExecution is post endpoint (response) execution: registered here, before
61+
// endpoint mapping, so each middleware wraps the endpoint and its post-next code runs
62+
// after the endpoint has executed.
63+
ConfigureMiddlewareSlot(PipelinePosition.AfterEndpointExecution);
6164

6265
return app;
66+
67+
void ConfigurePluginSlot(
68+
PluginPipelinePosition pipelinePosition,
69+
PipelinePosition? middlewarePosition = null)
70+
{
71+
PluginApplicationConfiguration.ConfigurePipeline(app, plugins, pipelinePosition);
72+
73+
if (middlewarePosition is { } position)
74+
ConfigureMiddlewareSlot(position);
75+
}
76+
77+
void ConfigureMiddlewareSlot(PipelinePosition middlewarePosition) =>
78+
PluginApplicationConfiguration.ConfigurePluginMiddlewares(app, plugins, middlewarePosition);
6379
}
6480
}

‎src/Host/Plugins/Configuration/PluginApplicationConfiguration.cs‎

Lines changed: 97 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System.Reflection;
2-
using AuthKit.Plugins.Abstractions;
32
using AuthKit.Plugins.Abstractions.Contracts;
3+
using AuthKit.Plugins.Abstractions.Contracts.PluginContract;
4+
using AuthKit.Plugins.Abstractions.Pipeline;
45
using Host.Plugins.Loading;
56
using IAuthKitPlugin = AuthKit.Plugins.Abstractions.Contracts.PluginContract.IAuthKitPlugin;
67

@@ -11,15 +12,15 @@ namespace Host.Plugins.Configuration;
1112
/// </summary>
1213
/// <remarks>
1314
/// <para>
14-
/// Plugins are invoked in a stable order regardless of the order they were
15+
/// Plugins are invoked in stable order regardless of the order they were
1516
/// discovered: first by <see cref="PluginPipelinePosition"/> and then by
1617
/// plugin identifier using an ordinal comparison.
1718
/// </para>
1819
/// <para>
19-
/// Plugins that do not implement a given hook are skipped. The newer
20+
/// Plugins that do not implement given hook are skipped. The newer
2021
/// <c>ConfigureApplication</c> and <c>ConfigurePipeline</c> hooks take
2122
/// precedence over the legacy <c>MiddlewareType</c> entry point, which is
22-
/// applied only as a compatibility fallback.
23+
/// applied only as compatibility fallback.
2324
/// </para>
2425
/// </remarks>
2526
internal static class PluginApplicationConfiguration
@@ -56,12 +57,12 @@ public static void ConfigureApplications(IApplicationBuilder application, IReadO
5657
/// <param name="plugins">The plugins loaded during application startup.</param>
5758
/// <param name="position">The pipeline position to run hooks for.</param>
5859
/// <exception cref="ArgumentOutOfRangeException">
59-
/// Thrown when <paramref name="position"/> is not a defined
60+
/// Thrown when <paramref name="position"/> is not defined
6061
/// <see cref="PluginPipelinePosition"/> value.
6162
/// </exception>
6263
/// <exception cref="InvalidOperationException">
63-
/// Thrown when a plugin that implements the pipeline hook declares a
64-
/// <c>PipelinePosition</c> that is not a defined enum value.
64+
/// Thrown when plugin that implements the pipeline hook declares a
65+
/// <c>PipelinePosition</c> that is not defined enum value.
6566
/// </exception>
6667
public static void ConfigurePipeline(
6768
IApplicationBuilder application,
@@ -134,6 +135,92 @@ public static void ConfigureLegacyMiddleware(WebApplication application, IReadOn
134135
}
135136
}
136137

138+
/// <summary>
139+
/// Inserts declarative <see cref="PluginMiddleware"/> entries for one
140+
/// <see cref="PipelinePosition"/> in deterministic order
141+
/// (Order -> stable plugin Id -> declaration index).
142+
/// Disabled entries are skipped without side effects.
143+
/// <see cref="IAuthKitMiddleware"/> and <see cref="AuthKitMiddlewareBase"/>
144+
/// implementations are resolved from the request
145+
/// service provider (single request scope); other types use
146+
/// <c>UseMiddleware</c> activation.
147+
/// </summary>
148+
public static void ConfigurePluginMiddlewares(
149+
IApplicationBuilder application,
150+
IReadOnlyList<LoadedPlugin> plugins,
151+
PipelinePosition position)
152+
{
153+
if (!Enum.IsDefined(position))
154+
throw new ArgumentOutOfRangeException(nameof(position), position, "Unsupported pipeline position.");
155+
156+
var ordered = plugins
157+
.SelectMany(lp => (lp.Plugin.Middlewares ?? [])
158+
.Select((mw, index) => (Plugin: lp.Plugin, Entry: mw, Index: index)))
159+
.Where(x => x.Entry.Position == position && x.Entry.IsMiddlewareEnabled
160+
&& x.Entry.Transport == AuthKitTransport.Http)
161+
.OrderBy(x => x.Entry.Order)
162+
.ThenBy(x => x.Plugin.Id, StringComparer.Ordinal)
163+
.ThenBy(x => x.Index)
164+
.ToList();
165+
166+
foreach (var (plugin, entry, _) in ordered)
167+
{
168+
if (entry.MiddlewareType is null)
169+
throw new InvalidOperationException($"Plugin '{plugin.Id}' declares middleware with null type.");
170+
171+
try
172+
{
173+
RegisterPluginMiddleware(application, entry.MiddlewareType);
174+
}
175+
catch (Exception ex)
176+
{
177+
throw new InvalidOperationException($"Plugin '{plugin.Id}' failed to register middleware '{entry.MiddlewareType?.Name ?? entry.Name}'.", ex);
178+
}
179+
}
180+
}
181+
182+
private static void RegisterPluginMiddleware(IApplicationBuilder application, Type middlewareType) =>
183+
application.UseWhen(
184+
static context => !IsGrpcRequest(context),
185+
branch => RegisterHttpMiddleware(branch, middlewareType));
186+
187+
private static bool IsGrpcRequest(HttpContext context) =>
188+
context.Request.ContentType?.StartsWith("application/grpc", StringComparison.OrdinalIgnoreCase) == true;
189+
190+
private static void RegisterHttpMiddleware(IApplicationBuilder application, Type middlewareType)
191+
{
192+
if (typeof(IAuthKitMiddleware).IsAssignableFrom(middlewareType))
193+
{
194+
application.Use(async (context, next) =>
195+
{
196+
var middleware = ResolvePluginMiddleware<IAuthKitMiddleware>(context, middlewareType);
197+
await middleware.InvokeAsync(context, next);
198+
});
199+
200+
return;
201+
}
202+
203+
if (typeof(AuthKitMiddlewareBase).IsAssignableFrom(middlewareType))
204+
{
205+
application.Use(async (context, next) =>
206+
{
207+
var middleware = ResolvePluginMiddleware<AuthKitMiddlewareBase>(context, middlewareType);
208+
await middleware.InvokeAsync(context, next);
209+
});
210+
211+
return;
212+
}
213+
214+
application.UseMiddleware(middlewareType);
215+
}
216+
217+
private static TMiddleware ResolvePluginMiddleware<TMiddleware>(HttpContext context, Type middlewareType)
218+
where TMiddleware : class =>
219+
context.RequestServices.GetService(middlewareType) as TMiddleware
220+
?? ActivatorUtilities.CreateInstance(context.RequestServices, middlewareType) as TMiddleware
221+
?? throw new InvalidOperationException(
222+
$"Middleware type '{middlewareType.FullName}' must be assignable to '{typeof(TMiddleware).FullName}'.");
223+
137224
/// <summary>
138225
/// Orders plugins by pipeline position and then by plugin identifier.
139226
/// </summary>
@@ -165,14 +252,14 @@ private static void ValidatePluginPositions(IReadOnlyList<LoadedPlugin> plugins)
165252
}
166253

167254
/// <summary>
168-
/// Determines whether a plugin provides a concrete implementation of the given
255+
/// Determines whether plugin provides concrete implementation of the given
169256
/// hook rather than inheriting the interface's default implementation.
170257
/// </summary>
171258
/// <param name="plugin">The plugin to inspect.</param>
172259
/// <param name="methodName">The name of the interface method to look up.</param>
173260
/// <param name="parameterTypes">The parameter types that identify the overload.</param>
174261
/// <returns>
175-
/// <c>true</c> when the plugin overrides the hook; otherwise, <c>false</c>.
262+
/// <c>true</c> when the plugin overrides the hook otherwise, <c>false</c>.
176263
/// </returns>
177264
private static bool HasImplementation(IAuthKitPlugin plugin, string methodName, params Type[] parameterTypes)
178265
{
@@ -185,4 +272,4 @@ private static bool HasImplementation(IAuthKitPlugin plugin, string methodName,
185272

186273
return method is not null && method.DeclaringType != typeof(IAuthKitPlugin);
187274
}
188-
}
275+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
using AuthKit.Plugins.Abstractions.Contracts.PluginContract;
2+
using AuthKit.Plugins.Abstractions.Pipeline;
3+
using Grpc.Core.Interceptors;
4+
using Host.Plugins.Loading;
5+
using Microsoft.Extensions.Logging;
6+
7+
namespace Host.Plugins.Configuration;
8+
9+
/// <summary>
10+
/// Composes plugin contributed gRPC interceptors into the host interceptor chain.
11+
/// </summary>
12+
/// <remarks>
13+
/// <para>
14+
/// Only <see cref="PluginMiddleware"/> entries declared with
15+
/// <see cref="AuthKitTransport.Grpc"/> are composed here. The host never guesses
16+
/// transport by reflection: entries targeting <see cref="AuthKitTransport.Http"/>
17+
/// are skipped on the gRPC transport with an explicit warning (no automatic
18+
/// <c>HttpContext</c> -> <c>ServerCallContext</c> bridge).
19+
/// </para>
20+
/// <para>
21+
/// <see cref="PipelinePosition"/> values are AuthKit semantic positions, not native
22+
/// ASP.NET Core gRPC insertion points: the single interceptor chain is ordered
23+
/// BeforeRouting -> … -> BeforeEndpoints, so each interceptor wraps the downstream
24+
/// call in that order; <c>AfterEndpointExecution</c> interceptors perform
25+
/// post-processing after the downstream call (the natural interceptor tail shape).
26+
/// Within one position entries are ordered deterministically by
27+
/// Order -> stable plugin Id -> declaration index.
28+
/// </para>
29+
/// <para>
30+
/// Interceptors are registered scoped and resolved per call from the request
31+
/// service provider (single scope, mirroring). Streaming (unary, client,
32+
/// server and duplex streaming) is supported through the base
33+
/// <see cref="Interceptor"/> overloads no custom streaming pipeline exists.
34+
/// </para>
35+
/// </remarks>
36+
internal static class PluginGrpcConfiguration
37+
{
38+
/// <summary>
39+
/// Registers every enabled gRPC transport interceptor and composes the
40+
/// interceptor chain in semantic position order.
41+
/// </summary>
42+
public static IServiceCollection AddPluginGrpcInterceptors(
43+
this IServiceCollection services,
44+
IReadOnlyList<LoadedPlugin> plugins,
45+
ILogger logger)
46+
{
47+
WarnForHttpOnlyMiddleware(plugins, logger);
48+
49+
var ordered = OrderGrpcInterceptors(plugins);
50+
51+
foreach (var (_, entry, _) in ordered)
52+
{
53+
if (entry.MiddlewareType is null)
54+
throw new InvalidOperationException("Plugin declares PluginMiddleware with a null MiddlewareType.");
55+
56+
if (!typeof(Interceptor).IsAssignableFrom(entry.MiddlewareType))
57+
throw new InvalidOperationException(
58+
$"Plugin middleware '{entry.MiddlewareType.FullName ?? entry.MiddlewareType.Name}' targets the gRPC transport " +
59+
$"but is not an Interceptor subclass.");
60+
61+
services.AddScoped(entry.MiddlewareType);
62+
}
63+
64+
services.Configure<global::Grpc.AspNetCore.Server.GrpcServiceOptions>(options =>
65+
{
66+
foreach (var (_, entry, _) in ordered)
67+
options.Interceptors.Add(entry.MiddlewareType!);
68+
});
69+
70+
return services;
71+
}
72+
73+
/// <summary>
74+
/// Orders enabled gRPC transport entries by semantic position, then
75+
/// Order -> stable plugin Id -> declaration index.
76+
/// </summary>
77+
internal static IReadOnlyList<(string PluginId, PluginMiddleware Entry, int Index)> OrderGrpcInterceptors(
78+
IReadOnlyList<LoadedPlugin> plugins) =>
79+
plugins
80+
.SelectMany(lp => (lp.Plugin.Middlewares ?? [])
81+
.Select((mw, index) => (PluginId: lp.Plugin.Id, Entry: mw, Index: index)))
82+
.Where(x => x.Entry.IsMiddlewareEnabled && x.Entry.Transport == AuthKitTransport.Grpc)
83+
.OrderBy(x => x.Entry.Position)
84+
.ThenBy(x => x.Entry.Order)
85+
.ThenBy(x => x.PluginId, StringComparer.Ordinal)
86+
.ThenBy(x => x.Index)
87+
.ToList();
88+
89+
private static void WarnForHttpOnlyMiddleware(IReadOnlyList<LoadedPlugin> plugins, ILogger logger)
90+
{
91+
foreach (var loadedPlugin in plugins)
92+
{
93+
foreach (var entry in loadedPlugin.Plugin.Middlewares ?? [])
94+
{
95+
if (!entry.IsMiddlewareEnabled || entry.Transport != AuthKitTransport.Http)
96+
continue;
97+
98+
logger.LogWarning(
99+
"Plugin '{PluginId}' middleware '{MiddlewareName}' targets the HTTP transport " +
100+
"and is skipped on the gRPC transport. Declare Transport = Grpc with an " +
101+
"Interceptor MiddlewareType to run on gRPC; no automatic HttpContext bridge is provided.",
102+
loadedPlugin.Plugin.Id,
103+
entry.Name ?? entry.MiddlewareType?.FullName ?? entry.MiddlewareType?.Name ?? "<unknown>");
104+
}
105+
}
106+
}
107+
}

0 commit comments

Comments
 (0)