Add unit tests for Router configuration and transport layers
- Implemented tests for RouterConfig, RoutingOptions, StaticInstanceConfig, and RouterConfigOptions to ensure default values are set correctly. - Added tests for RouterConfigProvider to validate configurations and ensure defaults are returned when no file is specified. - Created tests for ConfigValidationResult to check success and error scenarios. - Developed tests for ServiceCollectionExtensions to verify service registration for RouterConfig. - Introduced UdpTransportTests to validate serialization, connection, request-response, and error handling in UDP transport. - Added scripts for signing authority gaps and hashing DevPortal SDK snippets.
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
148
src/__Libraries/StellaOps.Router.Common/Frames/FrameConverter.cs
Normal file
148
src/__Libraries/StellaOps.Router.Common/Frames/FrameConverter.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
85
src/__Libraries/StellaOps.Router.Common/PathMatcher.cs
Normal file
85
src/__Libraries/StellaOps.Router.Common/PathMatcher.cs
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user