Add unit tests for Router configuration and transport layers
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled

- 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:
StellaOps Bot
2025-12-05 08:01:47 +02:00
parent 635c70e828
commit 6a299d231f
294 changed files with 28434 additions and 1329 deletions

View File

@@ -7,6 +7,38 @@ namespace StellaOps.Router.Common.Abstractions;
/// </summary>
public interface IGlobalRoutingState
{
/// <summary>
/// Adds a connection to the routing state.
/// </summary>
/// <param name="connection">The connection state to add.</param>
void AddConnection(ConnectionState connection);
/// <summary>
/// Removes a connection from the routing state.
/// </summary>
/// <param name="connectionId">The connection ID to remove.</param>
void RemoveConnection(string connectionId);
/// <summary>
/// Updates an existing connection's state.
/// </summary>
/// <param name="connectionId">The connection ID to update.</param>
/// <param name="update">The update action to apply.</param>
void UpdateConnection(string connectionId, Action<ConnectionState> update);
/// <summary>
/// Gets a connection by its ID.
/// </summary>
/// <param name="connectionId">The connection ID.</param>
/// <returns>The connection state, or null if not found.</returns>
ConnectionState? GetConnection(string connectionId);
/// <summary>
/// Gets all active connections.
/// </summary>
/// <returns>All active connections.</returns>
IReadOnlyList<ConnectionState> GetAllConnections();
/// <summary>
/// Resolves an HTTP request to an endpoint descriptor.
/// </summary>

View File

@@ -0,0 +1,43 @@
using StellaOps.Router.Common.Models;
namespace StellaOps.Router.Common.Abstractions;
/// <summary>
/// Represents a transport connection from a microservice to the gateway.
/// This interface is used by the Microservice SDK to communicate with the router.
/// </summary>
public interface IMicroserviceTransport
{
/// <summary>
/// Connects to the router and registers the microservice.
/// </summary>
/// <param name="instance">The instance descriptor.</param>
/// <param name="endpoints">The endpoints to register.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task ConnectAsync(
InstanceDescriptor instance,
IReadOnlyList<EndpointDescriptor> endpoints,
CancellationToken cancellationToken);
/// <summary>
/// Disconnects from the router.
/// </summary>
Task DisconnectAsync();
/// <summary>
/// Sends a heartbeat to the router.
/// </summary>
/// <param name="heartbeat">The heartbeat payload.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task SendHeartbeatAsync(HeartbeatPayload heartbeat, CancellationToken cancellationToken);
/// <summary>
/// Event raised when a REQUEST frame is received from the gateway.
/// </summary>
event Func<Frame, CancellationToken, Task<Frame>>? OnRequestReceived;
/// <summary>
/// Event raised when a CANCEL frame is received from the gateway.
/// </summary>
event Func<Guid, string?, Task>? OnCancelReceived;
}

View File

