up
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
Manifest Integrity / Validate Schema Integrity (push) Has been cancelled
Manifest Integrity / Validate Contract Documents (push) Has been cancelled
Manifest Integrity / Validate Pack Fixtures (push) Has been cancelled
Manifest Integrity / Audit SHA256SUMS Files (push) Has been cancelled
Manifest Integrity / Verify Merkle Roots (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
Manifest Integrity / Validate Schema Integrity (push) Has been cancelled
Manifest Integrity / Validate Contract Documents (push) Has been cancelled
Manifest Integrity / Validate Pack Fixtures (push) Has been cancelled
Manifest Integrity / Audit SHA256SUMS Files (push) Has been cancelled
Manifest Integrity / Verify Merkle Roots (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
using StellaOps.Router.Gateway.Middleware;
|
||||
using StellaOps.Router.Gateway.OpenApi;
|
||||
|
||||
namespace StellaOps.Router.Gateway;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for configuring the router gateway middleware pipeline.
|
||||
/// </summary>
|
||||
public static class ApplicationBuilderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the router gateway middleware pipeline.
|
||||
/// </summary>
|
||||
/// <param name="app">The application builder.</param>
|
||||
/// <returns>The application builder for chaining.</returns>
|
||||
public static IApplicationBuilder UseRouterGateway(this IApplicationBuilder app)
|
||||
{
|
||||
// Enforce payload limits first
|
||||
app.UseMiddleware<PayloadLimitsMiddleware>();
|
||||
|
||||
// Resolve endpoints from routing state
|
||||
app.UseMiddleware<EndpointResolutionMiddleware>();
|
||||
|
||||
// Make routing decisions (select instance)
|
||||
app.UseMiddleware<RoutingDecisionMiddleware>();
|
||||
|
||||
// Dispatch to transport and return response
|
||||
app.UseMiddleware<TransportDispatchMiddleware>();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the router gateway middleware pipeline without payload limiting.
|
||||
/// </summary>
|
||||
/// <param name="app">The application builder.</param>
|
||||
/// <returns>The application builder for chaining.</returns>
|
||||
public static IApplicationBuilder UseRouterGatewayCore(this IApplicationBuilder app)
|
||||
{
|
||||
// Resolve endpoints from routing state
|
||||
app.UseMiddleware<EndpointResolutionMiddleware>();
|
||||
|
||||
// Make routing decisions (select instance)
|
||||
app.UseMiddleware<RoutingDecisionMiddleware>();
|
||||
|
||||
// Dispatch to transport and return response
|
||||
app.UseMiddleware<TransportDispatchMiddleware>();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps OpenAPI endpoints to the application.
|
||||
/// Should be called before UseRouterGateway so OpenAPI requests are handled first.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The endpoint route builder.</param>
|
||||
/// <returns>The endpoint route builder for chaining.</returns>
|
||||
public static IEndpointRouteBuilder MapRouterOpenApi(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
return endpoints.MapRouterOpenApiEndpoints();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Router.Gateway.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// Background service that periodically refreshes claims from Authority.
|
||||
/// </summary>
|
||||
internal sealed class AuthorityClaimsRefreshService : BackgroundService
|
||||
{
|
||||
private readonly IAuthorityClaimsProvider _claimsProvider;
|
||||
private readonly IEffectiveClaimsStore _claimsStore;
|
||||
private readonly AuthorityConnectionOptions _options;
|
||||
private readonly ILogger<AuthorityClaimsRefreshService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AuthorityClaimsRefreshService"/> class.
|
||||
/// </summary>
|
||||
public AuthorityClaimsRefreshService(
|
||||
IAuthorityClaimsProvider claimsProvider,
|
||||
IEffectiveClaimsStore claimsStore,
|
||||
IOptions<AuthorityConnectionOptions> options,
|
||||
ILogger<AuthorityClaimsRefreshService> logger)
|
||||
{
|
||||
_claimsProvider = claimsProvider;
|
||||
_claimsStore = claimsStore;
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
if (!_options.Enabled)
|
||||
{
|
||||
_logger.LogInformation("Authority integration is disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_options.AuthorityUrl))
|
||||
{
|
||||
_logger.LogWarning("Authority URL not configured, skipping claims refresh");
|
||||
return;
|
||||
}
|
||||
|
||||
// Subscribe to push notifications if enabled
|
||||
if (_options.UseAuthorityPushNotifications)
|
||||
{
|
||||
_claimsProvider.OverridesChanged += OnOverridesChanged;
|
||||
}
|
||||
|
||||
// Initial fetch with optional wait
|
||||
await FetchWithRetryAsync(stoppingToken);
|
||||
|
||||
// Periodic refresh
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(_options.RefreshInterval, stoppingToken);
|
||||
await RefreshClaimsAsync(stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error during claims refresh");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task FetchWithRetryAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
if (!_options.WaitForAuthorityOnStartup)
|
||||
{
|
||||
await RefreshClaimsAsync(stoppingToken);
|
||||
return;
|
||||
}
|
||||
|
||||
var deadline = DateTime.UtcNow.Add(_options.StartupTimeout);
|
||||
var retryDelay = TimeSpan.FromSeconds(1);
|
||||
var attempt = 0;
|
||||
|
||||
while (DateTime.UtcNow < deadline && !stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
attempt++;
|
||||
_logger.LogDebug("Fetching claims from Authority (attempt {Attempt})", attempt);
|
||||
|
||||
await RefreshClaimsAsync(stoppingToken);
|
||||
|
||||
if (_claimsProvider.IsAvailable)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Successfully connected to Authority after {Attempts} attempts",
|
||||
attempt);
|
||||
return;
|
||||
}
|
||||
|
||||
await Task.Delay(retryDelay, stoppingToken);
|
||||
retryDelay = TimeSpan.FromSeconds(Math.Min(retryDelay.TotalSeconds * 2, 10));
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"Could not connect to Authority within {Timeout}. Proceeding without Authority claims.",
|
||||
_options.StartupTimeout);
|
||||
}
|
||||
|
||||
private async Task RefreshClaimsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var overrides = await _claimsProvider.GetOverridesAsync(cancellationToken);
|
||||
_claimsStore.UpdateFromAuthority(overrides);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to refresh claims from Authority");
|
||||
}
|
||||
}
|
||||
|
||||
private void OnOverridesChanged(object? sender, ClaimsOverrideChangedEventArgs e)
|
||||
{
|
||||
_logger.LogInformation("Received claims override update from Authority");
|
||||
_claimsStore.UpdateFromAuthority(e.Overrides);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Dispose()
|
||||
{
|
||||
if (_options.UseAuthorityPushNotifications)
|
||||
{
|
||||
_claimsProvider.OverridesChanged -= OnOverridesChanged;
|
||||
}
|
||||
|
||||
base.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
namespace StellaOps.Router.Gateway.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for connecting to the Authority service.
|
||||
/// </summary>
|
||||
public sealed class AuthorityConnectionOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "Router:Authority";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the Authority service URL.
|
||||
/// </summary>
|
||||
public string AuthorityUrl { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to wait for Authority on startup.
|
||||
/// If true, the gateway will delay handling traffic until Authority is available.
|
||||
/// </summary>
|
||||
public bool WaitForAuthorityOnStartup { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the startup timeout when waiting for Authority.
|
||||
/// </summary>
|
||||
public TimeSpan StartupTimeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the interval at which to refresh claims from Authority.
|
||||
/// </summary>
|
||||
public TimeSpan RefreshInterval { get; set; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to use push notifications from Authority.
|
||||
/// If false, the gateway will poll at the refresh interval.
|
||||
/// </summary>
|
||||
public bool UseAuthorityPushNotifications { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether Authority integration is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
namespace StellaOps.Router.Gateway.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// Middleware that enforces claims requirements for endpoints.
|
||||
/// </summary>
|
||||
public sealed class AuthorizationMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly IEffectiveClaimsStore _claimsStore;
|
||||
private readonly ILogger<AuthorizationMiddleware> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AuthorizationMiddleware"/> class.
|
||||
/// </summary>
|
||||
public AuthorizationMiddleware(
|
||||
RequestDelegate next,
|
||||
IEffectiveClaimsStore claimsStore,
|
||||
ILogger<AuthorizationMiddleware> logger)
|
||||
{
|
||||
_next = next;
|
||||
_claimsStore = claimsStore;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invokes the middleware.
|
||||
/// </summary>
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
// Get resolved endpoint from earlier middleware
|
||||
if (!context.Items.TryGetValue(RouterHttpContextKeys.EndpointDescriptor, out var endpointObj) ||
|
||||
endpointObj is not EndpointDescriptor endpoint)
|
||||
{
|
||||
// No endpoint resolved, let next middleware handle
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get effective claims for this endpoint
|
||||
var effectiveClaims = _claimsStore.GetEffectiveClaims(
|
||||
endpoint.ServiceName,
|
||||
endpoint.Method,
|
||||
endpoint.Path);
|
||||
|
||||
if (effectiveClaims.Count == 0)
|
||||
{
|
||||
// No claims required
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check each required claim
|
||||
foreach (var required in effectiveClaims)
|
||||
{
|
||||
var userClaims = context.User.Claims;
|
||||
|
||||
bool hasClaim = required.Value == null
|
||||
? userClaims.Any(c => c.Type == required.Type)
|
||||
: userClaims.Any(c => c.Type == required.Type && c.Value == required.Value);
|
||||
|
||||
if (!hasClaim)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Authorization failed for {Method} {Path}: user lacks claim {ClaimType}={ClaimValue}",
|
||||
endpoint.Method,
|
||||
endpoint.Path,
|
||||
required.Type,
|
||||
required.Value ?? "(any)");
|
||||
|
||||
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
context.Response.ContentType = "application/json";
|
||||
await context.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
error = "Forbidden",
|
||||
message = "Authorization failed: missing required claim",
|
||||
requiredClaim = new { type = required.Type, value = required.Value }
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await _next(context);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering the authorization middleware.
|
||||
/// </summary>
|
||||
public static class AuthorizationMiddlewareExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the claims authorization middleware to the pipeline.
|
||||
/// </summary>
|
||||
/// <param name="app">The application builder.</param>
|
||||
/// <returns>The application builder for chaining.</returns>
|
||||
public static IApplicationBuilder UseClaimsAuthorization(this IApplicationBuilder app)
|
||||
{
|
||||
return app.UseMiddleware<AuthorizationMiddleware>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
namespace StellaOps.Router.Gateway.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering Authority integration services.
|
||||
/// </summary>
|
||||
public static class AuthorizationServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds Authority integration services to the service collection.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configuration">The configuration.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddAuthorityIntegration(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
// Bind options
|
||||
services.Configure<AuthorityConnectionOptions>(
|
||||
configuration.GetSection(AuthorityConnectionOptions.SectionName));
|
||||
|
||||
// Register effective claims store
|
||||
services.AddSingleton<IEffectiveClaimsStore, EffectiveClaimsStore>();
|
||||
|
||||
// Register HTTP client for Authority
|
||||
services.AddHttpClient<IAuthorityClaimsProvider, HttpAuthorityClaimsProvider>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
|
||||
// Register background service for claims refresh
|
||||
services.AddHostedService<AuthorityClaimsRefreshService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds Authority integration services with custom options.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configure">Action to configure Authority options.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddAuthorityIntegration(
|
||||
this IServiceCollection services,
|
||||
Action<AuthorityConnectionOptions>? configure = null)
|
||||
{
|
||||
// Register options
|
||||
if (configure != null)
|
||||
{
|
||||
services.Configure(configure);
|
||||
}
|
||||
else
|
||||
{
|
||||
services.AddOptions<AuthorityConnectionOptions>();
|
||||
}
|
||||
|
||||
// Register effective claims store
|
||||
services.AddSingleton<IEffectiveClaimsStore, EffectiveClaimsStore>();
|
||||
|
||||
// Register HTTP client for Authority
|
||||
services.AddHttpClient<IAuthorityClaimsProvider, HttpAuthorityClaimsProvider>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
|
||||
// Register background service for claims refresh
|
||||
services.AddHostedService<AuthorityClaimsRefreshService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a no-op Authority integration (no external Authority).
|
||||
/// Claims are only from microservices.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddNoOpAuthorityIntegration(this IServiceCollection services)
|
||||
{
|
||||
services.Configure<AuthorityConnectionOptions>(options => options.Enabled = false);
|
||||
services.AddSingleton<IEffectiveClaimsStore, EffectiveClaimsStore>();
|
||||
services.AddSingleton<IAuthorityClaimsProvider, NoOpAuthorityClaimsProvider>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A no-op Authority claims provider that returns empty overrides.
|
||||
/// </summary>
|
||||
internal sealed class NoOpAuthorityClaimsProvider : IAuthorityClaimsProvider
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public bool IsAvailable => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
#pragma warning disable CS0067 // Event is never used (expected for no-op implementation)
|
||||
public event EventHandler<ClaimsOverrideChangedEventArgs>? OverridesChanged;
|
||||
#pragma warning restore CS0067
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyDictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>> GetOverridesAsync(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyDictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>>(
|
||||
new Dictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
namespace StellaOps.Router.Gateway.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory store for effective claims.
|
||||
/// Merges microservice defaults with Authority overrides.
|
||||
/// </summary>
|
||||
internal sealed class EffectiveClaimsStore : IEffectiveClaimsStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<EndpointKey, IReadOnlyList<ClaimRequirement>> _microserviceClaims = new();
|
||||
private readonly ConcurrentDictionary<EndpointKey, IReadOnlyList<ClaimRequirement>> _authorityClaims = new();
|
||||
private readonly ILogger<EffectiveClaimsStore> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="EffectiveClaimsStore"/> class.
|
||||
/// </summary>
|
||||
public EffectiveClaimsStore(ILogger<EffectiveClaimsStore> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<ClaimRequirement> GetEffectiveClaims(string serviceName, string method, string path)
|
||||
{
|
||||
var key = EndpointKey.Create(serviceName, method, path);
|
||||
|
||||
// Authority takes precedence
|
||||
if (_authorityClaims.TryGetValue(key, out var authorityClaims))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Using Authority claims for {Endpoint}: {ClaimCount} claims",
|
||||
key,
|
||||
authorityClaims.Count);
|
||||
return authorityClaims;
|
||||
}
|
||||
|
||||
// Fall back to microservice defaults
|
||||
if (_microserviceClaims.TryGetValue(key, out var msClaims))
|
||||
{
|
||||
return msClaims;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void UpdateFromMicroservice(string serviceName, IReadOnlyList<EndpointDescriptor> endpoints)
|
||||
{
|
||||
foreach (var endpoint in endpoints)
|
||||
{
|
||||
var key = EndpointKey.Create(serviceName, endpoint.Method, endpoint.Path);
|
||||
var claims = endpoint.RequiringClaims ?? [];
|
||||
|
||||
if (claims.Count > 0)
|
||||
{
|
||||
_microserviceClaims[key] = claims;
|
||||
_logger.LogDebug(
|
||||
"Registered {ClaimCount} claims from microservice for {Endpoint}",
|
||||
claims.Count,
|
||||
key);
|
||||
}
|
||||
else
|
||||
{
|
||||
_microserviceClaims.TryRemove(key, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void UpdateFromAuthority(IReadOnlyDictionary<EndpointKey, IReadOnlyList<ClaimRequirement>> overrides)
|
||||
{
|
||||
// Clear previous Authority claims
|
||||
_authorityClaims.Clear();
|
||||
|
||||
// Add new Authority claims
|
||||
foreach (var (key, claims) in overrides)
|
||||
{
|
||||
if (claims.Count > 0)
|
||||
{
|
||||
_authorityClaims[key] = claims;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Updated Authority claims: {EndpointCount} endpoints with overrides",
|
||||
overrides.Count);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void RemoveService(string serviceName)
|
||||
{
|
||||
var normalizedServiceName = serviceName.ToLowerInvariant();
|
||||
var keysToRemove = _microserviceClaims.Keys
|
||||
.Where(k => k.ServiceName == normalizedServiceName)
|
||||
.ToList();
|
||||
|
||||
foreach (var key in keysToRemove)
|
||||
{
|
||||
_microserviceClaims.TryRemove(key, out _);
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Removed {Count} endpoint claims for service {ServiceName}",
|
||||
keysToRemove.Count,
|
||||
serviceName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace StellaOps.Router.Gateway.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// Key for identifying an endpoint by service name, method, and path.
|
||||
/// </summary>
|
||||
/// <param name="ServiceName">The name of the service.</param>
|
||||
/// <param name="Method">The HTTP method.</param>
|
||||
/// <param name="Path">The path template.</param>
|
||||
public readonly record struct EndpointKey(string ServiceName, string Method, string Path)
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates an endpoint key with normalized values.
|
||||
/// </summary>
|
||||
public static EndpointKey Create(string serviceName, string method, string path)
|
||||
{
|
||||
return new EndpointKey(
|
||||
serviceName.ToLowerInvariant(),
|
||||
method.ToUpperInvariant(),
|
||||
path.ToLowerInvariant());
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string ToString() => $"{ServiceName}:{Method} {Path}";
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
namespace StellaOps.Router.Gateway.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// Fetches claims overrides from the Authority service via HTTP.
|
||||
/// </summary>
|
||||
internal sealed class HttpAuthorityClaimsProvider : IAuthorityClaimsProvider
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly AuthorityConnectionOptions _options;
|
||||
private readonly ILogger<HttpAuthorityClaimsProvider> _logger;
|
||||
private volatile bool _isAvailable;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="HttpAuthorityClaimsProvider"/> class.
|
||||
/// </summary>
|
||||
public HttpAuthorityClaimsProvider(
|
||||
HttpClient httpClient,
|
||||
IOptions<AuthorityConnectionOptions> options,
|
||||
ILogger<HttpAuthorityClaimsProvider> logger)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsAvailable => _isAvailable;
|
||||
|
||||
/// <inheritdoc />
|
||||
public event EventHandler<ClaimsOverrideChangedEventArgs>? OverridesChanged;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyDictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>> GetOverridesAsync(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.AuthorityUrl))
|
||||
{
|
||||
_logger.LogDebug("Authority URL not configured, returning empty overrides");
|
||||
_isAvailable = false;
|
||||
return new Dictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var url = $"{_options.AuthorityUrl.TrimEnd('/')}/api/v1/claims/overrides";
|
||||
|
||||
_logger.LogDebug("Fetching claims overrides from {Url}", url);
|
||||
|
||||
var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var overrideResponse = await response.Content.ReadFromJsonAsync<ClaimsOverrideResponse>(
|
||||
JsonOptions,
|
||||
cancellationToken);
|
||||
|
||||
if (overrideResponse?.Overrides == null)
|
||||
{
|
||||
_isAvailable = true;
|
||||
return new Dictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>();
|
||||
}
|
||||
|
||||
var result = new Dictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>();
|
||||
foreach (var entry in overrideResponse.Overrides)
|
||||
{
|
||||
var key = EndpointKey.Create(entry.ServiceName, entry.Method, entry.Path);
|
||||
var claims = entry.RequiringClaims
|
||||
.Select(c => new ClaimRequirement { Type = c.Type, Value = c.Value })
|
||||
.ToList();
|
||||
result[key] = claims;
|
||||
}
|
||||
|
||||
_isAvailable = true;
|
||||
_logger.LogInformation(
|
||||
"Fetched {Count} claims overrides from Authority",
|
||||
result.Count);
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
|
||||
{
|
||||
_isAvailable = false;
|
||||
_logger.LogWarning(ex, "Failed to fetch claims overrides from Authority");
|
||||
return new Dictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raises the <see cref="OverridesChanged"/> event.
|
||||
/// </summary>
|
||||
internal void RaiseOverridesChanged(IReadOnlyDictionary<EndpointKey, IReadOnlyList<ClaimRequirement>> overrides)
|
||||
{
|
||||
OverridesChanged?.Invoke(this, new ClaimsOverrideChangedEventArgs { Overrides = overrides });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DTO for claims override response from Authority.
|
||||
/// </summary>
|
||||
private sealed class ClaimsOverrideResponse
|
||||
{
|
||||
public List<ClaimsOverrideEntry> Overrides { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DTO for a single claims override entry.
|
||||
/// </summary>
|
||||
private sealed class ClaimsOverrideEntry
|
||||
{
|
||||
public string ServiceName { get; set; } = string.Empty;
|
||||
public string Method { get; set; } = string.Empty;
|
||||
public string Path { get; set; } = string.Empty;
|
||||
public List<ClaimRequirementDto> RequiringClaims { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DTO for a claim requirement.
|
||||
/// </summary>
|
||||
private sealed class ClaimRequirementDto
|
||||
{
|
||||
public string Type { get; set; } = string.Empty;
|
||||
public string? Value { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
namespace StellaOps.Router.Gateway.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// Provides claims overrides from the central Authority service.
|
||||
/// </summary>
|
||||
public interface IAuthorityClaimsProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets all claims overrides from Authority.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A dictionary of endpoint keys to claim requirements.</returns>
|
||||
Task<IReadOnlyDictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>> GetOverridesAsync(
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the Authority is currently available.
|
||||
/// </summary>
|
||||
bool IsAvailable { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Occurs when claims overrides change.
|
||||
/// </summary>
|
||||
event EventHandler<ClaimsOverrideChangedEventArgs>? OverridesChanged;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event arguments for claims override changes.
|
||||
/// </summary>
|
||||
public sealed class ClaimsOverrideChangedEventArgs : EventArgs
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the updated claims overrides.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<EndpointKey, IReadOnlyList<ClaimRequirement>> Overrides { get; init; }
|
||||
= new Dictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>();
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
namespace StellaOps.Router.Gateway.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// Stores and retrieves effective claims for endpoints.
|
||||
/// Handles merging of microservice defaults with Authority overrides.
|
||||
/// </summary>
|
||||
public interface IEffectiveClaimsStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the effective claims for an endpoint.
|
||||
/// Authority overrides take precedence over microservice defaults.
|
||||
/// </summary>
|
||||
/// <param name="serviceName">The service name.</param>
|
||||
/// <param name="method">The HTTP method.</param>
|
||||
/// <param name="path">The path template.</param>
|
||||
/// <returns>The effective claims for the endpoint.</returns>
|
||||
IReadOnlyList<ClaimRequirement> GetEffectiveClaims(string serviceName, string method, string path);
|
||||
|
||||
/// <summary>
|
||||
/// Updates claims from a microservice's HELLO message.
|
||||
/// </summary>
|
||||
/// <param name="serviceName">The service name.</param>
|
||||
/// <param name="endpoints">The endpoint descriptors with claims.</param>
|
||||
void UpdateFromMicroservice(string serviceName, IReadOnlyList<EndpointDescriptor> endpoints);
|
||||
|
||||
/// <summary>
|
||||
/// Updates claims from Authority overrides.
|
||||
/// </summary>
|
||||
/// <param name="overrides">The Authority claims overrides.</param>
|
||||
void UpdateFromAuthority(IReadOnlyDictionary<EndpointKey, IReadOnlyList<ClaimRequirement>> overrides);
|
||||
|
||||
/// <summary>
|
||||
/// Removes all claims for a service.
|
||||
/// Called when a microservice disconnects.
|
||||
/// </summary>
|
||||
/// <param name="serviceName">The service name.</param>
|
||||
void RemoveService(string serviceName);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace StellaOps.Router.Gateway.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for health monitoring.
|
||||
/// </summary>
|
||||
public sealed class HealthOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "Router:Health";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the threshold after which a connection is considered stale (no heartbeat).
|
||||
/// Default: 30 seconds.
|
||||
/// </summary>
|
||||
public TimeSpan StaleThreshold { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the threshold after which a connection is considered degraded.
|
||||
/// Default: 15 seconds.
|
||||
/// </summary>
|
||||
public TimeSpan DegradedThreshold { get; set; } = TimeSpan.FromSeconds(15);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the interval at which to check for stale connections.
|
||||
/// Default: 5 seconds.
|
||||
/// </summary>
|
||||
public TimeSpan CheckInterval { get; set; } = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the number of ping measurements to keep for averaging.
|
||||
/// Default: 10.
|
||||
/// </summary>
|
||||
public int PingHistorySize { get; set; } = 10;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.Router.Gateway.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Static configuration for a router gateway node.
|
||||
/// </summary>
|
||||
public sealed class RouterNodeConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name for binding.
|
||||
/// </summary>
|
||||
public const string SectionName = "Router:Node";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the region where this gateway is deployed (e.g., "eu1").
|
||||
/// Routing decisions use this value; it is never derived from headers or URLs.
|
||||
/// </summary>
|
||||
[Required(ErrorMessage = "Region is required for gateway routing")]
|
||||
public string Region { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the unique identifier for this gateway node (e.g., "gw-eu1-01").
|
||||
/// </summary>
|
||||
public string NodeId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the environment name (e.g., "prod", "staging", "dev").
|
||||
/// </summary>
|
||||
public string Environment { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the neighbor regions for fallback routing, in order of preference.
|
||||
/// </summary>
|
||||
public List<string> NeighborRegions { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Validates the configuration.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">Thrown when configuration is invalid.</exception>
|
||||
public void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Region))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"{SectionName}:Region is required. Gateway cannot start without a region assignment.");
|
||||
}
|
||||
|
||||
// Generate NodeId if not provided
|
||||
if (string.IsNullOrWhiteSpace(NodeId))
|
||||
{
|
||||
NodeId = $"gw-{Region}-{Guid.NewGuid().ToString("N")[..8]}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
namespace StellaOps.Router.Gateway.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Tie-breaker mode for routing when multiple instances have equal priority.
|
||||
/// </summary>
|
||||
public enum TieBreakerMode
|
||||
{
|
||||
/// <summary>
|
||||
/// Select randomly among tied instances.
|
||||
/// </summary>
|
||||
Random,
|
||||
|
||||
/// <summary>
|
||||
/// Rotate through tied instances in order.
|
||||
/// </summary>
|
||||
RoundRobin
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for routing behavior.
|
||||
/// </summary>
|
||||
public sealed class RoutingOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name for binding.
|
||||
/// </summary>
|
||||
public const string SectionName = "Router:Routing";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the default version to use when no version is specified in the request.
|
||||
/// If null, requests without version specification will match any available version.
|
||||
/// </summary>
|
||||
public string? DefaultVersion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to enable strict version matching.
|
||||
/// When true, requests must specify an exact version.
|
||||
/// When false, requests can match compatible versions.
|
||||
/// </summary>
|
||||
public bool StrictVersionMatching { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the timeout for routing decisions in milliseconds.
|
||||
/// </summary>
|
||||
public int RoutingTimeoutMs { get; set; } = 30000;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to prefer local region instances over neighbor regions.
|
||||
/// </summary>
|
||||
public bool PreferLocalRegion { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to allow routing to degraded instances when no healthy instances are available.
|
||||
/// </summary>
|
||||
public bool AllowDegradedInstances { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the tie-breaker mode when multiple instances have equal priority.
|
||||
/// </summary>
|
||||
public TieBreakerMode TieBreaker { get; set; } = TieBreakerMode.Random;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the ping tolerance in milliseconds for considering instances "tied".
|
||||
/// Instances within this tolerance of each other are considered to have equal latency.
|
||||
/// </summary>
|
||||
public double PingToleranceMs { get; set; } = 0.1;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using StellaOps.Router.Gateway.Configuration;
|
||||
using StellaOps.Router.Gateway.Middleware;
|
||||
using StellaOps.Router.Gateway.OpenApi;
|
||||
using StellaOps.Router.Gateway.Routing;
|
||||
using StellaOps.Router.Gateway.Services;
|
||||
using StellaOps.Router.Gateway.State;
|
||||
using StellaOps.Router.Transport.InMemory;
|
||||
|
||||
namespace StellaOps.Router.Gateway.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering router gateway services.
|
||||
/// </summary>
|
||||
public static class RouterServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds router gateway services to the service collection.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configuration">The configuration.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddRouterGateway(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
// Bind configuration options
|
||||
services.Configure<RouterNodeConfig>(
|
||||
configuration.GetSection(RouterNodeConfig.SectionName));
|
||||
services.Configure<RoutingOptions>(
|
||||
configuration.GetSection(RoutingOptions.SectionName));
|
||||
services.Configure<HealthOptions>(
|
||||
configuration.GetSection(HealthOptions.SectionName));
|
||||
services.Configure<PayloadLimits>(
|
||||
configuration.GetSection("Router:PayloadLimits"));
|
||||
|
||||
// Register routing state as singleton (shared across all requests)
|
||||
services.AddSingleton<IGlobalRoutingState, InMemoryRoutingState>();
|
||||
|
||||
// Register routing plugin
|
||||
services.AddSingleton<IRoutingPlugin, DefaultRoutingPlugin>();
|
||||
|
||||
// Register payload tracker
|
||||
services.AddSingleton<IPayloadTracker, PayloadTracker>();
|
||||
|
||||
// Register InMemory transport (for development/testing)
|
||||
services.AddInMemoryTransport();
|
||||
|
||||
// Register connection manager as hosted service
|
||||
services.AddHostedService<ConnectionManager>();
|
||||
|
||||
// Register health monitor as hosted service
|
||||
services.AddHostedService<HealthMonitorService>();
|
||||
|
||||
// Register OpenAPI aggregation services
|
||||
services.Configure<OpenApiAggregationOptions>(
|
||||
configuration.GetSection(OpenApiAggregationOptions.SectionName));
|
||||
services.AddSingleton<IOpenApiDocumentGenerator, OpenApiDocumentGenerator>();
|
||||
services.AddSingleton<IRouterOpenApiDocumentCache, RouterOpenApiDocumentCache>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds router gateway services with custom options.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configureNode">Action to configure router node options.</param>
|
||||
/// <param name="configureRouting">Action to configure routing options.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddRouterGateway(
|
||||
this IServiceCollection services,
|
||||
Action<RouterNodeConfig>? configureNode = null,
|
||||
Action<RoutingOptions>? configureRouting = null)
|
||||
{
|
||||
// Ensure default options are registered even if no configuration action provided
|
||||
services.AddOptions<RouterNodeConfig>();
|
||||
services.AddOptions<RoutingOptions>();
|
||||
services.AddOptions<HealthOptions>();
|
||||
services.AddOptions<PayloadLimits>();
|
||||
|
||||
// Configure options via actions
|
||||
if (configureNode is not null)
|
||||
{
|
||||
services.Configure(configureNode);
|
||||
}
|
||||
|
||||
if (configureRouting is not null)
|
||||
{
|
||||
services.Configure(configureRouting);
|
||||
}
|
||||
|
||||
// Register routing state as singleton (shared across all requests)
|
||||
services.AddSingleton<IGlobalRoutingState, InMemoryRoutingState>();
|
||||
|
||||
// Register routing plugin
|
||||
services.AddSingleton<IRoutingPlugin, DefaultRoutingPlugin>();
|
||||
|
||||
// Register payload tracker
|
||||
services.AddSingleton<IPayloadTracker, PayloadTracker>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds router gateway services with minimal defaults.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddRouterGatewayCore(this IServiceCollection services)
|
||||
{
|
||||
// Register options with defaults
|
||||
services.AddOptions<RouterNodeConfig>();
|
||||
services.AddOptions<RoutingOptions>();
|
||||
services.AddOptions<HealthOptions>();
|
||||
services.AddOptions<PayloadLimits>();
|
||||
|
||||
// Register routing state as singleton (shared across all requests)
|
||||
services.AddSingleton<IGlobalRoutingState, InMemoryRoutingState>();
|
||||
|
||||
// Register routing plugin
|
||||
services.AddSingleton<IRoutingPlugin, DefaultRoutingPlugin>();
|
||||
|
||||
// Register payload tracker
|
||||
services.AddSingleton<IPayloadTracker, PayloadTracker>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
6
src/__Libraries/StellaOps.Router.Gateway/GlobalUsings.cs
Normal file
6
src/__Libraries/StellaOps.Router.Gateway/GlobalUsings.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
global using Microsoft.AspNetCore.Builder;
|
||||
global using Microsoft.AspNetCore.Http;
|
||||
global using Microsoft.AspNetCore.Routing;
|
||||
global using Microsoft.Extensions.Configuration;
|
||||
global using Microsoft.Extensions.DependencyInjection;
|
||||
global using Microsoft.Extensions.Logging;
|
||||
@@ -0,0 +1,135 @@
|
||||
namespace StellaOps.Router.Gateway.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// A stream wrapper that counts bytes read and enforces a limit.
|
||||
/// </summary>
|
||||
public sealed class ByteCountingStream : Stream
|
||||
{
|
||||
private readonly Stream _inner;
|
||||
private readonly long _limit;
|
||||
private readonly Action? _onLimitExceeded;
|
||||
private long _bytesRead;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ByteCountingStream"/> class.
|
||||
/// </summary>
|
||||
/// <param name="inner">The inner stream to wrap.</param>
|
||||
/// <param name="limit">The maximum number of bytes that can be read.</param>
|
||||
/// <param name="onLimitExceeded">Optional callback invoked when the limit is exceeded.</param>
|
||||
public ByteCountingStream(Stream inner, long limit, Action? onLimitExceeded = null)
|
||||
{
|
||||
_inner = inner;
|
||||
_limit = limit;
|
||||
_onLimitExceeded = onLimitExceeded;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of bytes read from this stream.
|
||||
/// </summary>
|
||||
public long BytesRead => Interlocked.Read(ref _bytesRead);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool CanRead => _inner.CanRead;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool CanSeek => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool CanWrite => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override long Length => _inner.Length;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override long Position
|
||||
{
|
||||
get => _inner.Position;
|
||||
set => throw new NotSupportedException("Seeking not supported on ByteCountingStream.");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Flush() => _inner.Flush();
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task FlushAsync(CancellationToken cancellationToken) =>
|
||||
_inner.FlushAsync(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override int Read(byte[] buffer, int offset, int count)
|
||||
{
|
||||
var read = _inner.Read(buffer, offset, count);
|
||||
CheckLimit(read);
|
||||
return read;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
|
||||
{
|
||||
var read = await _inner.ReadAsync(buffer, offset, count, cancellationToken);
|
||||
CheckLimit(read);
|
||||
return read;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var read = await _inner.ReadAsync(buffer, cancellationToken);
|
||||
CheckLimit(read);
|
||||
return read;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override long Seek(long offset, SeekOrigin origin)
|
||||
{
|
||||
throw new NotSupportedException("Seeking not supported on ByteCountingStream.");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void SetLength(long value)
|
||||
{
|
||||
throw new NotSupportedException("Setting length not supported on ByteCountingStream.");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Write(byte[] buffer, int offset, int count)
|
||||
{
|
||||
throw new NotSupportedException("Writing not supported on ByteCountingStream.");
|
||||
}
|
||||
|
||||
private void CheckLimit(int bytesJustRead)
|
||||
{
|
||||
if (bytesJustRead <= 0) return;
|
||||
|
||||
var newTotal = Interlocked.Add(ref _bytesRead, bytesJustRead);
|
||||
if (newTotal > _limit)
|
||||
{
|
||||
_onLimitExceeded?.Invoke();
|
||||
throw new PayloadLimitExceededException(newTotal, _limit);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (!_disposed && disposing)
|
||||
{
|
||||
_inner.Dispose();
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask DisposeAsync()
|
||||
{
|
||||
if (!_disposed)
|
||||
{
|
||||
await _inner.DisposeAsync();
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
await base.DisposeAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
|
||||
namespace StellaOps.Router.Gateway.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves incoming HTTP requests to endpoint descriptors using the routing state.
|
||||
/// </summary>
|
||||
public sealed class EndpointResolutionMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="EndpointResolutionMiddleware"/> class.
|
||||
/// </summary>
|
||||
public EndpointResolutionMiddleware(RequestDelegate next)
|
||||
{
|
||||
_next = next;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invokes the middleware.
|
||||
/// </summary>
|
||||
public async Task Invoke(HttpContext context, IGlobalRoutingState routingState)
|
||||
{
|
||||
var method = context.Request.Method;
|
||||
var path = context.Request.Path.ToString();
|
||||
|
||||
var endpoint = routingState.ResolveEndpoint(method, path);
|
||||
if (endpoint is null)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
await context.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
error = "Endpoint not found",
|
||||
method,
|
||||
path
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
context.Items[RouterHttpContextKeys.EndpointDescriptor] = endpoint;
|
||||
await _next(context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace StellaOps.Router.Gateway.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when a payload limit is exceeded during streaming.
|
||||
/// </summary>
|
||||
public sealed class PayloadLimitExceededException : Exception
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PayloadLimitExceededException"/> class.
|
||||
/// </summary>
|
||||
/// <param name="bytesRead">The number of bytes read before the limit was exceeded.</param>
|
||||
/// <param name="limit">The limit that was exceeded.</param>
|
||||
public PayloadLimitExceededException(long bytesRead, long limit)
|
||||
: base($"Payload limit exceeded: {bytesRead} bytes read, limit is {limit} bytes")
|
||||
{
|
||||
BytesRead = bytesRead;
|
||||
Limit = limit;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of bytes read before the limit was exceeded.
|
||||
/// </summary>
|
||||
public long BytesRead { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the limit that was exceeded.
|
||||
/// </summary>
|
||||
public long Limit { get; }
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
namespace StellaOps.Router.Gateway.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Middleware that enforces payload limits per-request, per-connection, and aggregate.
|
||||
/// </summary>
|
||||
public sealed class PayloadLimitsMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly PayloadLimits _limits;
|
||||
private readonly ILogger<PayloadLimitsMiddleware> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PayloadLimitsMiddleware"/> class.
|
||||
/// </summary>
|
||||
public PayloadLimitsMiddleware(
|
||||
RequestDelegate next,
|
||||
IOptions<PayloadLimits> limits,
|
||||
ILogger<PayloadLimitsMiddleware> logger)
|
||||
{
|
||||
_next = next;
|
||||
_limits = limits.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invokes the middleware.
|
||||
/// </summary>
|
||||
public async Task Invoke(HttpContext context, IPayloadTracker tracker)
|
||||
{
|
||||
var connectionId = context.Connection.Id;
|
||||
var contentLength = context.Request.ContentLength ?? 0;
|
||||
|
||||
// Early rejection for known oversized Content-Length (LIM-002, LIM-003)
|
||||
if (context.Request.ContentLength.HasValue &&
|
||||
context.Request.ContentLength.Value > _limits.MaxRequestBytesPerCall)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Request rejected: Content-Length {ContentLength} exceeds per-call limit {Limit}. ConnectionId: {ConnectionId}",
|
||||
context.Request.ContentLength.Value,
|
||||
_limits.MaxRequestBytesPerCall,
|
||||
connectionId);
|
||||
|
||||
context.Response.StatusCode = StatusCodes.Status413PayloadTooLarge;
|
||||
await context.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
error = "Payload Too Large",
|
||||
maxBytes = _limits.MaxRequestBytesPerCall,
|
||||
contentLength = context.Request.ContentLength.Value
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to reserve capacity (checks aggregate and per-connection limits)
|
||||
if (!tracker.TryReserve(connectionId, contentLength))
|
||||
{
|
||||
// Check which limit was hit
|
||||
if (tracker.IsOverloaded)
|
||||
{
|
||||
// Aggregate limit exceeded (LIM-033)
|
||||
_logger.LogWarning(
|
||||
"Request rejected: Aggregate limit exceeded. Current inflight: {Current}, Limit: {Limit}. ConnectionId: {ConnectionId}",
|
||||
tracker.CurrentInflightBytes,
|
||||
_limits.MaxAggregateInflightBytes,
|
||||
connectionId);
|
||||
|
||||
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
|
||||
await context.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
error = "Service Overloaded",
|
||||
message = "Too many concurrent requests"
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
// Per-connection limit exceeded (LIM-022)
|
||||
_logger.LogWarning(
|
||||
"Request rejected: Per-connection limit exceeded. ConnectionId: {ConnectionId}, Current: {Current}, Limit: {Limit}",
|
||||
connectionId,
|
||||
tracker.GetConnectionInflightBytes(connectionId),
|
||||
_limits.MaxRequestBytesPerConnection);
|
||||
|
||||
context.Response.StatusCode = StatusCodes.Status429TooManyRequests;
|
||||
await context.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
error = "Too Many Requests",
|
||||
message = "Per-connection limit exceeded"
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Store the original body stream
|
||||
var originalBody = context.Request.Body;
|
||||
long actualBytesRead = 0;
|
||||
|
||||
try
|
||||
{
|
||||
// Wrap the request body with ByteCountingStream for streaming requests
|
||||
if (!context.Request.ContentLength.HasValue || context.Request.ContentLength.Value > 0)
|
||||
{
|
||||
var countingStream = new ByteCountingStream(
|
||||
originalBody,
|
||||
_limits.MaxRequestBytesPerCall,
|
||||
() =>
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Mid-stream limit exceeded. ConnectionId: {ConnectionId}, Limit: {Limit}",
|
||||
connectionId,
|
||||
_limits.MaxRequestBytesPerCall);
|
||||
});
|
||||
|
||||
context.Request.Body = countingStream;
|
||||
|
||||
// Store reference for later access to bytes read
|
||||
context.Items["PayloadLimits:CountingStream"] = countingStream;
|
||||
}
|
||||
|
||||
await _next(context);
|
||||
|
||||
// Get actual bytes read
|
||||
if (context.Items["PayloadLimits:CountingStream"] is ByteCountingStream cs)
|
||||
{
|
||||
actualBytesRead = cs.BytesRead;
|
||||
}
|
||||
}
|
||||
catch (PayloadLimitExceededException ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Payload limit exceeded mid-stream. ConnectionId: {ConnectionId}, BytesRead: {BytesRead}, Limit: {Limit}",
|
||||
connectionId,
|
||||
ex.BytesRead,
|
||||
ex.Limit);
|
||||
|
||||
// Only set response if not already started
|
||||
if (!context.Response.HasStarted)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status413PayloadTooLarge;
|
||||
await context.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
error = "Payload Too Large",
|
||||
maxBytes = _limits.MaxRequestBytesPerCall,
|
||||
bytesReceived = ex.BytesRead
|
||||
});
|
||||
}
|
||||
|
||||
actualBytesRead = ex.BytesRead;
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Restore original body stream
|
||||
context.Request.Body = originalBody;
|
||||
|
||||
// Release reserved capacity
|
||||
var bytesToRelease = actualBytesRead > 0 ? actualBytesRead : contentLength;
|
||||
tracker.Release(connectionId, bytesToRelease);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
namespace StellaOps.Router.Gateway.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Tracks payload bytes across requests, connections, and globally.
|
||||
/// </summary>
|
||||
public interface IPayloadTracker
|
||||
{
|
||||
/// <summary>
|
||||
/// Tries to reserve capacity for an estimated payload size.
|
||||
/// </summary>
|
||||
/// <param name="connectionId">The connection identifier.</param>
|
||||
/// <param name="estimatedBytes">The estimated bytes to reserve.</param>
|
||||
/// <returns>True if capacity was reserved; false if limits would be exceeded.</returns>
|
||||
bool TryReserve(string connectionId, long estimatedBytes);
|
||||
|
||||
/// <summary>
|
||||
/// Releases previously reserved capacity.
|
||||
/// </summary>
|
||||
/// <param name="connectionId">The connection identifier.</param>
|
||||
/// <param name="actualBytes">The actual bytes to release.</param>
|
||||
void Release(string connectionId, long actualBytes);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current total inflight bytes across all connections.
|
||||
/// </summary>
|
||||
long CurrentInflightBytes { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the system is overloaded.
|
||||
/// </summary>
|
||||
bool IsOverloaded { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current inflight bytes for a specific connection.
|
||||
/// </summary>
|
||||
/// <param name="connectionId">The connection identifier.</param>
|
||||
/// <returns>The current inflight bytes for the connection.</returns>
|
||||
long GetConnectionInflightBytes(string connectionId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IPayloadTracker"/>.
|
||||
/// </summary>
|
||||
public sealed class PayloadTracker : IPayloadTracker
|
||||
{
|
||||
private readonly PayloadLimits _limits;
|
||||
private readonly ILogger<PayloadTracker> _logger;
|
||||
private long _totalInflightBytes;
|
||||
private readonly ConcurrentDictionary<string, long> _perConnectionBytes = new();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PayloadTracker"/> class.
|
||||
/// </summary>
|
||||
public PayloadTracker(IOptions<PayloadLimits> limits, ILogger<PayloadTracker> logger)
|
||||
{
|
||||
_limits = limits.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public long CurrentInflightBytes => Interlocked.Read(ref _totalInflightBytes);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsOverloaded => CurrentInflightBytes > _limits.MaxAggregateInflightBytes;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryReserve(string connectionId, long estimatedBytes)
|
||||
{
|
||||
// Check aggregate limit
|
||||
var newTotal = Interlocked.Add(ref _totalInflightBytes, estimatedBytes);
|
||||
if (newTotal > _limits.MaxAggregateInflightBytes)
|
||||
{
|
||||
Interlocked.Add(ref _totalInflightBytes, -estimatedBytes);
|
||||
_logger.LogWarning(
|
||||
"Aggregate payload limit exceeded. Current: {Current}, Limit: {Limit}",
|
||||
newTotal - estimatedBytes,
|
||||
_limits.MaxAggregateInflightBytes);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check per-connection limit
|
||||
var connectionBytes = _perConnectionBytes.AddOrUpdate(
|
||||
connectionId,
|
||||
estimatedBytes,
|
||||
(_, current) => current + estimatedBytes);
|
||||
|
||||
if (connectionBytes > _limits.MaxRequestBytesPerConnection)
|
||||
{
|
||||
// Roll back
|
||||
_perConnectionBytes.AddOrUpdate(
|
||||
connectionId,
|
||||
0,
|
||||
(_, current) => current - estimatedBytes);
|
||||
Interlocked.Add(ref _totalInflightBytes, -estimatedBytes);
|
||||
|
||||
_logger.LogWarning(
|
||||
"Per-connection payload limit exceeded for {ConnectionId}. Current: {Current}, Limit: {Limit}",
|
||||
connectionId,
|
||||
connectionBytes - estimatedBytes,
|
||||
_limits.MaxRequestBytesPerConnection);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Release(string connectionId, long actualBytes)
|
||||
{
|
||||
Interlocked.Add(ref _totalInflightBytes, -actualBytes);
|
||||
|
||||
_perConnectionBytes.AddOrUpdate(
|
||||
connectionId,
|
||||
0,
|
||||
(_, current) => Math.Max(0, current - actualBytes));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public long GetConnectionInflightBytes(string connectionId)
|
||||
{
|
||||
return _perConnectionBytes.TryGetValue(connectionId, out var bytes) ? bytes : 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using StellaOps.Router.Gateway.Configuration;
|
||||
|
||||
namespace StellaOps.Router.Gateway.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Makes routing decisions for resolved endpoints.
|
||||
/// </summary>
|
||||
public sealed class RoutingDecisionMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RoutingDecisionMiddleware"/> class.
|
||||
/// </summary>
|
||||
public RoutingDecisionMiddleware(RequestDelegate next)
|
||||
{
|
||||
_next = next;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invokes the middleware.
|
||||
/// </summary>
|
||||
public async Task Invoke(
|
||||
HttpContext context,
|
||||
IRoutingPlugin routingPlugin,
|
||||
IGlobalRoutingState routingState,
|
||||
IOptions<RouterNodeConfig> gatewayConfig,
|
||||
IOptions<RoutingOptions> routingOptions)
|
||||
{
|
||||
var endpoint = context.Items[RouterHttpContextKeys.EndpointDescriptor] as EndpointDescriptor;
|
||||
if (endpoint is null)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
|
||||
await context.Response.WriteAsJsonAsync(new { error = "Endpoint descriptor missing" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Build routing context
|
||||
var availableConnections = routingState.GetConnectionsFor(
|
||||
endpoint.ServiceName,
|
||||
endpoint.Version,
|
||||
endpoint.Method,
|
||||
endpoint.Path);
|
||||
|
||||
var headers = context.Request.Headers
|
||||
.ToDictionary(h => h.Key, h => h.Value.ToString());
|
||||
|
||||
var routingContext = new RoutingContext
|
||||
{
|
||||
Method = context.Request.Method,
|
||||
Path = context.Request.Path.ToString(),
|
||||
Headers = headers,
|
||||
Endpoint = endpoint,
|
||||
AvailableConnections = availableConnections,
|
||||
GatewayRegion = gatewayConfig.Value.Region,
|
||||
RequestedVersion = ExtractVersionFromRequest(context, routingOptions.Value),
|
||||
CancellationToken = context.RequestAborted
|
||||
};
|
||||
|
||||
var decision = await routingPlugin.ChooseInstanceAsync(
|
||||
routingContext,
|
||||
context.RequestAborted);
|
||||
|
||||
if (decision is null)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
|
||||
await context.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
error = "No instances available",
|
||||
service = endpoint.ServiceName,
|
||||
version = endpoint.Version
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
context.Items[RouterHttpContextKeys.RoutingDecision] = decision;
|
||||
await _next(context);
|
||||
}
|
||||
|
||||
private static string? ExtractVersionFromRequest(HttpContext context, RoutingOptions options)
|
||||
{
|
||||
// Check for version in Accept header: Accept: application/vnd.stellaops.v1+json
|
||||
var acceptHeader = context.Request.Headers.Accept.FirstOrDefault();
|
||||
if (!string.IsNullOrEmpty(acceptHeader))
|
||||
{
|
||||
var versionMatch = System.Text.RegularExpressions.Regex.Match(
|
||||
acceptHeader,
|
||||
@"application/vnd\.stellaops\.v(\d+(?:\.\d+)*)\+json");
|
||||
if (versionMatch.Success)
|
||||
{
|
||||
return versionMatch.Groups[1].Value;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for X-Api-Version header
|
||||
var versionHeader = context.Request.Headers["X-Api-Version"].FirstOrDefault();
|
||||
if (!string.IsNullOrEmpty(versionHeader))
|
||||
{
|
||||
return versionHeader;
|
||||
}
|
||||
|
||||
// Fall back to default version from options
|
||||
return options.DefaultVersion;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
using StellaOps.Router.Common.Frames;
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
namespace StellaOps.Router.Gateway.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Dispatches HTTP requests to microservices via the transport layer.
|
||||
/// </summary>
|
||||
public sealed class TransportDispatchMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly ILogger<TransportDispatchMiddleware> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Tracks cancelled request IDs to ignore late responses.
|
||||
/// Keys expire after 60 seconds to prevent memory leaks.
|
||||
/// </summary>
|
||||
private static readonly ConcurrentDictionary<string, DateTimeOffset> CancelledRequests = new();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TransportDispatchMiddleware"/> class.
|
||||
/// </summary>
|
||||
public TransportDispatchMiddleware(RequestDelegate next, ILogger<TransportDispatchMiddleware> logger)
|
||||
{
|
||||
_next = next;
|
||||
_logger = logger;
|
||||
|
||||
// Start background cleanup task for expired cancelled request entries
|
||||
_ = Task.Run(CleanupExpiredCancelledRequestsAsync);
|
||||
}
|
||||
|
||||
private static async Task CleanupExpiredCancelledRequestsAsync()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(30));
|
||||
|
||||
var cutoff = DateTimeOffset.UtcNow.AddSeconds(-60);
|
||||
foreach (var kvp in CancelledRequests)
|
||||
{
|
||||
if (kvp.Value < cutoff)
|
||||
{
|
||||
CancelledRequests.TryRemove(kvp.Key, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void MarkCancelled(string requestId)
|
||||
{
|
||||
CancelledRequests[requestId] = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
private static bool IsCancelled(string requestId)
|
||||
{
|
||||
return CancelledRequests.ContainsKey(requestId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invokes the middleware.
|
||||
/// </summary>
|
||||
public async Task Invoke(
|
||||
HttpContext context,
|
||||
ITransportClient transportClient,
|
||||
IGlobalRoutingState routingState)
|
||||
{
|
||||
var decision = context.Items[RouterHttpContextKeys.RoutingDecision] as RoutingDecision;
|
||||
if (decision is null)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
|
||||
await context.Response.WriteAsJsonAsync(new { error = "Routing decision missing" });
|
||||
return;
|
||||
}
|
||||
|
||||
var requestId = Guid.NewGuid().ToString("N");
|
||||
|
||||
// Extract headers (exclude some internal headers)
|
||||
var headers = context.Request.Headers
|
||||
.Where(h => !h.Key.StartsWith(":", StringComparison.Ordinal))
|
||||
.ToDictionary(
|
||||
h => h.Key,
|
||||
h => h.Value.ToString());
|
||||
|
||||
// For streaming endpoints, use streaming dispatch
|
||||
if (decision.Endpoint.SupportsStreaming)
|
||||
{
|
||||
await DispatchStreamingAsync(context, transportClient, routingState, decision, requestId, headers);
|
||||
return;
|
||||
}
|
||||
|
||||
// Read request body (buffered)
|
||||
byte[] bodyBytes;
|
||||
using (var ms = new MemoryStream())
|
||||
{
|
||||
await context.Request.Body.CopyToAsync(ms, context.RequestAborted);
|
||||
bodyBytes = ms.ToArray();
|
||||
}
|
||||
|
||||
// Build request frame
|
||||
var requestFrame = new RequestFrame
|
||||
{
|
||||
RequestId = requestId,
|
||||
CorrelationId = context.TraceIdentifier,
|
||||
Method = context.Request.Method,
|
||||
Path = context.Request.Path.ToString() + context.Request.QueryString.ToString(),
|
||||
Headers = headers,
|
||||
Payload = bodyBytes,
|
||||
TimeoutSeconds = (int)decision.EffectiveTimeout.TotalSeconds,
|
||||
SupportsStreaming = false
|
||||
};
|
||||
|
||||
var frame = FrameConverter.ToFrame(requestFrame);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Dispatching {Method} {Path} to {ServiceName}/{Version} via {TransportType}",
|
||||
requestFrame.Method,
|
||||
requestFrame.Path,
|
||||
decision.Connection.Instance.ServiceName,
|
||||
decision.Connection.Instance.Version,
|
||||
decision.TransportType);
|
||||
|
||||
// Create linked cancellation token with timeout
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(context.RequestAborted);
|
||||
timeoutCts.CancelAfter(decision.EffectiveTimeout);
|
||||
|
||||
// Register client disconnect handler to send CANCEL
|
||||
var requestIdGuid = Guid.TryParse(requestId, out var parsed) ? parsed : Guid.NewGuid();
|
||||
using var clientDisconnectRegistration = context.RequestAborted.Register(() =>
|
||||
{
|
||||
// Mark as cancelled to ignore late responses
|
||||
MarkCancelled(requestId);
|
||||
|
||||
// Send CANCEL frame (fire and forget)
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await transportClient.SendCancelAsync(
|
||||
decision.Connection,
|
||||
requestIdGuid,
|
||||
CancelReasons.ClientDisconnected);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Sent CANCEL for request {RequestId} due to client disconnect",
|
||||
requestId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Failed to send CANCEL for request {RequestId} on client disconnect",
|
||||
requestId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Frame responseFrame;
|
||||
var startTimestamp = Stopwatch.GetTimestamp();
|
||||
try
|
||||
{
|
||||
responseFrame = await transportClient.SendRequestAsync(
|
||||
decision.Connection,
|
||||
frame,
|
||||
decision.EffectiveTimeout,
|
||||
timeoutCts.Token);
|
||||
|
||||
// Record ping latency and update connection's average
|
||||
var elapsed = Stopwatch.GetElapsedTime(startTimestamp);
|
||||
UpdateConnectionPing(routingState, decision.Connection.ConnectionId, elapsed.TotalMilliseconds);
|
||||
}
|
||||
catch (OperationCanceledException) when (!context.RequestAborted.IsCancellationRequested)
|
||||
{
|
||||
// Internal timeout (not client disconnect)
|
||||
_logger.LogWarning(
|
||||
"Request {RequestId} to {ServiceName} timed out after {Timeout}",
|
||||
requestId,
|
||||
decision.Connection.Instance.ServiceName,
|
||||
decision.EffectiveTimeout);
|
||||
|
||||
// Mark as cancelled to ignore late responses
|
||||
MarkCancelled(requestId);
|
||||
|
||||
// Send cancel to microservice
|
||||
try
|
||||
{
|
||||
await transportClient.SendCancelAsync(
|
||||
decision.Connection,
|
||||
requestIdGuid,
|
||||
CancelReasons.Timeout);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to send cancel for request {RequestId}", requestId);
|
||||
}
|
||||
|
||||
context.Response.StatusCode = StatusCodes.Status504GatewayTimeout;
|
||||
await context.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
error = "Upstream timeout",
|
||||
service = decision.Connection.Instance.ServiceName,
|
||||
timeout = decision.EffectiveTimeout.TotalSeconds
|
||||
});
|
||||
return;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Client disconnected - cancel already sent via registration above
|
||||
MarkCancelled(requestId);
|
||||
_logger.LogDebug("Client disconnected, request {RequestId} cancelled", requestId);
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"Error dispatching request {RequestId} to {ServiceName}",
|
||||
requestId,
|
||||
decision.Connection.Instance.ServiceName);
|
||||
|
||||
context.Response.StatusCode = StatusCodes.Status502BadGateway;
|
||||
await context.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
error = "Upstream error",
|
||||
message = ex.Message
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if request was cancelled while waiting for response
|
||||
if (IsCancelled(requestId))
|
||||
{
|
||||
_logger.LogDebug("Ignoring late response for cancelled request {RequestId}", requestId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse response
|
||||
var response = FrameConverter.ToResponseFrame(responseFrame);
|
||||
if (response is null)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Invalid response frame from {ServiceName} for request {RequestId}",
|
||||
decision.Connection.Instance.ServiceName,
|
||||
requestId);
|
||||
|
||||
context.Response.StatusCode = StatusCodes.Status502BadGateway;
|
||||
await context.Response.WriteAsJsonAsync(new { error = "Invalid upstream response" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Map response to HTTP
|
||||
context.Response.StatusCode = response.StatusCode;
|
||||
|
||||
// Copy response headers
|
||||
foreach (var (key, value) in response.Headers)
|
||||
{
|
||||
// Skip some headers that shouldn't be copied
|
||||
if (key.Equals("Transfer-Encoding", StringComparison.OrdinalIgnoreCase) ||
|
||||
key.Equals("Content-Length", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
context.Response.Headers[key] = value;
|
||||
}
|
||||
|
||||
// Write response body
|
||||
if (response.Payload.Length > 0)
|
||||
{
|
||||
await context.Response.Body.WriteAsync(response.Payload, context.RequestAborted);
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Request {RequestId} completed with status {StatusCode}",
|
||||
requestId,
|
||||
response.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the connection's average ping time using exponential moving average.
|
||||
/// </summary>
|
||||
private static void UpdateConnectionPing(
|
||||
IGlobalRoutingState routingState,
|
||||
string connectionId,
|
||||
double pingMs)
|
||||
{
|
||||
const double smoothingFactor = 0.2;
|
||||
|
||||
routingState.UpdateConnection(connectionId, connection =>
|
||||
{
|
||||
if (connection.AveragePingMs == 0)
|
||||
{
|
||||
connection.AveragePingMs = pingMs;
|
||||
}
|
||||
else
|
||||
{
|
||||
connection.AveragePingMs = (1 - smoothingFactor) * connection.AveragePingMs + smoothingFactor * pingMs;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dispatches a streaming request to a microservice.
|
||||
/// </summary>
|
||||
private async Task DispatchStreamingAsync(
|
||||
HttpContext context,
|
||||
ITransportClient transportClient,
|
||||
IGlobalRoutingState routingState,
|
||||
RoutingDecision decision,
|
||||
string requestId,
|
||||
Dictionary<string, string> headers)
|
||||
{
|
||||
var requestIdGuid = Guid.TryParse(requestId, out var parsed) ? parsed : Guid.NewGuid();
|
||||
|
||||
// Build request header frame (without body - will stream)
|
||||
var requestFrame = new RequestFrame
|
||||
{
|
||||
RequestId = requestId,
|
||||
CorrelationId = context.TraceIdentifier,
|
||||
Method = context.Request.Method,
|
||||
Path = context.Request.Path.ToString() + context.Request.QueryString.ToString(),
|
||||
Headers = headers,
|
||||
Payload = Array.Empty<byte>(), // Empty - body will be streamed
|
||||
TimeoutSeconds = (int)decision.EffectiveTimeout.TotalSeconds,
|
||||
SupportsStreaming = true
|
||||
};
|
||||
|
||||
var frame = FrameConverter.ToFrame(requestFrame);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Dispatching streaming {Method} {Path} to {ServiceName}/{Version}",
|
||||
requestFrame.Method,
|
||||
requestFrame.Path,
|
||||
decision.Connection.Instance.ServiceName,
|
||||
decision.Connection.Instance.Version);
|
||||
|
||||
// Create linked cancellation token with timeout
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(context.RequestAborted);
|
||||
timeoutCts.CancelAfter(decision.EffectiveTimeout);
|
||||
|
||||
// Register client disconnect handler to send CANCEL
|
||||
using var clientDisconnectRegistration = context.RequestAborted.Register(() =>
|
||||
{
|
||||
MarkCancelled(requestId);
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await transportClient.SendCancelAsync(
|
||||
decision.Connection,
|
||||
requestIdGuid,
|
||||
CancelReasons.ClientDisconnected);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Sent CANCEL for streaming request {RequestId} due to client disconnect",
|
||||
requestId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Failed to send CANCEL for streaming request {RequestId}",
|
||||
requestId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
var startTimestamp = Stopwatch.GetTimestamp();
|
||||
var responseReceived = false;
|
||||
|
||||
try
|
||||
{
|
||||
// Use streaming transport method
|
||||
await transportClient.SendStreamingAsync(
|
||||
decision.Connection,
|
||||
frame,
|
||||
context.Request.Body,
|
||||
async responseBodyStream =>
|
||||
{
|
||||
responseReceived = true;
|
||||
|
||||
// For now, read the response stream and write to HTTP response
|
||||
// The response headers should be set before streaming begins
|
||||
context.Response.StatusCode = StatusCodes.Status200OK;
|
||||
context.Response.Headers["Transfer-Encoding"] = "chunked";
|
||||
context.Response.ContentType = "application/octet-stream";
|
||||
|
||||
await responseBodyStream.CopyToAsync(context.Response.Body, timeoutCts.Token);
|
||||
},
|
||||
PayloadLimits.Default,
|
||||
timeoutCts.Token);
|
||||
|
||||
// Record ping latency
|
||||
var elapsed = Stopwatch.GetElapsedTime(startTimestamp);
|
||||
UpdateConnectionPing(routingState, decision.Connection.ConnectionId, elapsed.TotalMilliseconds);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Streaming request {RequestId} completed",
|
||||
requestId);
|
||||
}
|
||||
catch (OperationCanceledException) when (!context.RequestAborted.IsCancellationRequested)
|
||||
{
|
||||
// Internal timeout
|
||||
_logger.LogWarning(
|
||||
"Streaming request {RequestId} timed out after {Timeout}",
|
||||
requestId,
|
||||
decision.EffectiveTimeout);
|
||||
|
||||
MarkCancelled(requestId);
|
||||
|
||||
try
|
||||
{
|
||||
await transportClient.SendCancelAsync(
|
||||
decision.Connection,
|
||||
requestIdGuid,
|
||||
CancelReasons.Timeout);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to send cancel for streaming request {RequestId}", requestId);
|
||||
}
|
||||
|
||||
if (!responseReceived)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status504GatewayTimeout;
|
||||
await context.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
error = "Upstream streaming timeout",
|
||||
service = decision.Connection.Instance.ServiceName,
|
||||
timeout = decision.EffectiveTimeout.TotalSeconds
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Client disconnected
|
||||
MarkCancelled(requestId);
|
||||
_logger.LogDebug("Client disconnected, streaming request {RequestId} cancelled", requestId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"Error dispatching streaming request {RequestId}",
|
||||
requestId);
|
||||
|
||||
if (!responseReceived)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status502BadGateway;
|
||||
await context.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
error = "Upstream streaming error",
|
||||
message = ex.Message
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
namespace StellaOps.Router.Gateway.OpenApi;
|
||||
|
||||
/// <summary>
|
||||
/// Maps claim requirements to OpenAPI security schemes.
|
||||
/// </summary>
|
||||
internal static class ClaimSecurityMapper
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates security schemes from claim requirements.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">All endpoint descriptors.</param>
|
||||
/// <param name="tokenUrl">The OAuth2 token URL.</param>
|
||||
/// <returns>Security schemes JSON object.</returns>
|
||||
public static JsonObject GenerateSecuritySchemes(
|
||||
IEnumerable<EndpointDescriptor> endpoints,
|
||||
string tokenUrl)
|
||||
{
|
||||
var schemes = new JsonObject();
|
||||
|
||||
// Always add BearerAuth scheme
|
||||
schemes["BearerAuth"] = new JsonObject
|
||||
{
|
||||
["type"] = "http",
|
||||
["scheme"] = "bearer",
|
||||
["bearerFormat"] = "JWT",
|
||||
["description"] = "JWT Bearer token authentication"
|
||||
};
|
||||
|
||||
// Collect all unique scopes from claims
|
||||
var scopes = new Dictionary<string, string>();
|
||||
foreach (var endpoint in endpoints)
|
||||
{
|
||||
foreach (var claim in endpoint.RequiringClaims)
|
||||
{
|
||||
var scope = claim.Type;
|
||||
if (!scopes.ContainsKey(scope))
|
||||
{
|
||||
scopes[scope] = $"Access scope: {scope}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add OAuth2 scheme if there are any scopes
|
||||
if (scopes.Count > 0)
|
||||
{
|
||||
var scopesObject = new JsonObject();
|
||||
foreach (var (scope, description) in scopes)
|
||||
{
|
||||
scopesObject[scope] = description;
|
||||
}
|
||||
|
||||
schemes["OAuth2"] = new JsonObject
|
||||
{
|
||||
["type"] = "oauth2",
|
||||
["flows"] = new JsonObject
|
||||
{
|
||||
["clientCredentials"] = new JsonObject
|
||||
{
|
||||
["tokenUrl"] = tokenUrl,
|
||||
["scopes"] = scopesObject
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return schemes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates security requirement for an endpoint.
|
||||
/// </summary>
|
||||
/// <param name="endpoint">The endpoint descriptor.</param>
|
||||
/// <returns>Security requirement JSON array.</returns>
|
||||
public static JsonArray GenerateSecurityRequirement(EndpointDescriptor endpoint)
|
||||
{
|
||||
var requirements = new JsonArray();
|
||||
|
||||
if (endpoint.RequiringClaims.Count == 0)
|
||||
{
|
||||
return requirements;
|
||||
}
|
||||
|
||||
var requirement = new JsonObject();
|
||||
|
||||
// Always require BearerAuth
|
||||
requirement["BearerAuth"] = new JsonArray();
|
||||
|
||||
// Add OAuth2 scopes
|
||||
var scopes = new JsonArray();
|
||||
foreach (var claim in endpoint.RequiringClaims)
|
||||
{
|
||||
scopes.Add(claim.Type);
|
||||
}
|
||||
|
||||
if (scopes.Count > 0)
|
||||
{
|
||||
requirement["OAuth2"] = scopes;
|
||||
}
|
||||
|
||||
requirements.Add(requirement);
|
||||
return requirements;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace StellaOps.Router.Gateway.OpenApi;
|
||||
|
||||
/// <summary>
|
||||
/// Generates OpenAPI documents from aggregated microservice schemas.
|
||||
/// </summary>
|
||||
public interface IOpenApiDocumentGenerator
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates the OpenAPI 3.1.0 document as JSON.
|
||||
/// </summary>
|
||||
/// <returns>The OpenAPI document as a JSON string.</returns>
|
||||
string GenerateDocument();
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace StellaOps.Router.Gateway.OpenApi;
|
||||
|
||||
/// <summary>
|
||||
/// Caches the generated OpenAPI document with TTL-based expiration.
|
||||
/// </summary>
|
||||
public interface IRouterOpenApiDocumentCache
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the cached document or regenerates if expired.
|
||||
/// </summary>
|
||||
/// <returns>A tuple containing the document JSON, ETag, and generation timestamp.</returns>
|
||||
(string DocumentJson, string ETag, DateTime GeneratedAt) GetDocument();
|
||||
|
||||
/// <summary>
|
||||
/// Invalidates the cache, forcing regeneration on next access.
|
||||
/// </summary>
|
||||
void Invalidate();
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
namespace StellaOps.Router.Gateway.OpenApi;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for OpenAPI document aggregation.
|
||||
/// </summary>
|
||||
public sealed class OpenApiAggregationOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// The configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "Router:OpenApi";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the API title.
|
||||
/// </summary>
|
||||
public string Title { get; set; } = "StellaOps Gateway API";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the API description.
|
||||
/// </summary>
|
||||
public string Description { get; set; } = "Unified API aggregating all connected microservices.";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the API version.
|
||||
/// </summary>
|
||||
public string Version { get; set; } = "1.0.0";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the server URL.
|
||||
/// </summary>
|
||||
public string ServerUrl { get; set; } = "/";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the cache TTL in seconds.
|
||||
/// </summary>
|
||||
public int CacheTtlSeconds { get; set; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether OpenAPI aggregation is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the license name.
|
||||
/// </summary>
|
||||
public string LicenseName { get; set; } = "AGPL-3.0-or-later";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the contact name.
|
||||
/// </summary>
|
||||
public string? ContactName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the contact email.
|
||||
/// </summary>
|
||||
public string? ContactEmail { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the OAuth2 token URL for security schemes.
|
||||
/// </summary>
|
||||
public string TokenUrl { get; set; } = "/auth/token";
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
namespace StellaOps.Router.Gateway.OpenApi;
|
||||
|
||||
/// <summary>
|
||||
/// Generates OpenAPI 3.1.0 documents from aggregated microservice schemas.
|
||||
/// </summary>
|
||||
internal sealed class OpenApiDocumentGenerator : IOpenApiDocumentGenerator
|
||||
{
|
||||
private readonly IGlobalRoutingState _routingState;
|
||||
private readonly OpenApiAggregationOptions _options;
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
public OpenApiDocumentGenerator(
|
||||
IGlobalRoutingState routingState,
|
||||
IOptions<OpenApiAggregationOptions> options)
|
||||
{
|
||||
_routingState = routingState;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GenerateDocument()
|
||||
{
|
||||
var connections = _routingState.GetAllConnections();
|
||||
var doc = new JsonObject
|
||||
{
|
||||
["openapi"] = "3.1.0",
|
||||
["info"] = GenerateInfo(),
|
||||
["servers"] = GenerateServers(),
|
||||
["paths"] = GeneratePaths(connections),
|
||||
["components"] = GenerateComponents(connections),
|
||||
["tags"] = GenerateTags(connections)
|
||||
};
|
||||
|
||||
return doc.ToJsonString(JsonOptions);
|
||||
}
|
||||
|
||||
private JsonObject GenerateInfo()
|
||||
{
|
||||
var info = new JsonObject
|
||||
{
|
||||
["title"] = _options.Title,
|
||||
["version"] = _options.Version,
|
||||
["description"] = _options.Description,
|
||||
["license"] = new JsonObject
|
||||
{
|
||||
["name"] = _options.LicenseName
|
||||
}
|
||||
};
|
||||
|
||||
if (_options.ContactName is not null || _options.ContactEmail is not null)
|
||||
{
|
||||
var contact = new JsonObject();
|
||||
if (_options.ContactName is not null)
|
||||
contact["name"] = _options.ContactName;
|
||||
if (_options.ContactEmail is not null)
|
||||
contact["email"] = _options.ContactEmail;
|
||||
info["contact"] = contact;
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
private JsonArray GenerateServers()
|
||||
{
|
||||
return new JsonArray
|
||||
{
|
||||
new JsonObject
|
||||
{
|
||||
["url"] = _options.ServerUrl
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private JsonObject GeneratePaths(IReadOnlyList<ConnectionState> connections)
|
||||
{
|
||||
var paths = new JsonObject();
|
||||
|
||||
// Group endpoints by path
|
||||
var pathGroups = new Dictionary<string, List<(ConnectionState Conn, EndpointDescriptor Endpoint)>>();
|
||||
|
||||
foreach (var conn in connections)
|
||||
{
|
||||
foreach (var endpoint in conn.Endpoints.Values)
|
||||
{
|
||||
if (!pathGroups.TryGetValue(endpoint.Path, out var list))
|
||||
{
|
||||
list = [];
|
||||
pathGroups[endpoint.Path] = list;
|
||||
}
|
||||
list.Add((conn, endpoint));
|
||||
}
|
||||
}
|
||||
|
||||
// Generate path items
|
||||
foreach (var (path, endpoints) in pathGroups.OrderBy(p => p.Key))
|
||||
{
|
||||
var pathItem = new JsonObject();
|
||||
|
||||
foreach (var (conn, endpoint) in endpoints)
|
||||
{
|
||||
var operation = GenerateOperation(conn, endpoint);
|
||||
var method = endpoint.Method.ToLowerInvariant();
|
||||
pathItem[method] = operation;
|
||||
}
|
||||
|
||||
paths[path] = pathItem;
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
private JsonObject GenerateOperation(ConnectionState conn, EndpointDescriptor endpoint)
|
||||
{
|
||||
var operation = new JsonObject
|
||||
{
|
||||
["operationId"] = $"{conn.Instance.ServiceName}_{endpoint.Path.Replace("/", "_").Trim('_')}_{endpoint.Method}",
|
||||
["tags"] = new JsonArray { conn.Instance.ServiceName }
|
||||
};
|
||||
|
||||
// Add documentation from SchemaInfo
|
||||
if (endpoint.SchemaInfo is not null)
|
||||
{
|
||||
if (endpoint.SchemaInfo.Summary is not null)
|
||||
operation["summary"] = endpoint.SchemaInfo.Summary;
|
||||
if (endpoint.SchemaInfo.Description is not null)
|
||||
operation["description"] = endpoint.SchemaInfo.Description;
|
||||
if (endpoint.SchemaInfo.Deprecated)
|
||||
operation["deprecated"] = true;
|
||||
|
||||
// Override tags if specified
|
||||
if (endpoint.SchemaInfo.Tags.Count > 0)
|
||||
{
|
||||
var tags = new JsonArray();
|
||||
foreach (var tag in endpoint.SchemaInfo.Tags)
|
||||
{
|
||||
tags.Add(tag);
|
||||
}
|
||||
operation["tags"] = tags;
|
||||
}
|
||||
}
|
||||
|
||||
// Add security requirements
|
||||
var security = ClaimSecurityMapper.GenerateSecurityRequirement(endpoint);
|
||||
if (security.Count > 0)
|
||||
{
|
||||
operation["security"] = security;
|
||||
}
|
||||
|
||||
// Add request body if schema exists
|
||||
if (endpoint.SchemaInfo?.RequestSchemaId is not null)
|
||||
{
|
||||
var schemaRef = $"#/components/schemas/{conn.Instance.ServiceName}_{endpoint.SchemaInfo.RequestSchemaId}";
|
||||
operation["requestBody"] = new JsonObject
|
||||
{
|
||||
["required"] = true,
|
||||
["content"] = new JsonObject
|
||||
{
|
||||
["application/json"] = new JsonObject
|
||||
{
|
||||
["schema"] = new JsonObject
|
||||
{
|
||||
["$ref"] = schemaRef
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Add responses
|
||||
var responses = new JsonObject();
|
||||
|
||||
// Success response
|
||||
var successResponse = new JsonObject
|
||||
{
|
||||
["description"] = "Success"
|
||||
};
|
||||
|
||||
if (endpoint.SchemaInfo?.ResponseSchemaId is not null)
|
||||
{
|
||||
var schemaRef = $"#/components/schemas/{conn.Instance.ServiceName}_{endpoint.SchemaInfo.ResponseSchemaId}";
|
||||
successResponse["content"] = new JsonObject
|
||||
{
|
||||
["application/json"] = new JsonObject
|
||||
{
|
||||
["schema"] = new JsonObject
|
||||
{
|
||||
["$ref"] = schemaRef
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
responses["200"] = successResponse;
|
||||
|
||||
// Error responses
|
||||
responses["400"] = new JsonObject { ["description"] = "Bad Request" };
|
||||
responses["401"] = new JsonObject { ["description"] = "Unauthorized" };
|
||||
responses["404"] = new JsonObject { ["description"] = "Not Found" };
|
||||
responses["422"] = new JsonObject { ["description"] = "Validation Error" };
|
||||
responses["500"] = new JsonObject { ["description"] = "Internal Server Error" };
|
||||
|
||||
operation["responses"] = responses;
|
||||
|
||||
return operation;
|
||||
}
|
||||
|
||||
private JsonObject GenerateComponents(IReadOnlyList<ConnectionState> connections)
|
||||
{
|
||||
var components = new JsonObject();
|
||||
|
||||
// Generate schemas with service prefix
|
||||
var schemas = new JsonObject();
|
||||
foreach (var conn in connections)
|
||||
{
|
||||
foreach (var (schemaId, schemaDef) in conn.Schemas)
|
||||
{
|
||||
var prefixedId = $"{conn.Instance.ServiceName}_{schemaId}";
|
||||
try
|
||||
{
|
||||
var schemaNode = JsonNode.Parse(schemaDef.SchemaJson);
|
||||
if (schemaNode is not null)
|
||||
{
|
||||
schemas[prefixedId] = schemaNode;
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Skip invalid schemas
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (schemas.Count > 0)
|
||||
{
|
||||
components["schemas"] = schemas;
|
||||
}
|
||||
|
||||
// Generate security schemes
|
||||
var allEndpoints = connections.SelectMany(c => c.Endpoints.Values);
|
||||
var securitySchemes = ClaimSecurityMapper.GenerateSecuritySchemes(allEndpoints, _options.TokenUrl);
|
||||
if (securitySchemes.Count > 0)
|
||||
{
|
||||
components["securitySchemes"] = securitySchemes;
|
||||
}
|
||||
|
||||
return components;
|
||||
}
|
||||
|
||||
private JsonArray GenerateTags(IReadOnlyList<ConnectionState> connections)
|
||||
{
|
||||
var tags = new JsonArray();
|
||||
var seen = new HashSet<string>();
|
||||
|
||||
foreach (var conn in connections)
|
||||
{
|
||||
var serviceName = conn.Instance.ServiceName;
|
||||
if (seen.Add(serviceName))
|
||||
{
|
||||
var tag = new JsonObject
|
||||
{
|
||||
["name"] = serviceName,
|
||||
["description"] = $"{serviceName} microservice (v{conn.Instance.Version})"
|
||||
};
|
||||
|
||||
if (conn.OpenApiInfo?.Description is not null)
|
||||
{
|
||||
tag["description"] = conn.OpenApiInfo.Description;
|
||||
}
|
||||
|
||||
tags.Add(tag);
|
||||
}
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using YamlDotNet.Serialization;
|
||||
using YamlDotNet.Serialization.NamingConventions;
|
||||
|
||||
namespace StellaOps.Router.Gateway.OpenApi;
|
||||
|
||||
/// <summary>
|
||||
/// Endpoints for serving OpenAPI documentation.
|
||||
/// </summary>
|
||||
public static class OpenApiEndpoints
|
||||
{
|
||||
private static readonly ISerializer YamlSerializer = new SerializerBuilder()
|
||||
.WithNamingConvention(CamelCaseNamingConvention.Instance)
|
||||
.Build();
|
||||
|
||||
/// <summary>
|
||||
/// Maps OpenAPI endpoints to the application.
|
||||
/// </summary>
|
||||
public static IEndpointRouteBuilder MapRouterOpenApiEndpoints(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
endpoints.MapGet("/.well-known/openapi", GetOpenApiDiscovery)
|
||||
.ExcludeFromDescription();
|
||||
|
||||
endpoints.MapGet("/openapi.json", GetOpenApiJson)
|
||||
.ExcludeFromDescription();
|
||||
|
||||
endpoints.MapGet("/openapi.yaml", GetOpenApiYaml)
|
||||
.ExcludeFromDescription();
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
private static IResult GetOpenApiDiscovery(
|
||||
[FromServices] IRouterOpenApiDocumentCache cache,
|
||||
HttpContext context)
|
||||
{
|
||||
var (_, etag, generatedAt) = cache.GetDocument();
|
||||
|
||||
var discovery = new
|
||||
{
|
||||
openapi_json = "/openapi.json",
|
||||
openapi_yaml = "/openapi.yaml",
|
||||
etag,
|
||||
generated_at = generatedAt.ToString("O")
|
||||
};
|
||||
|
||||
context.Response.Headers.CacheControl = "public, max-age=60";
|
||||
return Results.Ok(discovery);
|
||||
}
|
||||
|
||||
private static IResult GetOpenApiJson(
|
||||
[FromServices] IRouterOpenApiDocumentCache cache,
|
||||
HttpContext context)
|
||||
{
|
||||
var (documentJson, etag, _) = cache.GetDocument();
|
||||
|
||||
// Check If-None-Match header
|
||||
if (context.Request.Headers.TryGetValue("If-None-Match", out var ifNoneMatch))
|
||||
{
|
||||
if (ifNoneMatch == etag)
|
||||
{
|
||||
context.Response.Headers.ETag = etag;
|
||||
context.Response.Headers.CacheControl = "public, max-age=60";
|
||||
return Results.StatusCode(304);
|
||||
}
|
||||
}
|
||||
|
||||
context.Response.Headers.ETag = etag;
|
||||
context.Response.Headers.CacheControl = "public, max-age=60";
|
||||
return Results.Content(documentJson, "application/json; charset=utf-8");
|
||||
}
|
||||
|
||||
private static IResult GetOpenApiYaml(
|
||||
[FromServices] IRouterOpenApiDocumentCache cache,
|
||||
HttpContext context)
|
||||
{
|
||||
var (documentJson, etag, _) = cache.GetDocument();
|
||||
|
||||
// Check If-None-Match header
|
||||
if (context.Request.Headers.TryGetValue("If-None-Match", out var ifNoneMatch))
|
||||
{
|
||||
if (ifNoneMatch == etag)
|
||||
{
|
||||
context.Response.Headers.ETag = etag;
|
||||
context.Response.Headers.CacheControl = "public, max-age=60";
|
||||
return Results.StatusCode(304);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert JSON to YAML
|
||||
var jsonNode = JsonNode.Parse(documentJson);
|
||||
var yamlContent = ConvertToYaml(jsonNode);
|
||||
|
||||
context.Response.Headers.ETag = etag;
|
||||
context.Response.Headers.CacheControl = "public, max-age=60";
|
||||
return Results.Content(yamlContent, "application/yaml; charset=utf-8");
|
||||
}
|
||||
|
||||
private static string ConvertToYaml(JsonNode? node)
|
||||
{
|
||||
if (node is null)
|
||||
return string.Empty;
|
||||
|
||||
var obj = ConvertJsonNodeToObject(node);
|
||||
return YamlSerializer.Serialize(obj);
|
||||
}
|
||||
|
||||
private static object? ConvertJsonNodeToObject(JsonNode? node)
|
||||
{
|
||||
return node switch
|
||||
{
|
||||
null => null,
|
||||
JsonObject obj => obj.ToDictionary(
|
||||
kvp => kvp.Key,
|
||||
kvp => ConvertJsonNodeToObject(kvp.Value)),
|
||||
JsonArray arr => arr.Select(ConvertJsonNodeToObject).ToList(),
|
||||
JsonValue val => val.GetValue<object>(),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Router.Gateway.OpenApi;
|
||||
|
||||
/// <summary>
|
||||
/// Caches the generated OpenAPI document with TTL-based expiration.
|
||||
/// </summary>
|
||||
internal sealed class RouterOpenApiDocumentCache : IRouterOpenApiDocumentCache
|
||||
{
|
||||
private readonly IOpenApiDocumentGenerator _generator;
|
||||
private readonly OpenApiAggregationOptions _options;
|
||||
private readonly object _lock = new();
|
||||
|
||||
private string? _cachedDocument;
|
||||
private string? _cachedETag;
|
||||
private DateTime _generatedAt;
|
||||
private bool _invalidated = true;
|
||||
|
||||
public RouterOpenApiDocumentCache(
|
||||
IOpenApiDocumentGenerator generator,
|
||||
IOptions<OpenApiAggregationOptions> options)
|
||||
{
|
||||
_generator = generator;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public (string DocumentJson, string ETag, DateTime GeneratedAt) GetDocument()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var ttl = TimeSpan.FromSeconds(_options.CacheTtlSeconds);
|
||||
|
||||
// Check if we need to regenerate
|
||||
if (_invalidated || _cachedDocument is null || now - _generatedAt > ttl)
|
||||
{
|
||||
Regenerate();
|
||||
}
|
||||
|
||||
return (_cachedDocument!, _cachedETag!, _generatedAt);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Invalidate()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_invalidated = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void Regenerate()
|
||||
{
|
||||
_cachedDocument = _generator.GenerateDocument();
|
||||
_cachedETag = ComputeETag(_cachedDocument);
|
||||
_generatedAt = DateTime.UtcNow;
|
||||
_invalidated = false;
|
||||
}
|
||||
|
||||
private static string ComputeETag(string content)
|
||||
{
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
|
||||
return $"\"{Convert.ToHexString(hash)[..16]}\"";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace StellaOps.Router.Gateway;
|
||||
|
||||
/// <summary>
|
||||
/// Well-known HttpContext.Items keys for router pipeline.
|
||||
/// </summary>
|
||||
public static class RouterHttpContextKeys
|
||||
{
|
||||
/// <summary>
|
||||
/// Key for the resolved <see cref="StellaOps.Router.Common.Models.EndpointDescriptor"/>.
|
||||
/// </summary>
|
||||
public const string EndpointDescriptor = "Stella.EndpointDescriptor";
|
||||
|
||||
/// <summary>
|
||||
/// Key for the <see cref="StellaOps.Router.Common.Models.RoutingDecision"/>.
|
||||
/// </summary>
|
||||
public const string RoutingDecision = "Stella.RoutingDecision";
|
||||
|
||||
/// <summary>
|
||||
/// Key for path parameters extracted from route template matching.
|
||||
/// </summary>
|
||||
public const string PathParameters = "Stella.PathParameters";
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using StellaOps.Router.Gateway.Configuration;
|
||||
|
||||
namespace StellaOps.Router.Gateway.Routing;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of routing plugin that provides health-aware, region-aware routing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Routing algorithm:
|
||||
/// 1. Filter by ServiceName (exact match from endpoint)
|
||||
/// 2. Filter by Version (strict semver equality when RequestedVersion specified)
|
||||
/// 3. Filter by Health (Healthy preferred, Degraded as fallback)
|
||||
/// 4. Group by Region Tier:
|
||||
/// - Tier 0: Same region as gateway
|
||||
/// - Tier 1: Configured neighbor regions
|
||||
/// - Tier 2: All other regions
|
||||
/// 5. Within each tier, sort by:
|
||||
/// - Primary: Lower AveragePingMs
|
||||
/// - Secondary: More recent LastHeartbeatUtc
|
||||
/// - Tie-breaker: Random or RoundRobin
|
||||
/// 6. Return first candidate from best available tier
|
||||
/// 7. If none remain, return null (503 Service Unavailable)
|
||||
/// </remarks>
|
||||
internal sealed class DefaultRoutingPlugin : IRoutingPlugin
|
||||
{
|
||||
private readonly RoutingOptions _options;
|
||||
private readonly RouterNodeConfig _gatewayConfig;
|
||||
private readonly ConcurrentDictionary<string, int> _roundRobinCounters = new();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DefaultRoutingPlugin"/> class.
|
||||
/// </summary>
|
||||
public DefaultRoutingPlugin(
|
||||
IOptions<RoutingOptions> options,
|
||||
IOptions<RouterNodeConfig> gatewayConfig)
|
||||
{
|
||||
_options = options.Value;
|
||||
_gatewayConfig = gatewayConfig.Value;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<RoutingDecision?> ChooseInstanceAsync(
|
||||
RoutingContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (context.AvailableConnections.Count == 0)
|
||||
{
|
||||
return Task.FromResult<RoutingDecision?>(null);
|
||||
}
|
||||
|
||||
var endpoint = context.Endpoint;
|
||||
if (endpoint is null)
|
||||
{
|
||||
return Task.FromResult<RoutingDecision?>(null);
|
||||
}
|
||||
|
||||
// Start with all available connections
|
||||
var candidates = context.AvailableConnections.ToList();
|
||||
|
||||
// Filter by version if requested
|
||||
candidates = FilterByVersion(candidates, context.RequestedVersion);
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
return Task.FromResult<RoutingDecision?>(null);
|
||||
}
|
||||
|
||||
// Filter by health status - prefer healthy, fall back to degraded
|
||||
candidates = FilterByHealth(candidates);
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
return Task.FromResult<RoutingDecision?>(null);
|
||||
}
|
||||
|
||||
// Group by region tier and select from best available tier
|
||||
var selected = SelectByRegionTier(candidates, context.GatewayRegion, endpoint.ServiceName);
|
||||
if (selected is null)
|
||||
{
|
||||
return Task.FromResult<RoutingDecision?>(null);
|
||||
}
|
||||
|
||||
var decision = new RoutingDecision
|
||||
{
|
||||
Endpoint = endpoint,
|
||||
Connection = selected,
|
||||
TransportType = selected.TransportType,
|
||||
EffectiveTimeout = TimeSpan.FromMilliseconds(_options.RoutingTimeoutMs)
|
||||
};
|
||||
|
||||
return Task.FromResult<RoutingDecision?>(decision);
|
||||
}
|
||||
|
||||
private List<ConnectionState> FilterByVersion(
|
||||
List<ConnectionState> candidates,
|
||||
string? requestedVersion)
|
||||
{
|
||||
// Determine effective version to match
|
||||
var versionToMatch = requestedVersion ?? _options.DefaultVersion;
|
||||
|
||||
// If no version specified and no default, return all candidates
|
||||
if (string.IsNullOrEmpty(versionToMatch))
|
||||
{
|
||||
return candidates;
|
||||
}
|
||||
|
||||
if (_options.StrictVersionMatching)
|
||||
{
|
||||
// Strict match: exact version equality
|
||||
return candidates
|
||||
.Where(c => string.Equals(c.Instance.Version, versionToMatch, StringComparison.Ordinal))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// Non-strict: allow compatible versions (for now, just exact match)
|
||||
// Future: implement semver compatibility checking
|
||||
return candidates
|
||||
.Where(c => string.Equals(c.Instance.Version, versionToMatch, StringComparison.Ordinal))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private List<ConnectionState> FilterByHealth(List<ConnectionState> candidates)
|
||||
{
|
||||
// Filter to only healthy instances first
|
||||
var healthy = candidates
|
||||
.Where(c => c.Status == InstanceHealthStatus.Healthy)
|
||||
.ToList();
|
||||
|
||||
if (healthy.Count > 0)
|
||||
{
|
||||
return healthy;
|
||||
}
|
||||
|
||||
// If no healthy instances and degraded allowed, include degraded
|
||||
if (_options.AllowDegradedInstances)
|
||||
{
|
||||
var degraded = candidates
|
||||
.Where(c => c.Status == InstanceHealthStatus.Degraded)
|
||||
.ToList();
|
||||
|
||||
if (degraded.Count > 0)
|
||||
{
|
||||
return degraded;
|
||||
}
|
||||
}
|
||||
|
||||
// No suitable instances
|
||||
return [];
|
||||
}
|
||||
|
||||
private ConnectionState? SelectByRegionTier(
|
||||
List<ConnectionState> candidates,
|
||||
string gatewayRegion,
|
||||
string serviceName)
|
||||
{
|
||||
if (!_options.PreferLocalRegion || string.IsNullOrEmpty(gatewayRegion))
|
||||
{
|
||||
// No region preference, select from all candidates
|
||||
return SelectFromTier(candidates, serviceName);
|
||||
}
|
||||
|
||||
// Tier 0: Same region as gateway
|
||||
var tier0 = candidates
|
||||
.Where(c => string.Equals(c.Instance.Region, gatewayRegion, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
var selected = SelectFromTier(tier0, serviceName);
|
||||
if (selected is not null)
|
||||
{
|
||||
return selected;
|
||||
}
|
||||
|
||||
// Tier 1: Configured neighbor regions
|
||||
var neighborRegions = _gatewayConfig.NeighborRegions;
|
||||
if (neighborRegions.Count > 0)
|
||||
{
|
||||
var tier1 = candidates
|
||||
.Where(c => neighborRegions.Contains(c.Instance.Region, StringComparer.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
selected = SelectFromTier(tier1, serviceName);
|
||||
if (selected is not null)
|
||||
{
|
||||
return selected;
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 2: All other regions (remaining candidates not in tier0 or tier1)
|
||||
var tier2 = candidates
|
||||
.Where(c => !string.Equals(c.Instance.Region, gatewayRegion, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(c => !neighborRegions.Contains(c.Instance.Region, StringComparer.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
return SelectFromTier(tier2, serviceName);
|
||||
}
|
||||
|
||||
private ConnectionState? SelectFromTier(List<ConnectionState> tier, string serviceName)
|
||||
{
|
||||
if (tier.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (tier.Count == 1)
|
||||
{
|
||||
return tier[0];
|
||||
}
|
||||
|
||||
// Sort by ping (ascending), then by heartbeat (descending = more recent first)
|
||||
var sorted = tier
|
||||
.OrderBy(c => c.AveragePingMs)
|
||||
.ThenByDescending(c => c.LastHeartbeatUtc)
|
||||
.ToList();
|
||||
|
||||
var best = sorted[0];
|
||||
|
||||
// Find all instances "tied" with the best one
|
||||
var tied = sorted
|
||||
.TakeWhile(c =>
|
||||
Math.Abs(c.AveragePingMs - best.AveragePingMs) <= _options.PingToleranceMs &&
|
||||
c.LastHeartbeatUtc == best.LastHeartbeatUtc)
|
||||
.ToList();
|
||||
|
||||
if (tied.Count == 1)
|
||||
{
|
||||
return tied[0];
|
||||
}
|
||||
|
||||
// Apply tie-breaker
|
||||
return _options.TieBreaker switch
|
||||
{
|
||||
TieBreakerMode.RoundRobin => SelectRoundRobin(tied, serviceName),
|
||||
_ => SelectRandom(tied)
|
||||
};
|
||||
}
|
||||
|
||||
private ConnectionState SelectRandom(List<ConnectionState> candidates)
|
||||
{
|
||||
var index = Random.Shared.Next(candidates.Count);
|
||||
return candidates[index];
|
||||
}
|
||||
|
||||
private ConnectionState SelectRoundRobin(List<ConnectionState> candidates, string serviceName)
|
||||
{
|
||||
// Get or create counter for this service
|
||||
var counter = _roundRobinCounters.AddOrUpdate(
|
||||
serviceName,
|
||||
_ => 0,
|
||||
(_, current) => current + 1);
|
||||
|
||||
var index = counter % candidates.Count;
|
||||
return candidates[index];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using StellaOps.Router.Gateway.OpenApi;
|
||||
using StellaOps.Router.Transport.InMemory;
|
||||
|
||||
namespace StellaOps.Router.Gateway.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Manages microservice connections and updates routing state.
|
||||
/// </summary>
|
||||
internal sealed class ConnectionManager : IHostedService
|
||||
{
|
||||
private readonly InMemoryTransportServer _transportServer;
|
||||
private readonly InMemoryConnectionRegistry _connectionRegistry;
|
||||
private readonly IGlobalRoutingState _routingState;
|
||||
private readonly IRouterOpenApiDocumentCache? _openApiCache;
|
||||
private readonly ILogger<ConnectionManager> _logger;
|
||||
|
||||
public ConnectionManager(
|
||||
InMemoryTransportServer transportServer,
|
||||
InMemoryConnectionRegistry connectionRegistry,
|
||||
IGlobalRoutingState routingState,
|
||||
ILogger<ConnectionManager> logger,
|
||||
IRouterOpenApiDocumentCache? openApiCache = null)
|
||||
{
|
||||
_transportServer = transportServer;
|
||||
_connectionRegistry = connectionRegistry;
|
||||
_routingState = routingState;
|
||||
_openApiCache = openApiCache;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Subscribe to transport server events
|
||||
_transportServer.OnHelloReceived += HandleHelloReceivedAsync;
|
||||
_transportServer.OnHeartbeatReceived += HandleHeartbeatReceivedAsync;
|
||||
_transportServer.OnConnectionClosed += HandleConnectionClosedAsync;
|
||||
|
||||
// Start the transport server
|
||||
await _transportServer.StartAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation("Connection manager started");
|
||||
}
|
||||
|
||||
public async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _transportServer.StopAsync(cancellationToken);
|
||||
|
||||
_transportServer.OnHelloReceived -= HandleHelloReceivedAsync;
|
||||
_transportServer.OnHeartbeatReceived -= HandleHeartbeatReceivedAsync;
|
||||
_transportServer.OnConnectionClosed -= HandleConnectionClosedAsync;
|
||||
|
||||
_logger.LogInformation("Connection manager stopped");
|
||||
}
|
||||
|
||||
private Task HandleHelloReceivedAsync(ConnectionState connectionState, HelloPayload payload)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Connection registered: {ConnectionId} from {ServiceName}/{Version} with {EndpointCount} endpoints, {SchemaCount} schemas",
|
||||
connectionState.ConnectionId,
|
||||
connectionState.Instance.ServiceName,
|
||||
connectionState.Instance.Version,
|
||||
connectionState.Endpoints.Count,
|
||||
connectionState.Schemas.Count);
|
||||
|
||||
// Add the connection to the routing state
|
||||
_routingState.AddConnection(connectionState);
|
||||
|
||||
// Start listening to this connection for frames
|
||||
_transportServer.StartListeningToConnection(connectionState.ConnectionId);
|
||||
|
||||
// Invalidate OpenAPI cache when connections change
|
||||
_openApiCache?.Invalidate();
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private Task HandleHeartbeatReceivedAsync(ConnectionState connectionState, HeartbeatPayload payload)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Heartbeat received from {ConnectionId}: status={Status}",
|
||||
connectionState.ConnectionId,
|
||||
payload.Status);
|
||||
|
||||
// Update connection state
|
||||
_routingState.UpdateConnection(connectionState.ConnectionId, conn =>
|
||||
{
|
||||
conn.Status = payload.Status;
|
||||
conn.LastHeartbeatUtc = DateTime.UtcNow;
|
||||
});
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private Task HandleConnectionClosedAsync(string connectionId)
|
||||
{
|
||||
_logger.LogInformation("Connection closed: {ConnectionId}", connectionId);
|
||||
|
||||
// Remove from routing state
|
||||
_routingState.RemoveConnection(connectionId);
|
||||
|
||||
// Invalidate OpenAPI cache when connections change
|
||||
_openApiCache?.Invalidate();
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Gateway.Configuration;
|
||||
|
||||
namespace StellaOps.Router.Gateway.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Background service that monitors connection health and marks stale instances as unhealthy.
|
||||
/// </summary>
|
||||
internal sealed class HealthMonitorService : BackgroundService
|
||||
{
|
||||
private readonly IGlobalRoutingState _routingState;
|
||||
private readonly IOptions<HealthOptions> _options;
|
||||
private readonly ILogger<HealthMonitorService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="HealthMonitorService"/> class.
|
||||
/// </summary>
|
||||
public HealthMonitorService(
|
||||
IGlobalRoutingState routingState,
|
||||
IOptions<HealthOptions> options,
|
||||
ILogger<HealthMonitorService> logger)
|
||||
{
|
||||
_routingState = routingState;
|
||||
_options = options;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Health monitor started. Stale threshold: {StaleThreshold}, Check interval: {CheckInterval}",
|
||||
_options.Value.StaleThreshold,
|
||||
_options.Value.CheckInterval);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(_options.Value.CheckInterval, stoppingToken);
|
||||
CheckStaleConnections();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected on shutdown
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in health monitor loop");
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Health monitor stopped");
|
||||
}
|
||||
|
||||
private void CheckStaleConnections()
|
||||
{
|
||||
var staleThreshold = _options.Value.StaleThreshold;
|
||||
var degradedThreshold = _options.Value.DegradedThreshold;
|
||||
var now = DateTime.UtcNow;
|
||||
var staleCount = 0;
|
||||
var degradedCount = 0;
|
||||
|
||||
foreach (var connection in _routingState.GetAllConnections())
|
||||
{
|
||||
// Skip connections that are already draining - they're intentionally stopping
|
||||
if (connection.Status == InstanceHealthStatus.Draining)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var age = now - connection.LastHeartbeatUtc;
|
||||
|
||||
// Check for stale (no heartbeat for too long)
|
||||
if (age > staleThreshold && connection.Status != InstanceHealthStatus.Unhealthy)
|
||||
{
|
||||
_routingState.UpdateConnection(connection.ConnectionId, c =>
|
||||
c.Status = InstanceHealthStatus.Unhealthy);
|
||||
|
||||
_logger.LogWarning(
|
||||
"Instance {InstanceId} ({ServiceName}/{Version}) marked Unhealthy: no heartbeat for {Age:g}",
|
||||
connection.Instance.InstanceId,
|
||||
connection.Instance.ServiceName,
|
||||
connection.Instance.Version,
|
||||
age);
|
||||
|
||||
staleCount++;
|
||||
}
|
||||
// Check for degraded (heartbeat delayed but not stale)
|
||||
else if (age > degradedThreshold &&
|
||||
connection.Status == InstanceHealthStatus.Healthy)
|
||||
{
|
||||
_routingState.UpdateConnection(connection.ConnectionId, c =>
|
||||
c.Status = InstanceHealthStatus.Degraded);
|
||||
|
||||
_logger.LogWarning(
|
||||
"Instance {InstanceId} ({ServiceName}/{Version}) marked Degraded: delayed heartbeat ({Age:g})",
|
||||
connection.Instance.InstanceId,
|
||||
connection.Instance.ServiceName,
|
||||
connection.Instance.Version,
|
||||
age);
|
||||
|
||||
degradedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (staleCount > 0 || degradedCount > 0)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Health check completed: {StaleCount} stale, {DegradedCount} degraded",
|
||||
staleCount,
|
||||
degradedCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace StellaOps.Router.Gateway.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Tracks round-trip time for requests to compute average ping latency.
|
||||
/// </summary>
|
||||
internal sealed class PingTracker
|
||||
{
|
||||
private readonly ConcurrentDictionary<Guid, long> _pendingRequests = new();
|
||||
private readonly object _lock = new();
|
||||
private double _averagePingMs;
|
||||
private const double SmoothingFactor = 0.2;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the exponential moving average of ping times in milliseconds.
|
||||
/// </summary>
|
||||
public double AveragePingMs
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _averagePingMs;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records that a request has been sent.
|
||||
/// </summary>
|
||||
/// <param name="correlationId">The correlation ID of the request.</param>
|
||||
public void RecordRequestSent(Guid correlationId)
|
||||
{
|
||||
_pendingRequests[correlationId] = Stopwatch.GetTimestamp();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records that a response has been received and updates the average ping.
|
||||
/// </summary>
|
||||
/// <param name="correlationId">The correlation ID of the request.</param>
|
||||
/// <returns>The round-trip time in milliseconds, or null if the correlation ID was not found.</returns>
|
||||
public double? RecordResponseReceived(Guid correlationId)
|
||||
{
|
||||
if (!_pendingRequests.TryRemove(correlationId, out var startTicks))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var elapsed = Stopwatch.GetElapsedTime(startTicks);
|
||||
var rtt = elapsed.TotalMilliseconds;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
// Exponential moving average: avg = (1 - alpha) * avg + alpha * new_value
|
||||
if (_averagePingMs == 0)
|
||||
{
|
||||
_averagePingMs = rtt; // First measurement
|
||||
}
|
||||
else
|
||||
{
|
||||
_averagePingMs = (1 - SmoothingFactor) * _averagePingMs + SmoothingFactor * rtt;
|
||||
}
|
||||
}
|
||||
|
||||
return rtt;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a pending request without recording a response.
|
||||
/// Call this when a request times out or is cancelled.
|
||||
/// </summary>
|
||||
/// <param name="correlationId">The correlation ID of the request.</param>
|
||||
public void RemovePending(Guid correlationId)
|
||||
{
|
||||
_pendingRequests.TryRemove(correlationId, out _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of pending requests.
|
||||
/// </summary>
|
||||
public int PendingCount => _pendingRequests.Count;
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
using System.Collections.Concurrent;
|
||||
using StellaOps.Router.Common;
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
namespace StellaOps.Router.Gateway.State;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of global routing state.
|
||||
/// </summary>
|
||||
internal sealed class InMemoryRoutingState : IGlobalRoutingState
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ConnectionState> _connections = new();
|
||||
private readonly ConcurrentDictionary<(string Method, string Path), ConcurrentBag<string>> _endpointIndex = new();
|
||||
private readonly ConcurrentDictionary<(string Method, string Path), PathMatcher> _pathMatchers = new();
|
||||
private readonly object _indexLock = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public void AddConnection(ConnectionState connection)
|
||||
{
|
||||
_connections[connection.ConnectionId] = connection;
|
||||
|
||||
// Index all endpoints
|
||||
foreach (var endpoint in connection.Endpoints.Values)
|
||||
{
|
||||
var key = (endpoint.Method, endpoint.Path);
|
||||
|
||||
// Add to endpoint index
|
||||
var connectionIds = _endpointIndex.GetOrAdd(key, _ => []);
|
||||
connectionIds.Add(connection.ConnectionId);
|
||||
|
||||
// Create path matcher if not exists
|
||||
_pathMatchers.GetOrAdd(key, _ => new PathMatcher(endpoint.Path));
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void RemoveConnection(string connectionId)
|
||||
{
|
||||
if (_connections.TryRemove(connectionId, out var connection))
|
||||
{
|
||||
// Remove from endpoint index
|
||||
foreach (var endpoint in connection.Endpoints.Values)
|
||||
{
|
||||
var key = (endpoint.Method, endpoint.Path);
|
||||
if (_endpointIndex.TryGetValue(key, out var connectionIds))
|
||||
{
|
||||
// ConcurrentBag doesn't support removal, so we need to rebuild
|
||||
lock (_indexLock)
|
||||
{
|
||||
var remaining = connectionIds.Where(id => id != connectionId).ToList();
|
||||
if (remaining.Count == 0)
|
||||
{
|
||||
_endpointIndex.TryRemove(key, out _);
|
||||
_pathMatchers.TryRemove(key, out _);
|
||||
}
|
||||
else
|
||||
{
|
||||
_endpointIndex[key] = new ConcurrentBag<string>(remaining);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void UpdateConnection(string connectionId, Action<ConnectionState> update)
|
||||
{
|
||||
if (_connections.TryGetValue(connectionId, out var connection))
|
||||
{
|
||||
update(connection);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ConnectionState? GetConnection(string connectionId)
|
||||
{
|
||||
return _connections.TryGetValue(connectionId, out var connection) ? connection : null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<ConnectionState> GetAllConnections()
|
||||
{
|
||||
return [.. _connections.Values];
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public EndpointDescriptor? ResolveEndpoint(string method, string path)
|
||||
{
|
||||
// First try exact match
|
||||
foreach (var ((m, p), matcher) in _pathMatchers)
|
||||
{
|
||||
if (!string.Equals(m, method, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
if (matcher.IsMatch(path))
|
||||
{
|
||||
// Get first connection with this endpoint
|
||||
if (_endpointIndex.TryGetValue((m, p), out var connectionIds))
|
||||
{
|
||||
foreach (var connectionId in connectionIds)
|
||||
{
|
||||
if (_connections.TryGetValue(connectionId, out var conn) &&
|
||||
conn.Endpoints.TryGetValue((m, p), out var endpoint))
|
||||
{
|
||||
return endpoint;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<ConnectionState> GetConnectionsFor(
|
||||
string serviceName,
|
||||
string version,
|
||||
string method,
|
||||
string path)
|
||||
{
|
||||
var result = new List<ConnectionState>();
|
||||
|
||||
foreach (var ((m, p), matcher) in _pathMatchers)
|
||||
{
|
||||
if (!string.Equals(m, method, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
if (!matcher.IsMatch(path))
|
||||
continue;
|
||||
|
||||
if (!_endpointIndex.TryGetValue((m, p), out var connectionIds))
|
||||
continue;
|
||||
|
||||
foreach (var connectionId in connectionIds)
|
||||
{
|
||||
if (!_connections.TryGetValue(connectionId, out var conn))
|
||||
continue;
|
||||
|
||||
// Filter by service name and version
|
||||
if (!string.Equals(conn.Instance.ServiceName, serviceName, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
if (!string.Equals(conn.Instance.Version, version, StringComparison.Ordinal))
|
||||
continue;
|
||||
|
||||
// Check endpoint exists
|
||||
if (conn.Endpoints.ContainsKey((m, p)))
|
||||
{
|
||||
result.Add(conn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="YamlDotNet" Version="16.2.1" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Router.Common\StellaOps.Router.Common.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Router.Config\StellaOps.Router.Config.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Router.Transport.InMemory\StellaOps.Router.Transport.InMemory.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="StellaOps.Router.Gateway.Tests" />
|
||||
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user