audit, advisories and doctors/setup work

This commit is contained in:
master
2026-01-13 18:53:39 +02:00
parent 9ca7cb183e
commit d7be6ba34b
811 changed files with 54242 additions and 4056 deletions

View File

@@ -66,18 +66,26 @@ public sealed class EndpointOverrideMerger : IEndpointOverrideMerger
EndpointDescriptor codeDefault,
EndpointOverrideConfig yamlOverride)
{
var hasTimeout = yamlOverride.TryGetDefaultTimeoutAsTimeSpan(out var timeout, out var timeoutError);
if (timeoutError is not null)
{
_logger.LogWarning(
"Invalid defaultTimeout '{Timeout}' for endpoint {Method} {Path}. Override ignored.",
yamlOverride.DefaultTimeout,
yamlOverride.Method,
yamlOverride.Path);
}
var merged = codeDefault with
{
DefaultTimeout = yamlOverride.GetDefaultTimeoutAsTimeSpan() ?? codeDefault.DefaultTimeout,
DefaultTimeout = hasTimeout ? timeout : 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)
if (hasTimeout || yamlOverride.SupportsStreaming.HasValue || yamlOverride.RequiringClaims?.Count > 0)
{
_logger.LogDebug(
"Applied YAML overrides to endpoint {Method} {Path}: Timeout={Timeout}, Streaming={Streaming}, Claims={Claims}",

View File

@@ -141,27 +141,25 @@ public sealed class SchemaDetailEndpoint : IRawStellaEndpoint
path = "/" + path;
}
// Get direction from query string (default to request)
// Get direction from query parameters (default to request)
var direction = SchemaDirection.Request;
if (context.Headers.TryGetValue("X-Schema-Direction", out var directionHeader) &&
directionHeader is not null)
if (context.QueryParameters.TryGetValue("direction", out var directionQuery) &&
!string.IsNullOrWhiteSpace(directionQuery))
{
if (directionHeader.Equals("response", StringComparison.OrdinalIgnoreCase))
if (directionQuery.Equals("response", StringComparison.OrdinalIgnoreCase))
{
direction = SchemaDirection.Response;
}
else if (directionQuery.Equals("request", StringComparison.OrdinalIgnoreCase))
{
direction = SchemaDirection.Request;
}
}
// Check for query parameter (parsed from path or context)
// For simplicity, check if path contains ?direction=response
if (path.Contains("?direction=response", StringComparison.OrdinalIgnoreCase))
else if (context.Headers.TryGetValue("X-Schema-Direction", out var directionHeader) &&
directionHeader is not null &&
directionHeader.Equals("response", StringComparison.OrdinalIgnoreCase))
{
direction = SchemaDirection.Response;
path = path.Split('?')[0];
}
else if (path.Contains("?direction=request", StringComparison.OrdinalIgnoreCase))
{
path = path.Split('?')[0];
}
// Check if schema exists

View File

@@ -31,7 +31,8 @@ public sealed class GeneratedEndpointDiscoveryProvider : IEndpointDiscoveryProvi
_reflectionFallback = new ReflectionEndpointDiscoveryProvider(
options,
assemblies: null,
serviceProviderIsService: serviceProviderIsService);
serviceProviderIsService: serviceProviderIsService,
logger: logger);
}
/// <inheritdoc />

View File