@@ -0,0 +1,148 @@
using System.Text.Json;
using StellaOps.Router.Common.Enums;
using StellaOps.Router.Common.Models;
namespace StellaOps.Router.Common.Frames;
/// <summary>
/// Converts between generic Frame and typed frame records.
/// </summary>
public static class FrameConverter
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
/// <summary>
/// Converts a RequestFrame to a generic Frame for transport.
/// </summary>
public static Frame ToFrame(RequestFrame request)
{
var envelope = new RequestEnvelope
{
RequestId = request.RequestId,
Method = request.Method,
Path = request.Path,
Headers = request.Headers,
TimeoutSeconds = request.TimeoutSeconds,
SupportsStreaming = request.SupportsStreaming,
Payload = request.Payload.ToArray()
};
var envelopeBytes = JsonSerializer.SerializeToUtf8Bytes(envelope, JsonOptions);
return new Frame
{
Type = FrameType.Request,
CorrelationId = request.CorrelationId ?? request.RequestId,
Payload = envelopeBytes
};
}
/// <summary>
/// Converts a generic Frame to a RequestFrame.
/// </summary>
public static RequestFrame? ToRequestFrame(Frame frame)
{
if (frame.Type != FrameType.Request)
return null;
try
{
var envelope = JsonSerializer.Deserialize<RequestEnvelope>(frame.Payload.Span, JsonOptions);
if (envelope is null)
return null;
return new RequestFrame
{
RequestId = envelope.RequestId,
CorrelationId = frame.CorrelationId,
Method = envelope.Method,
Path = envelope.Path,
Headers = envelope.Headers ?? new Dictionary<string, string>(),
TimeoutSeconds = envelope.TimeoutSeconds,
SupportsStreaming = envelope.SupportsStreaming,
Payload = envelope.Payload ?? []
};
}
catch (JsonException)
{
return null;
}
}
/// <summary>
/// Converts a ResponseFrame to a generic Frame for transport.
/// </summary>
public static Frame ToFrame(ResponseFrame response)
{
var envelope = new ResponseEnvelope
{
RequestId = response.RequestId,
StatusCode = response.StatusCode,
Headers = response.Headers,
HasMoreChunks = response.HasMoreChunks,
Payload = response.Payload.ToArray()
};
var envelopeBytes = JsonSerializer.SerializeToUtf8Bytes(envelope, JsonOptions);
return new Frame
{
Type = FrameType.Response,
CorrelationId = response.RequestId,
Payload = envelopeBytes
};
}
/// <summary>
/// Converts a generic Frame to a ResponseFrame.
/// </summary>
public static ResponseFrame? ToResponseFrame(Frame frame)
{
if (frame.Type != FrameType.Response)
return null;
try
{
var envelope = JsonSerializer.Deserialize<ResponseEnvelope>(frame.Payload.Span, JsonOptions);
if (envelope is null)
return null;
return new ResponseFrame
{
RequestId = envelope.RequestId,
StatusCode = envelope.StatusCode,
Headers = envelope.Headers ?? new Dictionary<string, string>(),
HasMoreChunks = envelope.HasMoreChunks,
Payload = envelope.Payload ?? []
};
}
catch (JsonException)
{
return null;
}
}
private sealed class RequestEnvelope
{
public required string RequestId { get; set; }
public required string Method { get; set; }
public required string Path { get; set; }
public IReadOnlyDictionary<string, string>? Headers { get; set; }
public int TimeoutSeconds { get; set; } = 30;
public bool SupportsStreaming { get; set; }
public byte[]? Payload { get; set; }
}
private sealed class ResponseEnvelope
{
public required string RequestId { get; set; }
public int StatusCode { get; set; } = 200;
public IReadOnlyDictionary<string, string>? Headers { get; set; }
public bool HasMoreChunks { get; set; }
public byte[]? Payload { get; set; }
}
}

View File

@@ -0,0 +1,47 @@
namespace StellaOps.Router.Common.Frames;
/// <summary>
/// Represents a REQUEST frame sent from gateway to microservice.
/// </summary>
public sealed record RequestFrame
{
/// <summary>
/// Gets the unique request ID for this request.
/// </summary>
public required string RequestId { get; init; }
/// <summary>
/// Gets the correlation ID for distributed tracing.
/// </summary>
public string? CorrelationId { get; init; }
/// <summary>
/// Gets the HTTP method (GET, POST, PUT, DELETE, etc.).
/// </summary>
public required string Method { get; init; }
/// <summary>
/// Gets the request path.
/// </summary>
public required string Path { get; init; }
/// <summary>
/// Gets the request headers.
/// </summary>
public IReadOnlyDictionary<string, string> Headers { get; init; } = new Dictionary<string, string>();
/// <summary>
/// Gets the request payload (body).
/// </summary>
public ReadOnlyMemory<byte> Payload { get; init; }
/// <summary>
/// Gets the timeout in seconds for this request.
/// </summary>
public int TimeoutSeconds { get; init; } = 30;
/// <summary>
/// Gets whether this request supports streaming response.
/// </summary>
public bool SupportsStreaming { get; init; }
}

