Add unit tests for Router configuration and transport layers
- Implemented tests for RouterConfig, RoutingOptions, StaticInstanceConfig, and RouterConfigOptions to ensure default values are set correctly. - Added tests for RouterConfigProvider to validate configurations and ensure defaults are returned when no file is specified. - Created tests for ConfigValidationResult to check success and error scenarios. - Developed tests for ServiceCollectionExtensions to verify service registration for RouterConfig. - Introduced UdpTransportTests to validate serialization, connection, request-response, and error handling in UDP transport. - Added scripts for signing authority gaps and hashing DevPortal SDK snippets.
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
namespace StellaOps.Microservice;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for discovering endpoints with YAML configuration support.
|
||||
/// </summary>
|
||||
public interface IEndpointDiscoveryService
|
||||
{
|
||||
/// <summary>
|
||||
/// Discovers all endpoints, applying any YAML configuration overrides.
|
||||
/// </summary>
|
||||
/// <returns>The discovered endpoints with overrides applied.</returns>
|
||||
IReadOnlyList<EndpointDescriptor> DiscoverEndpoints();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service that discovers endpoints and applies YAML configuration overrides.
|
||||
/// </summary>
|
||||
public sealed class EndpointDiscoveryService : IEndpointDiscoveryService
|
||||
{
|
||||
private readonly IEndpointDiscoveryProvider _discoveryProvider;
|
||||
private readonly IMicroserviceYamlLoader _yamlLoader;
|
||||
private readonly IEndpointOverrideMerger _merger;
|
||||
private readonly ILogger<EndpointDiscoveryService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="EndpointDiscoveryService"/> class.
|
||||
/// </summary>
|
||||
public EndpointDiscoveryService(
|
||||
IEndpointDiscoveryProvider discoveryProvider,
|
||||
IMicroserviceYamlLoader yamlLoader,
|
||||
IEndpointOverrideMerger merger,
|
||||
ILogger<EndpointDiscoveryService> logger)
|
||||
{
|
||||
_discoveryProvider = discoveryProvider;
|
||||
_yamlLoader = yamlLoader;
|
||||
_merger = merger;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<EndpointDescriptor> DiscoverEndpoints()
|
||||
{
|
||||
// 1. Discover endpoints from code (via reflection or source gen)
|
||||
var codeEndpoints = _discoveryProvider.DiscoverEndpoints();
|
||||
_logger.LogDebug("Discovered {Count} endpoints from code", codeEndpoints.Count);
|
||||
|
||||
// 2. Load YAML overrides
|
||||
MicroserviceYamlConfig? yamlConfig = null;
|
||||
try
|
||||
{
|
||||
yamlConfig = _yamlLoader.Load();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to load YAML configuration, using code defaults only");
|
||||
}
|
||||
|
||||
// 3. Merge code endpoints with YAML overrides
|
||||
var mergedEndpoints = _merger.Merge(codeEndpoints, yamlConfig);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Endpoint discovery complete: {Count} endpoints (YAML overrides: {HasYaml})",
|
||||
mergedEndpoints.Count,
|
||||
yamlConfig != null);
|
||||
|
||||
return mergedEndpoints;
|
||||
}
|
||||
}
|
||||
115
src/__Libraries/StellaOps.Microservice/EndpointOverrideMerger.cs
Normal file
115
src/__Libraries/StellaOps.Microservice/EndpointOverrideMerger.cs
Normal file
@@ -0,0 +1,115 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
namespace StellaOps.Microservice;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for merging endpoint overrides from YAML configuration.
|
||||
/// </summary>
|
||||
public interface IEndpointOverrideMerger
|
||||
{
|
||||
/// <summary>
|
||||
/// Merges YAML overrides with code-defined endpoints.
|
||||
/// </summary>
|
||||
/// <param name="codeEndpoints">The endpoints discovered from code.</param>
|
||||
/// <param name="yamlConfig">The YAML configuration, if any.</param>
|
||||
/// <returns>The merged endpoints.</returns>
|
||||
IReadOnlyList<EndpointDescriptor> Merge(
|
||||
IReadOnlyList<EndpointDescriptor> codeEndpoints,
|
||||
MicroserviceYamlConfig? yamlConfig);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges endpoint overrides from YAML configuration with code defaults.
|
||||
/// </summary>
|
||||
public sealed class EndpointOverrideMerger : IEndpointOverrideMerger
|
||||
{
|
||||
private readonly ILogger<EndpointOverrideMerger> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="EndpointOverrideMerger"/> class.
|
||||
/// </summary>
|
||||
public EndpointOverrideMerger(ILogger<EndpointOverrideMerger> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<EndpointDescriptor> Merge(
|
||||
IReadOnlyList<EndpointDescriptor> codeEndpoints,
|
||||
MicroserviceYamlConfig? yamlConfig)
|
||||
{
|
||||
if (yamlConfig == null || yamlConfig.Endpoints.Count == 0)
|
||||
{
|
||||
return codeEndpoints;
|
||||
}
|
||||
|
||||
WarnUnmatchedOverrides(codeEndpoints, yamlConfig);
|
||||
|
||||
return codeEndpoints.Select(ep =>
|
||||
{
|
||||
var yamlOverride = FindMatchingOverride(ep, yamlConfig);
|
||||
return yamlOverride == null ? ep : MergeEndpoint(ep, yamlOverride);
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private static EndpointOverrideConfig? FindMatchingOverride(
|
||||
EndpointDescriptor endpoint,
|
||||
MicroserviceYamlConfig yamlConfig)
|
||||
{
|
||||
return yamlConfig.Endpoints.FirstOrDefault(y =>
|
||||
string.Equals(y.Method, endpoint.Method, StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(y.Path, endpoint.Path, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private EndpointDescriptor MergeEndpoint(
|
||||
EndpointDescriptor codeDefault,
|
||||
EndpointOverrideConfig yamlOverride)
|
||||
{
|
||||
var merged = codeDefault with
|
||||
{
|
||||
DefaultTimeout = yamlOverride.GetDefaultTimeoutAsTimeSpan() ?? codeDefault.DefaultTimeout,
|
||||
SupportsStreaming = yamlOverride.SupportsStreaming ?? codeDefault.SupportsStreaming,
|
||||
RequiringClaims = yamlOverride.RequiringClaims?.Count > 0
|
||||
? yamlOverride.RequiringClaims.Select(c => c.ToClaimRequirement()).ToList()
|
||||
: codeDefault.RequiringClaims
|
||||
};
|
||||
|
||||
if (yamlOverride.GetDefaultTimeoutAsTimeSpan().HasValue ||
|
||||
yamlOverride.SupportsStreaming.HasValue ||
|
||||
yamlOverride.RequiringClaims?.Count > 0)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Applied YAML overrides to endpoint {Method} {Path}: Timeout={Timeout}, Streaming={Streaming}, Claims={Claims}",
|
||||
merged.Method,
|
||||
merged.Path,
|
||||
merged.DefaultTimeout,
|
||||
merged.SupportsStreaming,
|
||||
merged.RequiringClaims?.Count ?? 0);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
private void WarnUnmatchedOverrides(
|
||||
IReadOnlyList<EndpointDescriptor> codeEndpoints,
|
||||
MicroserviceYamlConfig yamlConfig)
|
||||
{
|
||||
var codeKeys = codeEndpoints
|
||||
.Select(e => (Method: e.Method.ToUpperInvariant(), Path: e.Path.ToLowerInvariant()))
|
||||
.ToHashSet();
|
||||
|
||||
foreach (var yamlEntry in yamlConfig.Endpoints)
|
||||
{
|
||||
var key = (Method: yamlEntry.Method.ToUpperInvariant(), Path: yamlEntry.Path.ToLowerInvariant());
|
||||
if (!codeKeys.Contains(key))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"YAML override for {Method} {Path} does not match any code endpoint. " +
|
||||
"YAML cannot create endpoints, only modify existing ones.",
|
||||
yamlEntry.Method,
|
||||
yamlEntry.Path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
namespace StellaOps.Microservice;
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
namespace StellaOps.Microservice;
|
||||
|
||||
/// <summary>
|
||||
/// Discovers endpoints using source-generated provider, falling back to reflection.
|
||||
/// </summary>
|
||||
public sealed class GeneratedEndpointDiscoveryProvider : IEndpointDiscoveryProvider
|
||||
{
|
||||
private readonly StellaMicroserviceOptions _options;
|
||||
private readonly ILogger<GeneratedEndpointDiscoveryProvider> _logger;
|
||||
private readonly ReflectionEndpointDiscoveryProvider _reflectionFallback;
|
||||
|
||||
private const string GeneratedProviderTypeName = "StellaOps.Microservice.Generated.GeneratedEndpointProvider";
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="GeneratedEndpointDiscoveryProvider"/> class.
|
||||
/// </summary>
|
||||
public GeneratedEndpointDiscoveryProvider(
|
||||
StellaMicroserviceOptions options,
|
||||
ILogger<GeneratedEndpointDiscoveryProvider> logger)
|
||||
{
|
||||
_options = options;
|
||||
_logger = logger;
|
||||
_reflectionFallback = new ReflectionEndpointDiscoveryProvider(options);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<EndpointDescriptor> DiscoverEndpoints()
|
||||
{
|
||||
// Try to find the generated provider
|
||||
var generatedProvider = TryGetGeneratedProvider();
|
||||
|
||||
if (generatedProvider != null)
|
||||
{
|
||||
_logger.LogDebug("Using source-generated endpoint discovery");
|
||||
var endpoints = generatedProvider.GetEndpoints();
|
||||
|
||||
// Apply service name and version from options
|
||||
var result = new List<EndpointDescriptor>();
|
||||
foreach (var endpoint in endpoints)
|
||||
{
|
||||
result.Add(endpoint with
|
||||
{
|
||||
ServiceName = _options.ServiceName,
|
||||
Version = _options.Version
|
||||
});
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Discovered {Count} endpoints via source generation",
|
||||
result.Count);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Fall back to reflection
|
||||
_logger.LogDebug("Source-generated provider not found, falling back to reflection");
|
||||
return _reflectionFallback.DiscoverEndpoints();
|
||||
}
|
||||
|
||||
private IGeneratedEndpointProvider? TryGetGeneratedProvider()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Look in the entry assembly first
|
||||
var entryAssembly = Assembly.GetEntryAssembly();
|
||||
var providerType = entryAssembly?.GetType(GeneratedProviderTypeName);
|
||||
|
||||
if (providerType != null)
|
||||
{
|
||||
return (IGeneratedEndpointProvider)Activator.CreateInstance(providerType)!;
|
||||
}
|
||||
|
||||
// Also check the calling assembly
|
||||
var callingAssembly = Assembly.GetCallingAssembly();
|
||||
providerType = callingAssembly.GetType(GeneratedProviderTypeName);
|
||||
|
||||
if (providerType != null)
|
||||
{
|
||||
return (IGeneratedEndpointProvider)Activator.CreateInstance(providerType)!;
|
||||
}
|
||||
|
||||
// Check all loaded assemblies
|
||||
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
try
|
||||
{
|
||||
providerType = assembly.GetType(GeneratedProviderTypeName);
|
||||
if (providerType != null)
|
||||
{
|
||||
return (IGeneratedEndpointProvider)Activator.CreateInstance(providerType)!;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore assembly loading errors
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to load generated endpoint provider");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
namespace StellaOps.Microservice;
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
namespace StellaOps.Microservice;
|
||||
|
||||
/// <summary>
|
||||
/// Interface implemented by the source-generated endpoint provider.
|
||||
/// </summary>
|
||||
public interface IGeneratedEndpointProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets all discovered endpoint descriptors.
|
||||
/// </summary>
|
||||
IReadOnlyList<EndpointDescriptor> GetEndpoints();
|
||||
|
||||
/// <summary>
|
||||
/// Registers all endpoint handlers with the service collection.
|
||||
/// </summary>
|
||||
void RegisterHandlers(IServiceCollection services);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all handler types for endpoint discovery.
|
||||
/// </summary>
|
||||
IReadOnlyList<Type> GetHandlerTypes();
|
||||
}
|
||||
145
src/__Libraries/StellaOps.Microservice/InflightRequestTracker.cs
Normal file
145
src/__Libraries/StellaOps.Microservice/InflightRequestTracker.cs
Normal file
@@ -0,0 +1,145 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Microservice;
|
||||
|
||||
/// <summary>
|
||||
/// Tracks in-flight requests and manages their cancellation tokens.
|
||||
/// </summary>
|
||||
public sealed class InflightRequestTracker : IDisposable
|
||||
{
|
||||
private readonly ConcurrentDictionary<Guid, InflightRequest> _inflight = new();
|
||||
private readonly ILogger<InflightRequestTracker> _logger;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="InflightRequestTracker"/> class.
|
||||
/// </summary>
|
||||
public InflightRequestTracker(ILogger<InflightRequestTracker> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the count of in-flight requests.
|
||||
/// </summary>
|
||||
public int Count => _inflight.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Starts tracking a request and returns a cancellation token for it.
|
||||
/// </summary>
|
||||
/// <param name="correlationId">The correlation ID of the request.</param>
|
||||
/// <returns>A cancellation token that will be triggered if the request is cancelled.</returns>
|
||||
public CancellationToken Track(Guid correlationId)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
var request = new InflightRequest(cts);
|
||||
|
||||
if (!_inflight.TryAdd(correlationId, request))
|
||||
{
|
||||
cts.Dispose();
|
||||
throw new InvalidOperationException($"Request {correlationId} is already being tracked");
|
||||
}
|
||||
|
||||
_logger.LogDebug("Started tracking request {CorrelationId}", correlationId);
|
||||
return cts.Token;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancels a specific request.
|
||||
/// </summary>
|
||||
/// <param name="correlationId">The correlation ID of the request to cancel.</param>
|
||||
/// <param name="reason">The reason for cancellation.</param>
|
||||
/// <returns>True if the request was found and cancelled; otherwise false.</returns>
|
||||
public bool Cancel(Guid correlationId, string? reason)
|
||||
{
|
||||
if (_inflight.TryGetValue(correlationId, out var request))
|
||||
{
|
||||
try
|
||||
{
|
||||
request.Cts.Cancel();
|
||||
_logger.LogInformation(
|
||||
"Cancelled request {CorrelationId}: {Reason}",
|
||||
correlationId,
|
||||
reason ?? "Unknown");
|
||||
return true;
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// CTS was already disposed, request completed
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Cannot cancel request {CorrelationId}: not found (may have already completed)",
|
||||
correlationId);
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks a request as completed and removes it from tracking.
|
||||
/// </summary>
|
||||
/// <param name="correlationId">The correlation ID of the completed request.</param>
|
||||
public void Complete(Guid correlationId)
|
||||
{
|
||||
if (_inflight.TryRemove(correlationId, out var request))
|
||||
{
|
||||
request.Cts.Dispose();
|
||||
_logger.LogDebug("Completed request {CorrelationId}", correlationId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancels all in-flight requests.
|
||||
/// </summary>
|
||||
/// <param name="reason">The reason for cancellation.</param>
|
||||
public void CancelAll(string reason)
|
||||
{
|
||||
var count = 0;
|
||||
foreach (var kvp in _inflight)
|
||||
{
|
||||
try
|
||||
{
|
||||
kvp.Value.Cts.Cancel();
|
||||
count++;
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Already disposed
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Cancelled {Count} in-flight requests: {Reason}", count, reason);
|
||||
|
||||
// Clear and dispose all
|
||||
foreach (var kvp in _inflight)
|
||||
{
|
||||
if (_inflight.TryRemove(kvp.Key, out var request))
|
||||
{
|
||||
request.Cts.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
CancelAll("Disposing tracker");
|
||||
}
|
||||
|
||||
private sealed class InflightRequest
|
||||
{
|
||||
public CancellationTokenSource Cts { get; }
|
||||
|
||||
public InflightRequest(CancellationTokenSource cts)
|
||||
{
|
||||
Cts = cts;
|
||||
}
|
||||
}
|
||||
}
|
||||
113
src/__Libraries/StellaOps.Microservice/MicroserviceYamlConfig.cs
Normal file
113
src/__Libraries/StellaOps.Microservice/MicroserviceYamlConfig.cs
Normal file
@@ -0,0 +1,113 @@
|
||||
using StellaOps.Router.Common.Models;
|
||||
using YamlDotNet.Serialization;
|
||||
|
||||
namespace StellaOps.Microservice;
|
||||
|
||||
/// <summary>
|
||||
/// Root configuration for microservice endpoint overrides loaded from YAML.
|
||||
/// </summary>
|
||||
public sealed class MicroserviceYamlConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the endpoint override configurations.
|
||||
/// </summary>
|
||||
[YamlMember(Alias = "endpoints")]
|
||||
public List<EndpointOverrideConfig> Endpoints { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for overriding an endpoint's properties.
|
||||
/// </summary>
|
||||
public sealed class EndpointOverrideConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the HTTP method to match.
|
||||
/// </summary>
|
||||
[YamlMember(Alias = "method")]
|
||||
public string Method { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the path to match.
|
||||
/// </summary>
|
||||
[YamlMember(Alias = "path")]
|
||||
public string Path { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the default timeout override.
|
||||
/// </summary>
|
||||
[YamlMember(Alias = "defaultTimeout")]
|
||||
public string? DefaultTimeout { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether streaming is supported.
|
||||
/// </summary>
|
||||
[YamlMember(Alias = "supportsStreaming")]
|
||||
public bool? SupportsStreaming { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the claim requirements.
|
||||
/// </summary>
|
||||
[YamlMember(Alias = "requiringClaims")]
|
||||
public List<ClaimRequirementConfig>? RequiringClaims { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Parses the DefaultTimeout string to a TimeSpan.
|
||||
/// </summary>
|
||||
public TimeSpan? GetDefaultTimeoutAsTimeSpan()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(DefaultTimeout))
|
||||
return null;
|
||||
|
||||
// Handle formats like "30s", "5m", "1h", or "00:00:30"
|
||||
var value = DefaultTimeout.Trim();
|
||||
|
||||
if (value.EndsWith("s", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (int.TryParse(value[..^1], out var seconds))
|
||||
return TimeSpan.FromSeconds(seconds);
|
||||
}
|
||||
else if (value.EndsWith("m", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (int.TryParse(value[..^1], out var minutes))
|
||||
return TimeSpan.FromMinutes(minutes);
|
||||
}
|
||||
else if (value.EndsWith("h", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (int.TryParse(value[..^1], out var hours))
|
||||
return TimeSpan.FromHours(hours);
|
||||
}
|
||||
else if (TimeSpan.TryParse(value, out var timespan))
|
||||
{
|
||||
return timespan;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for a claim requirement.
|
||||
/// </summary>
|
||||
public sealed class ClaimRequirementConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the claim type.
|
||||
/// </summary>
|
||||
[YamlMember(Alias = "type")]
|
||||
public string Type { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the claim value.
|
||||
/// </summary>
|
||||
[YamlMember(Alias = "value")]
|
||||
public string? Value { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Converts to a ClaimRequirement model.
|
||||
/// </summary>
|
||||
public ClaimRequirement ToClaimRequirement() => new()
|
||||
{
|
||||
Type = Type,
|
||||
Value = Value
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using YamlDotNet.Serialization;
|
||||
using YamlDotNet.Serialization.NamingConventions;
|
||||
|
||||
namespace StellaOps.Microservice;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for loading microservice YAML configuration.
|
||||
/// </summary>
|
||||
public interface IMicroserviceYamlLoader
|
||||
{
|
||||
/// <summary>
|
||||
/// Loads the microservice configuration from YAML.
|
||||
/// </summary>
|
||||
/// <returns>The configuration, or null if no file is configured or file doesn't exist.</returns>
|
||||
MicroserviceYamlConfig? Load();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads microservice configuration from a YAML file.
|
||||
/// </summary>
|
||||
public sealed class MicroserviceYamlLoader : IMicroserviceYamlLoader
|
||||
{
|
||||
private readonly StellaMicroserviceOptions _options;
|
||||
private readonly ILogger<MicroserviceYamlLoader> _logger;
|
||||
private readonly IDeserializer _deserializer;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MicroserviceYamlLoader"/> class.
|
||||
/// </summary>
|
||||
public MicroserviceYamlLoader(
|
||||
StellaMicroserviceOptions options,
|
||||
ILogger<MicroserviceYamlLoader> logger)
|
||||
{
|
||||
_options = options;
|
||||
_logger = logger;
|
||||
_deserializer = new DeserializerBuilder()
|
||||
.WithNamingConvention(CamelCaseNamingConvention.Instance)
|
||||
.IgnoreUnmatchedProperties()
|
||||
.Build();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public MicroserviceYamlConfig? Load()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.ConfigFilePath))
|
||||
{
|
||||
_logger.LogDebug("No ConfigFilePath specified, skipping YAML configuration");
|
||||
return null;
|
||||
}
|
||||
|
||||
var fullPath = Path.GetFullPath(_options.ConfigFilePath);
|
||||
|
||||
if (!File.Exists(fullPath))
|
||||
{
|
||||
_logger.LogDebug("Configuration file {Path} does not exist, skipping", fullPath);
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var yaml = File.ReadAllText(fullPath);
|
||||
var config = _deserializer.Deserialize<MicroserviceYamlConfig>(yaml);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Loaded microservice configuration from {Path} with {Count} endpoint overrides",
|
||||
fullPath,
|
||||
config?.Endpoints?.Count ?? 0);
|
||||
|
||||
return config;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to load microservice configuration from {Path}", fullPath);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,85 +1,2 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Microservice;
|
||||
|
||||
/// <summary>
|
||||
/// Matches request paths against route templates.
|
||||
/// </summary>
|
||||
public sealed partial class PathMatcher
|
||||
{
|
||||
private readonly string _template;
|
||||
private readonly Regex _regex;
|
||||
private readonly string[] _parameterNames;
|
||||
private readonly bool _caseInsensitive;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the route template.
|
||||
/// </summary>
|
||||
public string Template => _template;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PathMatcher"/> class.
|
||||
/// </summary>
|
||||
/// <param name="template">The route template (e.g., "/api/users/{id}").</param>
|
||||
/// <param name="caseInsensitive">Whether matching should be case-insensitive.</param>
|
||||
public PathMatcher(string template, bool caseInsensitive = true)
|
||||
{
|
||||
_template = template;
|
||||
_caseInsensitive = caseInsensitive;
|
||||
|
||||
// Extract parameter names and build regex
|
||||
var paramNames = new List<string>();
|
||||
var pattern = "^" + ParameterRegex().Replace(template, match =>
|
||||
{
|
||||
paramNames.Add(match.Groups[1].Value);
|
||||
return "([^/]+)";
|
||||
}) + "/?$";
|
||||
|
||||
var options = caseInsensitive ? RegexOptions.IgnoreCase : RegexOptions.None;
|
||||
_regex = new Regex(pattern, options | RegexOptions.Compiled);
|
||||
_parameterNames = [.. paramNames];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to match a path against the template.
|
||||
/// </summary>
|
||||
/// <param name="path">The request path.</param>
|
||||
/// <param name="parameters">The extracted path parameters if matched.</param>
|
||||
/// <returns>True if the path matches.</returns>
|
||||
public bool TryMatch(string path, out Dictionary<string, string> parameters)
|
||||
{
|
||||
parameters = [];
|
||||
|
||||
// Normalize path
|
||||
path = path.TrimEnd('/');
|
||||
if (!path.StartsWith('/'))
|
||||
path = "/" + path;
|
||||
|
||||
var match = _regex.Match(path);
|
||||
if (!match.Success)
|
||||
return false;
|
||||
|
||||
for (int i = 0; i < _parameterNames.Length; i++)
|
||||
{
|
||||
parameters[_parameterNames[i]] = match.Groups[i + 1].Value;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a path matches the template.
|
||||
/// </summary>
|
||||
/// <param name="path">The request path.</param>
|
||||
/// <returns>True if the path matches.</returns>
|
||||
public bool IsMatch(string path)
|
||||
{
|
||||
path = path.TrimEnd('/');
|
||||
if (!path.StartsWith('/'))
|
||||
path = "/" + path;
|
||||
return _regex.IsMatch(path);
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\{([^}:]+)(?::[^}]+)?\}")]
|
||||
private static partial Regex ParameterRegex();
|
||||
}
|
||||
// Re-export PathMatcher from Router.Common for backwards compatibility
|
||||
global using PathMatcher = StellaOps.Router.Common.PathMatcher;
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Router.Common.Frames;
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
namespace StellaOps.Microservice;
|
||||
|
||||
@@ -116,6 +117,13 @@ public sealed class RequestDispatcher
|
||||
RawRequestContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Ensure handler type is set
|
||||
if (endpoint.HandlerType is null)
|
||||
{
|
||||
_logger.LogError("Endpoint {Method} {Path} has no handler type", endpoint.Method, endpoint.Path);
|
||||
return RawResponse.InternalError("No handler configured");
|
||||
}
|
||||
|
||||
// Get handler instance from DI
|
||||
var handler = scopedProvider.GetService(endpoint.HandlerType);
|
||||
if (handler is null)
|
||||
|
||||
@@ -14,13 +14,16 @@ public sealed class RouterConnectionManager : IRouterConnectionManager, IDisposa
|
||||
{
|
||||
private readonly StellaMicroserviceOptions _options;
|
||||
private readonly IEndpointDiscoveryProvider _endpointDiscovery;
|
||||
private readonly ITransportClient _transportClient;
|
||||
private readonly IMicroserviceTransport? _microserviceTransport;
|
||||
private readonly ILogger<RouterConnectionManager> _logger;
|
||||
private readonly ConcurrentDictionary<string, ConnectionState> _connections = new();
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private IReadOnlyList<EndpointDescriptor>? _endpoints;
|
||||
private Task? _heartbeatTask;
|
||||
private bool _disposed;
|
||||
private volatile InstanceHealthStatus _currentStatus = InstanceHealthStatus.Healthy;
|
||||
private int _inFlightRequestCount;
|
||||
private double _errorRate;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<ConnectionState> Connections => [.. _connections.Values];
|
||||
@@ -31,15 +34,42 @@ public sealed class RouterConnectionManager : IRouterConnectionManager, IDisposa
|
||||
public RouterConnectionManager(
|
||||
IOptions<StellaMicroserviceOptions> options,
|
||||
IEndpointDiscoveryProvider endpointDiscovery,
|
||||
ITransportClient transportClient,
|
||||
IMicroserviceTransport? microserviceTransport,
|
||||
ILogger<RouterConnectionManager> logger)
|
||||
{
|
||||
_options = options.Value;
|
||||
_endpointDiscovery = endpointDiscovery;
|
||||
_transportClient = transportClient;
|
||||
_microserviceTransport = microserviceTransport;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the current health status reported by this instance.
|
||||
/// </summary>
|
||||
public InstanceHealthStatus CurrentStatus
|
||||
{
|
||||
get => _currentStatus;
|
||||
set => _currentStatus = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the count of in-flight requests.
|
||||
/// </summary>
|
||||
public int InFlightRequestCount
|
||||
{
|
||||
get => _inFlightRequestCount;
|
||||
set => _inFlightRequestCount = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the error rate (0.0 to 1.0).
|
||||
/// </summary>
|
||||
public double ErrorRate
|
||||
{
|
||||
get => _errorRate;
|
||||
set => _errorRate = value;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -168,32 +198,40 @@ public sealed class RouterConnectionManager : IRouterConnectionManager, IDisposa
|
||||
{
|
||||
await Task.Delay(_options.HeartbeatInterval, cancellationToken);
|
||||
|
||||
foreach (var connection in _connections.Values)
|
||||
// Build heartbeat payload with current status and metrics
|
||||
var heartbeat = new HeartbeatPayload
|
||||
{
|
||||
InstanceId = _options.InstanceId,
|
||||
Status = _currentStatus,
|
||||
InFlightRequestCount = _inFlightRequestCount,
|
||||
ErrorRate = _errorRate,
|
||||
TimestampUtc = DateTime.UtcNow
|
||||
};
|
||||
|
||||
// Send heartbeat via transport
|
||||
if (_microserviceTransport is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Build heartbeat payload
|
||||
var heartbeat = new HeartbeatPayload
|
||||
{
|
||||
InstanceId = _options.InstanceId,
|
||||
Status = connection.Status,
|
||||
TimestampUtc = DateTime.UtcNow
|
||||
};
|
||||
|
||||
// Update last heartbeat time
|
||||
connection.LastHeartbeatUtc = DateTime.UtcNow;
|
||||
await _microserviceTransport.SendHeartbeatAsync(heartbeat, cancellationToken);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Sent heartbeat for connection {ConnectionId}",
|
||||
connection.ConnectionId);
|
||||
"Sent heartbeat: status={Status}, inflight={InFlight}, errorRate={ErrorRate:P1}",
|
||||
heartbeat.Status,
|
||||
heartbeat.InFlightRequestCount,
|
||||
heartbeat.ErrorRate);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Failed to send heartbeat for connection {ConnectionId}",
|
||||
connection.ConnectionId);
|
||||
_logger.LogWarning(ex, "Failed to send heartbeat");
|
||||
}
|
||||
}
|
||||
|
||||
// Update connection state local heartbeat times
|
||||
foreach (var connection in _connections.Values)
|
||||
{
|
||||
connection.LastHeartbeatUtc = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
|
||||
@@ -22,17 +22,34 @@ public static class ServiceCollectionExtensions
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configure);
|
||||
|
||||
// Configure options
|
||||
// Configure and register options as singleton
|
||||
var options = new StellaMicroserviceOptions { ServiceName = "", Version = "1.0.0", Region = "" };
|
||||
configure(options);
|
||||
services.AddSingleton(options);
|
||||
services.Configure(configure);
|
||||
|
||||
// Register endpoint discovery
|
||||
services.TryAddSingleton<IEndpointDiscoveryProvider>(sp =>
|
||||
// Register YAML loader and merger
|
||||
services.TryAddSingleton<IMicroserviceYamlLoader, MicroserviceYamlLoader>();
|
||||
services.TryAddSingleton<IEndpointOverrideMerger, EndpointOverrideMerger>();
|
||||
|
||||
// Register endpoint discovery provider (prefers generated over reflection)
|
||||
services.TryAddSingleton<IEndpointDiscoveryProvider, GeneratedEndpointDiscoveryProvider>();
|
||||
|
||||
// Register endpoint discovery service (with YAML integration)
|
||||
services.TryAddSingleton<IEndpointDiscoveryService, EndpointDiscoveryService>();
|
||||
|
||||
// Register endpoint registry (using discovery service)
|
||||
services.TryAddSingleton<IEndpointRegistry>(sp =>
|
||||
{
|
||||
var options = new StellaMicroserviceOptions { ServiceName = "", Version = "1.0.0", Region = "" };
|
||||
configure(options);
|
||||
return new ReflectionEndpointDiscoveryProvider(options);
|
||||
var discoveryService = sp.GetRequiredService<IEndpointDiscoveryService>();
|
||||
var registry = new EndpointRegistry();
|
||||
registry.RegisterAll(discoveryService.DiscoverEndpoints());
|
||||
return registry;
|
||||
});
|
||||
|
||||
// Register request dispatcher
|
||||
services.TryAddSingleton<RequestDispatcher>();
|
||||
|
||||
// Register connection manager
|
||||
services.TryAddSingleton<IRouterConnectionManager, RouterConnectionManager>();
|
||||
|
||||
@@ -57,12 +74,34 @@ public static class ServiceCollectionExtensions
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configure);
|
||||
|
||||
// Configure options
|
||||
// Configure and register options as singleton
|
||||
var options = new StellaMicroserviceOptions { ServiceName = "", Version = "1.0.0", Region = "" };
|
||||
configure(options);
|
||||
services.AddSingleton(options);
|
||||
services.Configure(configure);
|
||||
|
||||
// Register YAML loader and merger
|
||||
services.TryAddSingleton<IMicroserviceYamlLoader, MicroserviceYamlLoader>();
|
||||
services.TryAddSingleton<IEndpointOverrideMerger, EndpointOverrideMerger>();
|
||||
|
||||
// Register custom endpoint discovery
|
||||
services.TryAddSingleton<IEndpointDiscoveryProvider, TDiscovery>();
|
||||
|
||||
// Register endpoint discovery service (with YAML integration)
|
||||
services.TryAddSingleton<IEndpointDiscoveryService, EndpointDiscoveryService>();
|
||||
|
||||
// Register endpoint registry (using discovery service)
|
||||
services.TryAddSingleton<IEndpointRegistry>(sp =>
|
||||
{
|
||||
var discoveryService = sp.GetRequiredService<IEndpointDiscoveryService>();
|
||||
var registry = new EndpointRegistry();
|
||||
registry.RegisterAll(discoveryService.DiscoverEndpoints());
|
||||
return registry;
|
||||
});
|
||||
|
||||
// Register request dispatcher
|
||||
services.TryAddSingleton<RequestDispatcher>();
|
||||
|
||||
// Register connection manager
|
||||
services.TryAddSingleton<IRouterConnectionManager, RouterConnectionManager>();
|
||||
|
||||
@@ -71,4 +110,17 @@ public static class ServiceCollectionExtensions
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers an endpoint handler type for dependency injection.
|
||||
/// </summary>
|
||||
/// <typeparam name="THandler">The endpoint handler type.</typeparam>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddStellaEndpoint<THandler>(this IServiceCollection services)
|
||||
where THandler : class, IStellaEndpoint
|
||||
{
|
||||
services.AddScoped<THandler>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="YamlDotNet" Version="13.7.1" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Router.Common\StellaOps.Router.Common.csproj" />
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace StellaOps.Microservice.Streaming;
|
||||
|
||||
/// <summary>
|
||||
/// A read-only stream that reads from a channel of data chunks.
|
||||
/// Used to expose streaming request body to handlers.
|
||||
/// </summary>
|
||||
public sealed class StreamingRequestBodyStream : Stream
|
||||
{
|
||||
private readonly ChannelReader<StreamChunk> _reader;
|
||||
private readonly CancellationToken _cancellationToken;
|
||||
private byte[] _currentBuffer = [];
|
||||
private int _currentBufferPosition;
|
||||
private bool _endOfStream;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="StreamingRequestBodyStream"/> class.
|
||||
/// </summary>
|
||||
/// <param name="reader">The channel reader for incoming chunks.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public StreamingRequestBodyStream(
|
||||
ChannelReader<StreamChunk> reader,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_reader = reader;
|
||||
_cancellationToken = cancellationToken;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool CanRead => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool CanSeek => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool CanWrite => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override long Length => throw new NotSupportedException("Streaming body length unknown.");
|
||||
|
||||
/// <inheritdoc />
|
||||
public override long Position
|
||||
{
|
||||
get => throw new NotSupportedException("Streaming body position not supported.");
|
||||
set => throw new NotSupportedException("Streaming body position not supported.");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Flush() { }
|
||||
|
||||
/// <inheritdoc />
|
||||
public override int Read(byte[] buffer, int offset, int count)
|
||||
{
|
||||
return ReadAsync(buffer, offset, count, CancellationToken.None)
|
||||
.GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
|
||||
{
|
||||
return await ReadAsync(buffer.AsMemory(offset, count), cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
if (_endOfStream)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _cancellationToken);
|
||||
|
||||
// Try to use remaining data from current buffer first
|
||||
if (_currentBufferPosition < _currentBuffer.Length)
|
||||
{
|
||||
var bytesToCopy = Math.Min(buffer.Length, _currentBuffer.Length - _currentBufferPosition);
|
||||
_currentBuffer.AsSpan(_currentBufferPosition, bytesToCopy).CopyTo(buffer.Span);
|
||||
_currentBufferPosition += bytesToCopy;
|
||||
return bytesToCopy;
|
||||
}
|
||||
|
||||
// Need to read next chunk from channel
|
||||
if (!await _reader.WaitToReadAsync(linkedCts.Token))
|
||||
{
|
||||
_endOfStream = true;
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!_reader.TryRead(out var chunk))
|
||||
{
|
||||
_endOfStream = true;
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (chunk.EndOfStream)
|
||||
{
|
||||
_endOfStream = true;
|
||||
// Still process any data in the final chunk
|
||||
if (chunk.Data.Length == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
_currentBuffer = chunk.Data;
|
||||
_currentBufferPosition = 0;
|
||||
|
||||
var bytesToReturn = Math.Min(buffer.Length, _currentBuffer.Length);
|
||||
_currentBuffer.AsSpan(0, bytesToReturn).CopyTo(buffer.Span);
|
||||
_currentBufferPosition = bytesToReturn;
|
||||
return bytesToReturn;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override long Seek(long offset, SeekOrigin origin)
|
||||
{
|
||||
throw new NotSupportedException("Seeking not supported on streaming body.");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void SetLength(long value)
|
||||
{
|
||||
throw new NotSupportedException("Setting length not supported on streaming body.");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Write(byte[] buffer, int offset, int count)
|
||||
{
|
||||
throw new NotSupportedException("Write not supported on streaming body.");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
_disposed = true;
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a chunk of streaming data.
|
||||
/// </summary>
|
||||
public sealed record StreamChunk
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the chunk data.
|
||||
/// </summary>
|
||||
public byte[] Data { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether this is the final chunk.
|
||||
/// </summary>
|
||||
public bool EndOfStream { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the sequence number.
|
||||
/// </summary>
|
||||
public int SequenceNumber { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace StellaOps.Microservice.Streaming;
|
||||
|
||||
/// <summary>
|
||||
/// A write-only stream that writes chunks to a channel.
|
||||
/// Used to enable streaming response body from handlers.
|
||||
/// </summary>
|
||||
public sealed class StreamingResponseBodyStream : Stream
|
||||
{
|
||||
private readonly ChannelWriter<StreamChunk> _writer;
|
||||
private readonly int _chunkSize;
|
||||
private readonly CancellationToken _cancellationToken;
|
||||
private byte[] _buffer;
|
||||
private int _bufferPosition;
|
||||
private int _sequenceNumber;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="StreamingResponseBodyStream"/> class.
|
||||
/// </summary>
|
||||
/// <param name="writer">The channel writer for outgoing chunks.</param>
|
||||
/// <param name="chunkSize">The chunk size for buffered writes.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public StreamingResponseBodyStream(
|
||||
ChannelWriter<StreamChunk> writer,
|
||||
int chunkSize,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_writer = writer;
|
||||
_chunkSize = chunkSize;
|
||||
_cancellationToken = cancellationToken;
|
||||
_buffer = new byte[chunkSize];
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool CanRead => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool CanSeek => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool CanWrite => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override long Length => throw new NotSupportedException();
|
||||
|
||||
/// <inheritdoc />
|
||||
public override long Position
|
||||
{
|
||||
get => throw new NotSupportedException();
|
||||
set => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Flush()
|
||||
{
|
||||
FlushAsync(CancellationToken.None).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task FlushAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_bufferPosition > 0)
|
||||
{
|
||||
var chunk = new StreamChunk
|
||||
{
|
||||
Data = _buffer[.._bufferPosition],
|
||||
SequenceNumber = _sequenceNumber++,
|
||||
EndOfStream = false
|
||||
};
|
||||
|
||||
await _writer.WriteAsync(chunk, cancellationToken);
|
||||
_buffer = new byte[_chunkSize];
|
||||
_bufferPosition = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override int Read(byte[] buffer, int offset, int count)
|
||||
{
|
||||
throw new NotSupportedException("Read not supported on streaming response body.");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override long Seek(long offset, SeekOrigin origin)
|
||||
{
|
||||
throw new NotSupportedException("Seeking not supported on streaming response body.");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void SetLength(long value)
|
||||
{
|
||||
throw new NotSupportedException("Setting length not supported on streaming response body.");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Write(byte[] buffer, int offset, int count)
|
||||
{
|
||||
WriteAsync(buffer, offset, count, CancellationToken.None).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
|
||||
{
|
||||
await WriteAsync(buffer.AsMemory(offset, count), cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _cancellationToken);
|
||||
|
||||
var bytesWritten = 0;
|
||||
while (bytesWritten < buffer.Length)
|
||||
{
|
||||
var spaceInBuffer = _chunkSize - _bufferPosition;
|
||||
var bytesToWrite = Math.Min(spaceInBuffer, buffer.Length - bytesWritten);
|
||||
|
||||
buffer.Slice(bytesWritten, bytesToWrite).Span.CopyTo(_buffer.AsSpan(_bufferPosition));
|
||||
_bufferPosition += bytesToWrite;
|
||||
bytesWritten += bytesToWrite;
|
||||
|
||||
if (_bufferPosition >= _chunkSize)
|
||||
{
|
||||
await FlushAsync(linkedCts.Token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Completes the stream by flushing remaining data and sending end-of-stream signal.
|
||||
/// </summary>
|
||||
public async Task CompleteAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Flush any remaining buffered data
|
||||
await FlushAsync(cancellationToken);
|
||||
|
||||
// Send end-of-stream marker
|
||||
var endChunk = new StreamChunk
|
||||
{
|
||||
Data = [],
|
||||
SequenceNumber = _sequenceNumber++,
|
||||
EndOfStream = true
|
||||
};
|
||||
|
||||
await _writer.WriteAsync(endChunk, cancellationToken);
|
||||
_writer.Complete();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (!_disposed && disposing)
|
||||
{
|
||||
// Try to complete the stream if not already completed
|
||||
try
|
||||
{
|
||||
_writer.TryComplete();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore errors during disposal
|
||||
}
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask DisposeAsync()
|
||||
{
|
||||
if (!_disposed)
|
||||
{
|
||||
try
|
||||
{
|
||||
await CompleteAsync(CancellationToken.None);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore errors during disposal
|
||||
}
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
await base.DisposeAsync();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user