@@ -8,18 +8,25 @@ namespace StellaOps.Microservice;
public sealed class HeaderCollection : IHeaderCollection
{
private readonly Dictionary<string, List<string>> _headers;
private readonly bool _readOnly;
/// <summary>
/// Gets an empty header collection.
/// </summary>
public static readonly HeaderCollection Empty = new();
public static readonly HeaderCollection Empty = new(isReadOnly: true);
/// <summary>
/// Initializes a new instance of the <see cref="HeaderCollection"/> class.
/// </summary>
public HeaderCollection()
: this(isReadOnly: false)
{
}
private HeaderCollection(bool isReadOnly)
{
_headers = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
_readOnly = isReadOnly;
}
/// <summary>
@@ -47,6 +54,7 @@ public sealed class HeaderCollection : IHeaderCollection
/// <param name="value">The header value.</param>
public void Add(string key, string value)
{
EnsureWritable();
if (!_headers.TryGetValue(key, out var values))
{
values = [];
@@ -62,6 +70,7 @@ public sealed class HeaderCollection : IHeaderCollection
/// <param name="value">The header value.</param>
public void Set(string key, string value)
{
EnsureWritable();
_headers[key] = [value];
}
@@ -99,4 +108,12 @@ public sealed class HeaderCollection : IHeaderCollection
}
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
private void EnsureWritable()
{
if (_readOnly)
{
throw new InvalidOperationException("HeaderCollection.Empty is read-only. Use a new HeaderCollection instance.");
}
}
}

View File

@@ -10,22 +10,35 @@ public sealed class MicroserviceHostedService : IHostedService
{
private readonly IRouterConnectionManager _connectionManager;
private readonly ILogger<MicroserviceHostedService> _logger;
private readonly SchemaProviderDiscoveryDiagnostics? _schemaDiagnostics;
/// <summary>
/// Initializes a new instance of the <see cref="MicroserviceHostedService"/> class.
/// </summary>
public MicroserviceHostedService(
IRouterConnectionManager connectionManager,
ILogger<MicroserviceHostedService> logger)
ILogger<MicroserviceHostedService> logger,
SchemaProviderDiscoveryDiagnostics? schemaDiagnostics = null)
{
_connectionManager = connectionManager;
_logger = logger;
_schemaDiagnostics = schemaDiagnostics;
}
/// <inheritdoc />
public async Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Starting Stella microservice");
if (_schemaDiagnostics?.Issues.Count > 0)
{
foreach (var issue in _schemaDiagnostics.Issues)
{
_logger.LogWarning(
"Schema provider discovery issue in {Assembly}: {Message}",
issue.AssemblyName,
issue.Message);
}
}
await _connectionManager.StartAsync(cancellationToken);
_logger.LogInformation("Stella microservice started");
}

View File

@@ -1,3 +1,4 @@
using System.Globalization;
using StellaOps.Router.Common.Models;
using YamlDotNet.Serialization;
@@ -55,33 +56,69 @@ public sealed class EndpointOverrideConfig
/// </summary>
public TimeSpan? GetDefaultTimeoutAsTimeSpan()
{
return TryGetDefaultTimeoutAsTimeSpan(out var timeout, out _) ? timeout : null;
}
/// <summary>
/// Parses the DefaultTimeout string to a TimeSpan with error output.
/// </summary>
public bool TryGetDefaultTimeoutAsTimeSpan(out TimeSpan timeout, out string? error)
{
timeout = default;
error = null;
if (string.IsNullOrWhiteSpace(DefaultTimeout))
return null;
{
return false;
}
// 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;
if (int.TryParse(value[..^1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var seconds))
{
timeout = TimeSpan.FromSeconds(seconds);
return true;
}
error = $"Invalid seconds value '{value}'.";
return false;
}
return null;
if (value.EndsWith("m", StringComparison.OrdinalIgnoreCase))
{
if (int.TryParse(value[..^1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var minutes))
{
timeout = TimeSpan.FromMinutes(minutes);
return true;
}
error = $"Invalid minutes value '{value}'.";
return false;
}
if (value.EndsWith("h", StringComparison.OrdinalIgnoreCase))
{
if (int.TryParse(value[..^1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var hours))
{
timeout = TimeSpan.FromHours(hours);
return true;
}
error = $"Invalid hours value '{value}'.";
return false;
}
if (TimeSpan.TryParse(value, CultureInfo.InvariantCulture, out var timespan))
{
timeout = timespan;
return true;
}
error = $"Invalid timeout format '{value}'.";
return false;
}
}

View File

@@ -1,5 +1,6 @@
using System.Reflection;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.Router.Common.Models;
namespace StellaOps.Microservice;
@@ -12,6 +13,7 @@ public sealed class ReflectionEndpointDiscoveryProvider : IEndpointDiscoveryProv
private readonly StellaMicroserviceOptions _options;
private readonly IEnumerable<Assembly> _assemblies;
private readonly IServiceProviderIsService? _serviceProviderIsService;
private readonly ILogger? _logger;
/// <summary>
/// Initializes a new instance of the <see cref="ReflectionEndpointDiscoveryProvider"/> class.
@@ -21,11 +23,13 @@ public sealed class ReflectionEndpointDiscoveryProvider : IEndpointDiscoveryProv
public ReflectionEndpointDiscoveryProvider(
StellaMicroserviceOptions options,
IEnumerable<Assembly>? assemblies = null,
IServiceProviderIsService? serviceProviderIsService = null)
IServiceProviderIsService? serviceProviderIsService = null,
ILogger? logger = null)
{
_options = options;
_assemblies = assemblies ?? AppDomain.CurrentDomain.GetAssemblies();
_serviceProviderIsService = serviceProviderIsService;
_logger = logger;
}
/// <inheritdoc />
@@ -72,9 +76,41 @@ public sealed class ReflectionEndpointDiscoveryProvider : IEndpointDiscoveryProv
endpoints.Add(descriptor);
}
}
catch (ReflectionTypeLoadException)
catch (ReflectionTypeLoadException ex)
{
// Skip assemblies that cannot be loaded
if (_logger is not null)
{
var assemblyName = assembly.FullName ?? assembly.GetName().Name ?? "unknown";
if (ex.LoaderExceptions is not null && ex.LoaderExceptions.Length > 0)
{
foreach (var loaderException in ex.LoaderExceptions)
{
if (loaderException is null)
{
continue;
}
_logger.LogWarning(
loaderException,
"Endpoint discovery skipped assembly {Assembly} due to type load error.",
assemblyName);
}
}
else
{
_logger.LogWarning(
ex,
"Endpoint discovery skipped assembly {Assembly} due to type load error.",
assemblyName);
}
}
}
catch (Exception ex)
{
_logger?.LogWarning(
ex,
"Endpoint discovery skipped assembly {Assembly} due to reflection error.",
assembly.FullName ?? assembly.GetName().Name ?? "unknown");
}
}