View File

@@ -0,0 +1,32 @@
namespace StellaOps.Router.Common.Frames;
/// <summary>
/// Represents a RESPONSE frame sent from microservice to gateway.
/// </summary>
public sealed record ResponseFrame
{
/// <summary>
/// Gets the request ID this response is for.
/// </summary>
public required string RequestId { get; init; }
/// <summary>
/// Gets the HTTP status code.
/// </summary>
public int StatusCode { get; init; } = 200;
/// <summary>
/// Gets the response headers.
/// </summary>
public IReadOnlyDictionary<string, string> Headers { get; init; } = new Dictionary<string, string>();
/// <summary>
/// Gets the response payload (body).
/// </summary>
public ReadOnlyMemory<byte> Payload { get; init; }
/// <summary>
/// Gets whether there are more streaming chunks to follow.
/// </summary>
public bool HasMoreChunks { get; init; }
}

View File

@@ -10,3 +10,34 @@ public sealed record CancelPayload
/// </summary>
public string? Reason { get; init; }
}
/// <summary>
/// Standard reasons for request cancellation.
/// </summary>
public static class CancelReasons
{
/// <summary>
/// The HTTP client disconnected before the request completed.
/// </summary>
public const string ClientDisconnected = "ClientDisconnected";
/// <summary>
/// The request exceeded its timeout.
/// </summary>
public const string Timeout = "Timeout";
/// <summary>
/// The request or response payload exceeded configured limits.
/// </summary>
public const string PayloadLimitExceeded = "PayloadLimitExceeded";
/// <summary>
/// The gateway or microservice is shutting down.
/// </summary>
public const string Shutdown = "Shutdown";
/// <summary>
/// The transport connection was closed unexpectedly.
/// </summary>
public const string ConnectionClosed = "ConnectionClosed";
}

View File

@@ -39,4 +39,10 @@ public sealed record EndpointDescriptor
/// Gets a value indicating whether this endpoint supports streaming.
/// </summary>
public bool SupportsStreaming { get; init; }
/// <summary>
/// Gets the handler type that processes requests for this endpoint.
/// This is used by the Microservice SDK for handler resolution.
/// </summary>
public Type? HandlerType { get; init; }
}

View File

@@ -0,0 +1,27 @@
namespace StellaOps.Router.Common.Models;
/// <summary>
/// Payload for streaming data frames (REQUEST_STREAM_DATA/RESPONSE_STREAM_DATA).
/// </summary>
public sealed record StreamDataPayload
{
/// <summary>
/// Gets the correlation ID linking stream data to the original request.
/// </summary>
public required Guid CorrelationId { get; init; }
/// <summary>
/// Gets the stream data chunk.
/// </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 for ordering.
/// </summary>
public int SequenceNumber { get; init; }
}

View File

@@ -0,0 +1,36 @@
namespace StellaOps.Router.Common.Models;
/// <summary>
/// Configuration options for streaming operations.
/// </summary>
public sealed record StreamingOptions
{
/// <summary>
/// Gets the default streaming options.
/// </summary>
public static readonly StreamingOptions Default = new();
/// <summary>
/// Gets the size of each chunk when streaming data.
/// Default: 64 KB.
/// </summary>
public int ChunkSize { get; init; } = 64 * 1024;
/// <summary>
/// Gets the maximum number of concurrent streams per connection.
/// Default: 100.
/// </summary>
public int MaxConcurrentStreams { get; init; } = 100;
/// <summary>
/// Gets the timeout for idle streams (no data flowing).
/// Default: 5 minutes.
/// </summary>
public TimeSpan StreamIdleTimeout { get; init; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Gets the channel capacity for buffered stream data.
/// Default: 16 chunks.
/// </summary>
public int ChannelCapacity { get; init; } = 16;
}

View File

@@ -0,0 +1,85 @@
using System.Text.RegularExpressions;
namespace StellaOps.Router.Common;
/// <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();
}