audit, advisories and doctors/setup work
This commit is contained in:
@@ -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}",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -31,7 +31,8 @@ public sealed class GeneratedEndpointDiscoveryProvider : IEndpointDiscoveryProvi
|
||||
_reflectionFallback = new ReflectionEndpointDiscoveryProvider(
|
||||
options,
|
||||
assemblies: null,
|
||||
serviceProviderIsService: serviceProviderIsService);
|
||||
serviceProviderIsService: serviceProviderIsService,
|
||||
logger: logger);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() => [];
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user