View File

@@ -202,11 +202,26 @@ public sealed class RequestDispatcher : IRequestDispatcher
{
try
{
var body = context.Body;
await using var bufferedBody = body != Stream.Null && !body.CanSeek
? new MemoryStream()
: null;
if (bufferedBody is not null)
{
await body.CopyToAsync(bufferedBody, cancellationToken);
bufferedBody.Position = 0;
body = bufferedBody;
}
var hasBody = body != Stream.Null && body.Length > 0;
// Schema validation (before deserialization)
if (_schemaRegistry is not null && _schemaValidator is not null &&
_schemaRegistry.HasSchema(context.Method, context.Path, SchemaDirection.Request))
{
if (context.Body == Stream.Null || context.Body.Length == 0)
if (!hasBody)
{
return ValidationProblemDetails.Create(
context.Method,
@@ -217,11 +232,14 @@ public sealed class RequestDispatcher : IRequestDispatcher
).ToRawResponse();
}
context.Body.Position = 0;
if (body.CanSeek)
{
body.Position = 0;
}
JsonDocument doc;
try
{
doc = await JsonDocument.ParseAsync(context.Body, cancellationToken: cancellationToken);
doc = await JsonDocument.ParseAsync(body, cancellationToken: cancellationToken);
}
catch (JsonException ex)
{
@@ -253,19 +271,25 @@ public sealed class RequestDispatcher : IRequestDispatcher
}
// Reset stream for deserialization
context.Body.Position = 0;
if (body.CanSeek)
{
body.Position = 0;
}
}
// Deserialize request (or bind from query/path params when body is empty).
object? request;
if (context.Body == Stream.Null || context.Body.Length == 0)
if (!hasBody)
{
request = CreateRequestFromParameters(requestType, context);
}
else
{
context.Body.Position = 0;
request = await JsonSerializer.DeserializeAsync(context.Body, requestType, _jsonOptions, cancellationToken);
if (body.CanSeek)
{
body.Position = 0;
}
request = await JsonSerializer.DeserializeAsync(body, requestType, _jsonOptions, cancellationToken);
if (request is not null)
{
@@ -559,7 +583,7 @@ public sealed class RequestDispatcher : IRequestDispatcher
{
RequestId = requestId,
StatusCode = statusCode,
Headers = headers.ToDictionary(h => h.Key, h => h.Value),
Headers = CollapseHeaders(headers),
Payload = System.Text.Encoding.UTF8.GetBytes(message)
};
}
@@ -578,7 +602,10 @@ public sealed class RequestDispatcher : IRequestDispatcher
else
{
using var ms = new MemoryStream();
response.Body.Position = 0;
if (response.Body.CanSeek)
{
response.Body.Position = 0;
}
await response.Body.CopyToAsync(ms, cancellationToken);
payload = ms.ToArray();
}
@@ -587,8 +614,32 @@ public sealed class RequestDispatcher : IRequestDispatcher
{
RequestId = requestId,
StatusCode = response.StatusCode,
Headers = response.Headers.ToDictionary(h => h.Key, h => h.Value),
Headers = CollapseHeaders(response.Headers),
Payload = payload
};
}
private static IReadOnlyDictionary<string, string> CollapseHeaders(
IEnumerable<KeyValuePair<string, string>> headers)
{
var grouped = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
foreach (var (key, value) in headers)
{
if (!grouped.TryGetValue(key, out var values))
{
values = [];
grouped[key] = values;
}
values.Add(value);
}
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var (key, values) in grouped)
{
result[key] = string.Join(",", values);
}
return result;
}
}

