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

This commit is contained in:
StellaOps Bot
2025-12-13 18:08:55 +02:00
parent 6e45066e37
commit f1a39c4ce3
234 changed files with 24038 additions and 6910 deletions

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}

View File

@@ -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>();
}
}

View File

@@ -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>>());
}
}

View File

@@ -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);
}
}

View File

@@ -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}";
}

View File

@@ -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; }
}
}

View File

@@ -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>>();
}

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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]}";
}
}
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View 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;

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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; }
}

View File

@@ -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);
}
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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
});
}
}
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}

View File

@@ -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();
}

View File

@@ -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";
}

View File

@@ -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;
}
}

View File

@@ -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
};
}
}

View File

@@ -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]}\"";
}
}

View File

@@ -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";
}

View File

@@ -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];
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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>