View File

@@ -0,0 +1,26 @@
namespace StellaOps.Microservice;
/// <summary>
/// Captures schema provider discovery issues for startup diagnostics.
/// </summary>
public sealed class SchemaProviderDiscoveryDiagnostics
{
/// <summary>
/// Initializes a new instance of the <see cref="SchemaProviderDiscoveryDiagnostics"/> class.
/// </summary>
/// <param name="issues">The discovery issues.</param>
public SchemaProviderDiscoveryDiagnostics(IReadOnlyList<SchemaProviderDiscoveryIssue> issues)
{
Issues = issues ?? throw new ArgumentNullException(nameof(issues));
}
/// <summary>
/// Gets the discovery issues.
/// </summary>
public IReadOnlyList<SchemaProviderDiscoveryIssue> Issues { get; }
}
/// <summary>
/// Represents a schema provider discovery issue.
/// </summary>
public sealed record SchemaProviderDiscoveryIssue(string AssemblyName, string Message);

View File

@@ -1,3 +1,4 @@
using System.Reflection;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
@@ -20,48 +21,10 @@ public static class ServiceCollectionExtensions
this IServiceCollection services,
Action<StellaMicroserviceOptions> configure)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configure);
// 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 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 discoveryService = sp.GetRequiredService<IEndpointDiscoveryService>();
var registry = new EndpointRegistry();
registry.RegisterAll(discoveryService.DiscoverEndpoints());
return registry;
});
// Register request dispatcher
services.TryAddSingleton<RequestDispatcher>();
services.TryAddSingleton<IRequestDispatcher>(sp => sp.GetRequiredService<RequestDispatcher>());
// Register schema validation services
RegisterSchemaValidationServices(services);
// Register connection manager
services.TryAddSingleton<IRouterConnectionManager, RouterConnectionManager>();
// Register hosted service
services.AddHostedService<MicroserviceHostedService>();
return services;
return AddStellaMicroserviceInternal(
services,
configure,
registration => registration.TryAddSingleton<IEndpointDiscoveryProvider, GeneratedEndpointDiscoveryProvider>());
}
/// <summary>
@@ -76,48 +39,10 @@ public static class ServiceCollectionExtensions
Action<StellaMicroserviceOptions> configure)
where TDiscovery : class, IEndpointDiscoveryProvider
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configure);
// 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>();
services.TryAddSingleton<IRequestDispatcher>(sp => sp.GetRequiredService<RequestDispatcher>());
// Register schema validation services
RegisterSchemaValidationServices(services);
// Register connection manager
services.TryAddSingleton<IRouterConnectionManager, RouterConnectionManager>();
// Register hosted service
services.AddHostedService<MicroserviceHostedService>();
return services;
return AddStellaMicroserviceInternal(
services,
configure,
registration => registration.TryAddSingleton<IEndpointDiscoveryProvider, TDiscovery>());
}
/// <summary>
@@ -146,29 +71,150 @@ public static class ServiceCollectionExtensions
return services;
}
private static IServiceCollection AddStellaMicroserviceInternal(
IServiceCollection services,
Action<StellaMicroserviceOptions> configure,
Action<IServiceCollection> registerDiscoveryProvider)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configure);
ArgumentNullException.ThrowIfNull(registerDiscoveryProvider);
// Configure and register options as singleton
var options = new StellaMicroserviceOptions { ServiceName = "", Version = "1.0.0", Region = "" };
configure(options);
services.AddSingleton(options);
services.AddOptions<StellaMicroserviceOptions>()
.Configure(configure)
.Validate(
microserviceOptions =>
{
try
{
microserviceOptions.Validate();
return true;
}
catch
{
return false;
}
},
"Stella microservice options validation failed.")
.ValidateOnStart();
// Register YAML loader and merger
services.TryAddSingleton<IMicroserviceYamlLoader, MicroserviceYamlLoader>();
services.TryAddSingleton<IEndpointOverrideMerger, EndpointOverrideMerger>();
// Register endpoint discovery provider
registerDiscoveryProvider(services);
// 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>();
services.TryAddSingleton<IRequestDispatcher>(sp => sp.GetRequiredService<RequestDispatcher>());
// Register schema validation services
RegisterSchemaValidationServices(services);
// Register validator and registry
services.TryAddSingleton<IRequestSchemaValidator, RequestSchemaValidator>();
services.TryAddSingleton<ISchemaRegistry, SchemaRegistry>();
// Register connection manager
services.TryAddSingleton<IRouterConnectionManager, RouterConnectionManager>();
// Register hosted service
services.AddHostedService<MicroserviceHostedService>();
return services;
}
private static void RegisterSchemaValidationServices(IServiceCollection services)
{
var issues = new List<SchemaProviderDiscoveryIssue>();
// Try to find and register generated schema provider (if source gen was used)
// The generated provider will be named "GeneratedSchemaProvider" in the namespace
// "StellaOps.Microservice.Generated"
var generatedType = AppDomain.CurrentDomain.GetAssemblies()
.SelectMany(a =>
{
try { return a.GetTypes(); }
catch { return []; }
})
.FirstOrDefault(t =>
t.Name == "GeneratedSchemaProvider" &&
typeof(IGeneratedSchemaProvider).IsAssignableFrom(t) &&
!t.IsAbstract);
var generatedType = FindGeneratedSchemaProvider(issues);
if (generatedType is not null)
{
services.TryAddSingleton(typeof(IGeneratedSchemaProvider), generatedType);
}
// Register validator and registry
services.TryAddSingleton<IRequestSchemaValidator, RequestSchemaValidator>();
services.TryAddSingleton<ISchemaRegistry, SchemaRegistry>();
if (issues.Count > 0)
{
services.TryAddSingleton(new SchemaProviderDiscoveryDiagnostics(issues));
}
}
private static Type? FindGeneratedSchemaProvider(List<SchemaProviderDiscoveryIssue> issues)
{
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
{
Type[] types;
try
{
types = assembly.GetTypes();
}
catch (ReflectionTypeLoadException ex)
{
types = ex.Types.Where(type => type is not null).Select(type => type!).ToArray();
var assemblyName = assembly.FullName ?? assembly.GetName().Name ?? "unknown";
if (ex.LoaderExceptions is not null && ex.LoaderExceptions.Length > 0)
{
foreach (var loaderException in ex.LoaderExceptions)
{
if (loaderException is null)
{
continue;
}
issues.Add(new SchemaProviderDiscoveryIssue(assemblyName, loaderException.Message));
}
}
else
{
issues.Add(new SchemaProviderDiscoveryIssue(assemblyName, ex.Message));
}
}
catch (Exception ex)
{
var assemblyName = assembly.FullName ?? assembly.GetName().Name ?? "unknown";
issues.Add(new SchemaProviderDiscoveryIssue(assemblyName, ex.Message));
continue;
}
foreach (var type in types)
{
if (type is null)
{
continue;
}
if (type.Name == "GeneratedSchemaProvider" &&
typeof(IGeneratedSchemaProvider).IsAssignableFrom(type) &&
!type.IsAbstract)
{
return type;
}
}
}
return null;
}
}

View File

@@ -7,4 +7,5 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| --- | --- | --- |
| AUDIT-0387-M | DONE | Revalidated 2026-01-07; maintainability audit for StellaOps.Microservice. |
| AUDIT-0387-T | DONE | Revalidated 2026-01-07; test coverage audit for StellaOps.Microservice. |
| AUDIT-0387-A | TODO | Revalidated 2026-01-07 (open findings). |
| AUDIT-0387-A | DONE | Applied 2026-01-13; superseded by AUDIT-0598-A. |
| AUDIT-0598-A | DONE | Applied 2026-01-13; hotlist fixes and tests. |

View File

@@ -33,7 +33,7 @@ public static class TypedEndpointAdapter
{
// Deserialize request
TRequest? request;
if (context.Body == Stream.Null || context.Body.Length == 0)
if (context.Body == Stream.Null || (context.Body.CanSeek && context.Body.Length == 0))
{
request = default;
}

View File

@@ -0,0 +1,93 @@
using Microsoft.Extensions.Logging;
using Moq;
using StellaOps.Router.Common.Models;
using StellaOps.TestKit;
namespace StellaOps.Microservice.Tests;
/// <summary>
/// Unit tests for <see cref="EndpointOverrideMerger"/>.
/// </summary>
public sealed class EndpointOverrideMergerTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Merge_InvalidTimeout_LogsWarningAndKeepsDefault()
{
var logger = new Mock<ILogger<EndpointOverrideMerger>>();
var merger = new EndpointOverrideMerger(logger.Object);
var endpoint = new EndpointDescriptor
{
ServiceName = "svc",
Version = "1.0.0",
Method = "GET",
Path = "/ping",
DefaultTimeout = TimeSpan.FromSeconds(10)
};
var config = new MicroserviceYamlConfig
{
Endpoints =
[
new EndpointOverrideConfig
{
Method = "GET",
Path = "/ping",
DefaultTimeout = "bogus"
}
]
};
var merged = merger.Merge([endpoint], config);
merged[0].DefaultTimeout.Should().Be(TimeSpan.FromSeconds(10));
VerifyWarning(logger, "Invalid defaultTimeout");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Merge_ValidTimeout_OverridesDefault()
{
var logger = new Mock<ILogger<EndpointOverrideMerger>>();
var merger = new EndpointOverrideMerger(logger.Object);
var endpoint = new EndpointDescriptor
{
ServiceName = "svc",
Version = "1.0.0",
Method = "GET",
Path = "/ping",
DefaultTimeout = TimeSpan.FromSeconds(10)
};
var config = new MicroserviceYamlConfig
{
Endpoints =
[
new EndpointOverrideConfig
{
Method = "GET",
Path = "/ping",
DefaultTimeout = "30s"
}
]
};
var merged = merger.Merge([endpoint], config);
merged[0].DefaultTimeout.Should().Be(TimeSpan.FromSeconds(30));
}
private static void VerifyWarning(Mock<ILogger<EndpointOverrideMerger>> logger, string messageContains)
{
logger.Verify(
log => log.Log(
LogLevel.Warning,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((value, _) => value.ToString()!.Contains(messageContains, StringComparison.Ordinal)),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
}

View File

@@ -83,6 +83,34 @@ public sealed class HeaderCollectionTests
empty.Should().BeEmpty();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Empty_Add_Throws()
{
// Arrange
var empty = HeaderCollection.Empty;
// Act
var action = () => empty.Add("X-Test", "value");
// Assert
action.Should().Throw<InvalidOperationException>();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Empty_Set_Throws()
{
// Arrange
var empty = HeaderCollection.Empty;
// Act
var action = () => empty.Set("X-Test", "value");
// Assert
action.Should().Throw<InvalidOperationException>();
}
#endregion
#region Indexer Tests

View File

@@ -0,0 +1,147 @@
using System.Text;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Router.Common.Frames;
using StellaOps.Router.Common.Models;
using StellaOps.TestKit;
namespace StellaOps.Microservice.Tests;
/// <summary>
/// Unit tests for <see cref="RequestDispatcher"/>.
/// </summary>
public sealed class RequestDispatcherTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task DispatchAsync_MergesMultiValueHeaders()
{
var dispatcher = CreateDispatcher(typeof(MultiHeaderEndpoint));
var request = new RequestFrame
{
RequestId = "req-1",
Method = "GET",
Path = "/test",
Headers = new Dictionary<string, string>(),
Payload = ReadOnlyMemory<byte>.Empty
};
var response = await dispatcher.DispatchAsync(request, CancellationToken.None);
response.Headers.Should().ContainKey("X-Test");
response.Headers["X-Test"].Should().Be("one,two");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task DispatchAsync_AllowsNonSeekableResponseBody()
{
var dispatcher = CreateDispatcher(typeof(NonSeekableEndpoint));
var request = new RequestFrame
{
RequestId = "req-2",
Method = "GET",
Path = "/test",
Headers = new Dictionary<string, string>(),
Payload = ReadOnlyMemory<byte>.Empty
};
var response = await dispatcher.DispatchAsync(request, CancellationToken.None);
Encoding.UTF8.GetString(response.Payload.ToArray()).Should().Be("ok");
}
private static RequestDispatcher CreateDispatcher(Type handlerType)
{
var registry = new EndpointRegistry();
registry.Register(new EndpointDescriptor
{
ServiceName = "svc",
Version = "1.0.0",
Method = "GET",
Path = "/test",
HandlerType = handlerType
});
var services = new ServiceCollection();
services.AddScoped(handlerType);
var provider = services.BuildServiceProvider();
return new RequestDispatcher(registry, provider, NullLogger<RequestDispatcher>.Instance);
}
private sealed class MultiHeaderEndpoint : IRawStellaEndpoint
{
public Task<RawResponse> HandleAsync(RawRequestContext context, CancellationToken cancellationToken)
{
var headers = new HeaderCollection();
headers.Add("X-Test", "one");
headers.Add("X-Test", "two");
return Task.FromResult(new RawResponse
{
StatusCode = 200,
Headers = headers,
Body = new MemoryStream(Encoding.UTF8.GetBytes("ok"))
});
}
}
private sealed class NonSeekableEndpoint : IRawStellaEndpoint
{
public Task<RawResponse> HandleAsync(RawRequestContext context, CancellationToken cancellationToken)
{
return Task.FromResult(new RawResponse
{
StatusCode = 200,
Headers = new HeaderCollection(),
Body = new NonSeekableReadStream(Encoding.UTF8.GetBytes("ok"))
});
}
}
private sealed class NonSeekableReadStream : Stream
{
private readonly MemoryStream _inner;
public NonSeekableReadStream(byte[] data)
{
_inner = new MemoryStream(data, writable: false);
}
public override bool CanRead => true;
public override bool CanSeek => false;
public override bool CanWrite => false;
public override long Length => throw new NotSupportedException();
public override long Position
{
get => throw new NotSupportedException();
set => throw new NotSupportedException();
}
public override void Flush()
{
}
public override int Read(byte[] buffer, int offset, int count)
{
return _inner.Read(buffer, offset, count);
}
public override long Seek(long offset, SeekOrigin origin)
{
throw new NotSupportedException();
}
public override void SetLength(long value)
{
throw new NotSupportedException();
}
public override void Write(byte[] buffer, int offset, int count)
{
throw new NotSupportedException();
}
}
}

View File

@@ -0,0 +1,65 @@
using System.Text;
using StellaOps.Microservice.Endpoints;
using StellaOps.Microservice.Validation;
using StellaOps.TestKit;
namespace StellaOps.Microservice.Tests;
/// <summary>
/// Unit tests for schema discovery endpoints.
/// </summary>
public sealed class SchemaDiscoveryEndpointsTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task SchemaDetailEndpoint_UsesQueryParametersDirection()
{
var registry = new FakeSchemaRegistry();
var endpoint = new SchemaDetailEndpoint(registry);
var context = new RawRequestContext
{
PathParameters = new Dictionary<string, string>
{
["method"] = "get",
["path"] = "api/widgets"
},
QueryParameters = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["direction"] = "response"
},
Headers = new HeaderCollection()
};
var response = await endpoint.HandleAsync(context, CancellationToken.None);
response.StatusCode.Should().Be(200);
using var reader = new StreamReader(response.Body, Encoding.UTF8);
reader.ReadToEnd().Should().Be(FakeSchemaRegistry.ResponseSchema);
}
private sealed class FakeSchemaRegistry : ISchemaRegistry
{
public const string RequestSchema = "{\"type\":\"object\",\"title\":\"request\"}";
public const string ResponseSchema = "{\"type\":\"object\",\"title\":\"response\"}";
public Json.Schema.JsonSchema? GetRequestSchema(string method, string path) => null;
public Json.Schema.JsonSchema? GetResponseSchema(string method, string path) => null;
public string? GetSchemaText(string method, string path, SchemaDirection direction)
{
return direction == SchemaDirection.Response ? ResponseSchema : RequestSchema;
}
public string? GetSchemaETag(string method, string path, SchemaDirection direction) => null;
public bool HasSchema(string method, string path, SchemaDirection direction)
{
return method.Equals("GET", StringComparison.OrdinalIgnoreCase) &&
path.Equals("/api/widgets", StringComparison.Ordinal);
}
public IReadOnlyList<EndpointSchemaDefinition> GetAllSchemas() => [];
}
}

View File

@@ -0,0 +1,63 @@
using System.Threading.Channels;
using StellaOps.Microservice.Streaming;
using StellaOps.TestKit;
namespace StellaOps.Microservice.Tests;
/// <summary>
/// Unit tests for streaming request/response streams.
/// </summary>
public sealed class StreamingStreamsTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task StreamingRequestBodyStream_ReadsChunksInOrder()
{
var channel = Channel.CreateUnbounded<StreamChunk>();
await channel.Writer.WriteAsync(new StreamChunk { Data = [1, 2], SequenceNumber = 0 });
await channel.Writer.WriteAsync(new StreamChunk { Data = [3, 4], SequenceNumber = 1, EndOfStream = true });
channel.Writer.Complete();
var stream = new StreamingRequestBodyStream(channel.Reader, CancellationToken.None);
var buffer = new byte[8];
var total = 0;
int read;
while ((read = await stream.ReadAsync(buffer.AsMemory(total), CancellationToken.None)) > 0)
{
total += read;
}
buffer.Take(total).Should().BeEquivalentTo(new byte[] { 1, 2, 3, 4 }, options => options.WithStrictOrdering());
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task StreamingResponseBodyStream_WritesChunksAndCompletes()
{
var channel = Channel.CreateUnbounded<StreamChunk>();
var stream = new StreamingResponseBodyStream(channel.Writer, chunkSize: 2, cancellationToken: CancellationToken.None);
await stream.WriteAsync(new byte[] { 1, 2, 3 }, 0, 3, CancellationToken.None);
await stream.CompleteAsync();
var chunks = new List<StreamChunk>();
while (await channel.Reader.WaitToReadAsync())
{
while (channel.Reader.TryRead(out var chunk))
{
chunks.Add(chunk);
if (chunk.EndOfStream)
{
break;
}
}
}
chunks.Should().NotBeEmpty();
chunks[^1].EndOfStream.Should().BeTrue();
var data = chunks.Where(c => c.Data.Length > 0).SelectMany(c => c.Data).ToArray();
data.Should().BeEquivalentTo(new byte[] { 1, 2, 3 }, options => options.WithStrictOrdering());
}
}

View File

@@ -0,0 +1,84 @@
using System.Text;
using StellaOps.TestKit;
namespace StellaOps.Microservice.Tests;
/// <summary>
/// Unit tests for <see cref="TypedEndpointAdapter"/>.
/// </summary>
public sealed class TypedEndpointAdapterTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Adapt_TypedEndpoint_AllowsNonSeekableBody()
{
var handler = TypedEndpointAdapter.Adapt<EchoRequest, EchoResponse>(new EchoEndpoint());
var payload = Encoding.UTF8.GetBytes("{\"name\":\"Ada\"}");
var context = new RawRequestContext
{
Body = new NonSeekableReadStream(payload)
};
var response = await handler(context, CancellationToken.None);
using var reader = new StreamReader(response.Body, Encoding.UTF8);
reader.ReadToEnd().Should().Contain("\"name\":\"Ada\"");
}
private sealed record EchoRequest(string Name);
private sealed record EchoResponse(string Name);
private sealed class EchoEndpoint : IStellaEndpoint<EchoRequest, EchoResponse>
{
public Task<EchoResponse> HandleAsync(EchoRequest request, CancellationToken cancellationToken)
{
return Task.FromResult(new EchoResponse(request.Name));
}
}
private sealed class NonSeekableReadStream : Stream
{
private readonly MemoryStream _inner;
public NonSeekableReadStream(byte[] data)
{
_inner = new MemoryStream(data, writable: false);
}
public override bool CanRead => true;
public override bool CanSeek => false;
public override bool CanWrite => false;
public override long Length => throw new NotSupportedException();
public override long Position
{
get => throw new NotSupportedException();
set => throw new NotSupportedException();
}
public override void Flush()
{
}
public override int Read(byte[] buffer, int offset, int count)
{
return _inner.Read(buffer, offset, count);
}
public override long Seek(long offset, SeekOrigin origin)
{
throw new NotSupportedException();
}
public override void SetLength(long value)
{
throw new NotSupportedException();
}
public override void Write(byte[] buffer, int offset, int count)
{
throw new NotSupportedException();
}
}
}