Implement InMemory Transport Layer for StellaOps Router
- Added InMemoryTransportOptions class for configuration settings including timeouts and latency. - Developed InMemoryTransportServer class to handle connections, frame processing, and event management. - Created ServiceCollectionExtensions for easy registration of InMemory transport services. - Established project structure and dependencies for InMemory transport library. - Implemented comprehensive unit tests for endpoint discovery, connection management, request/response flow, and streaming capabilities. - Ensured proper handling of cancellation, heartbeat, and hello frames within the transport layer.
This commit is contained in:
75
src/__Libraries/StellaOps.Microservice/EndpointRegistry.cs
Normal file
75
src/__Libraries/StellaOps.Microservice/EndpointRegistry.cs
Normal file
@@ -0,0 +1,75 @@
|
||||
namespace StellaOps.Microservice;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of endpoint registry using path matchers.
|
||||
/// </summary>
|
||||
public sealed class EndpointRegistry : IEndpointRegistry
|
||||
{
|
||||
private readonly List<RegisteredEndpoint> _endpoints = [];
|
||||
private readonly bool _caseInsensitive;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="EndpointRegistry"/> class.
|
||||
/// </summary>
|
||||
/// <param name="caseInsensitive">Whether path matching should be case-insensitive.</param>
|
||||
public EndpointRegistry(bool caseInsensitive = true)
|
||||
{
|
||||
_caseInsensitive = caseInsensitive;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers an endpoint descriptor.
|
||||
/// </summary>
|
||||
/// <param name="endpoint">The endpoint descriptor to register.</param>
|
||||
public void Register(EndpointDescriptor endpoint)
|
||||
{
|
||||
var matcher = new PathMatcher(endpoint.Path, _caseInsensitive);
|
||||
_endpoints.Add(new RegisteredEndpoint(endpoint, matcher));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers multiple endpoint descriptors.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The endpoint descriptors to register.</param>
|
||||
public void RegisterAll(IEnumerable<EndpointDescriptor> endpoints)
|
||||
{
|
||||
foreach (var endpoint in endpoints)
|
||||
{
|
||||
Register(endpoint);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryMatch(string method, string path, out EndpointMatch? match)
|
||||
{
|
||||
match = null;
|
||||
|
||||
foreach (var registered in _endpoints)
|
||||
{
|
||||
// Check method match (case-insensitive)
|
||||
if (!string.Equals(registered.Endpoint.Method, method, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
// Check path match
|
||||
if (registered.Matcher.TryMatch(path, out var parameters))
|
||||
{
|
||||
match = new EndpointMatch
|
||||
{
|
||||
Endpoint = registered.Endpoint,
|
||||
PathParameters = parameters
|
||||
};
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<EndpointDescriptor> GetAllEndpoints()
|
||||
{
|
||||
return _endpoints.Select(e => e.Endpoint).ToList();
|
||||
}
|
||||
|
||||
private sealed record RegisteredEndpoint(EndpointDescriptor Endpoint, PathMatcher Matcher);
|
||||
}
|
||||
102
src/__Libraries/StellaOps.Microservice/HeaderCollection.cs
Normal file
102
src/__Libraries/StellaOps.Microservice/HeaderCollection.cs
Normal file
@@ -0,0 +1,102 @@
|
||||
using System.Collections;
|
||||
|
||||
namespace StellaOps.Microservice;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of header collection.
|
||||
/// </summary>
|
||||
public sealed class HeaderCollection : IHeaderCollection
|
||||
{
|
||||
private readonly Dictionary<string, List<string>> _headers;
|
||||
|
||||
/// <summary>
|
||||
/// Gets an empty header collection.
|
||||
/// </summary>
|
||||
public static readonly HeaderCollection Empty = new();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="HeaderCollection"/> class.
|
||||
/// </summary>
|
||||
public HeaderCollection()
|
||||
{
|
||||
_headers = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance from key-value pairs.
|
||||
/// </summary>
|
||||
public HeaderCollection(IEnumerable<KeyValuePair<string, string>> headers)
|
||||
: this()
|
||||
{
|
||||
foreach (var kvp in headers)
|
||||
{
|
||||
Add(kvp.Key, kvp.Value);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? this[string key]
|
||||
{
|
||||
get => _headers.TryGetValue(key, out var values) && values.Count > 0 ? values[0] : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a header value.
|
||||
/// </summary>
|
||||
/// <param name="key">The header key.</param>
|
||||
/// <param name="value">The header value.</param>
|
||||
public void Add(string key, string value)
|
||||
{
|
||||
if (!_headers.TryGetValue(key, out var values))
|
||||
{
|
||||
values = [];
|
||||
_headers[key] = values;
|
||||
}
|
||||
values.Add(value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets a header, replacing any existing values.
|
||||
/// </summary>
|
||||
/// <param name="key">The header key.</param>
|
||||
/// <param name="value">The header value.</param>
|
||||
public void Set(string key, string value)
|
||||
{
|
||||
_headers[key] = [value];
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<string> GetValues(string key)
|
||||
{
|
||||
return _headers.TryGetValue(key, out var values) ? values : [];
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryGetValue(string key, out string? value)
|
||||
{
|
||||
if (_headers.TryGetValue(key, out var values) && values.Count > 0)
|
||||
{
|
||||
value = values[0];
|
||||
return true;
|
||||
}
|
||||
value = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool ContainsKey(string key) => _headers.ContainsKey(key);
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerator<KeyValuePair<string, string>> GetEnumerator()
|
||||
{
|
||||
foreach (var kvp in _headers)
|
||||
{
|
||||
foreach (var value in kvp.Value)
|
||||
{
|
||||
yield return new KeyValuePair<string, string>(kvp.Key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
namespace StellaOps.Microservice;
|
||||
|
||||
/// <summary>
|
||||
/// Provides endpoint discovery functionality.
|
||||
/// </summary>
|
||||
public interface IEndpointDiscoveryProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Discovers all endpoints in the application.
|
||||
/// </summary>
|
||||
/// <returns>The discovered endpoints.</returns>
|
||||
IReadOnlyList<EndpointDescriptor> DiscoverEndpoints();
|
||||
}
|
||||
38
src/__Libraries/StellaOps.Microservice/IEndpointRegistry.cs
Normal file
38
src/__Libraries/StellaOps.Microservice/IEndpointRegistry.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
namespace StellaOps.Microservice;
|
||||
|
||||
/// <summary>
|
||||
/// Registry for looking up endpoint handlers by method and path.
|
||||
/// </summary>
|
||||
public interface IEndpointRegistry
|
||||
{
|
||||
/// <summary>
|
||||
/// Tries to find a matching endpoint for the given method and path.
|
||||
/// </summary>
|
||||
/// <param name="method">The HTTP method.</param>
|
||||
/// <param name="path">The request path.</param>
|
||||
/// <param name="match">The matching endpoint information if found.</param>
|
||||
/// <returns>True if a matching endpoint was found.</returns>
|
||||
bool TryMatch(string method, string path, out EndpointMatch? match);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all registered endpoints.
|
||||
/// </summary>
|
||||
/// <returns>All registered endpoint descriptors.</returns>
|
||||
IReadOnlyList<EndpointDescriptor> GetAllEndpoints();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a matched endpoint with extracted path parameters.
|
||||
/// </summary>
|
||||
public sealed class EndpointMatch
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the matched endpoint descriptor.
|
||||
/// </summary>
|
||||
public required EndpointDescriptor Endpoint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the path parameters extracted from the URL.
|
||||
/// </summary>
|
||||
public required IReadOnlyDictionary<string, string> PathParameters { get; init; }
|
||||
}
|
||||
36
src/__Libraries/StellaOps.Microservice/IHeaderCollection.cs
Normal file
36
src/__Libraries/StellaOps.Microservice/IHeaderCollection.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
namespace StellaOps.Microservice;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction for HTTP-style header collection.
|
||||
/// </summary>
|
||||
public interface IHeaderCollection : IEnumerable<KeyValuePair<string, string>>
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a header value by key.
|
||||
/// </summary>
|
||||
/// <param name="key">The header key (case-insensitive).</param>
|
||||
/// <returns>The header value, or null if not found.</returns>
|
||||
string? this[string key] { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets all values for a header key.
|
||||
/// </summary>
|
||||
/// <param name="key">The header key (case-insensitive).</param>
|
||||
/// <returns>All values for the key.</returns>
|
||||
IEnumerable<string> GetValues(string key);
|
||||
|
||||
/// <summary>
|
||||
/// Tries to get a header value.
|
||||
/// </summary>
|
||||
/// <param name="key">The header key.</param>
|
||||
/// <param name="value">The header value if found.</param>
|
||||
/// <returns>True if the header was found.</returns>
|
||||
bool TryGetValue(string key, out string? value);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a header exists.
|
||||
/// </summary>
|
||||
/// <param name="key">The header key.</param>
|
||||
/// <returns>True if the header exists.</returns>
|
||||
bool ContainsKey(string key);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
namespace StellaOps.Microservice;
|
||||
|
||||
/// <summary>
|
||||
/// Manages connections to router gateways.
|
||||
/// </summary>
|
||||
public interface IRouterConnectionManager
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the current connection states.
|
||||
/// </summary>
|
||||
IReadOnlyList<ConnectionState> Connections { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Starts the connection manager.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task StartAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Stops the connection manager.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task StopAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
52
src/__Libraries/StellaOps.Microservice/IStellaEndpoint.cs
Normal file
52
src/__Libraries/StellaOps.Microservice/IStellaEndpoint.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
namespace StellaOps.Microservice;
|
||||
|
||||
/// <summary>
|
||||
/// Marker interface for all Stella endpoints.
|
||||
/// </summary>
|
||||
public interface IStellaEndpoint
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for a typed Stella endpoint with request and response.
|
||||
/// </summary>
|
||||
/// <typeparam name="TRequest">The request type.</typeparam>
|
||||
/// <typeparam name="TResponse">The response type.</typeparam>
|
||||
public interface IStellaEndpoint<TRequest, TResponse> : IStellaEndpoint
|
||||
{
|
||||
/// <summary>
|
||||
/// Handles the request.
|
||||
/// </summary>
|
||||
/// <param name="request">The request.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The response.</returns>
|
||||
Task<TResponse> HandleAsync(TRequest request, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for a typed Stella endpoint with response only (no request body).
|
||||
/// </summary>
|
||||
/// <typeparam name="TResponse">The response type.</typeparam>
|
||||
public interface IStellaEndpoint<TResponse> : IStellaEndpoint
|
||||
{
|
||||
/// <summary>
|
||||
/// Handles the request.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The response.</returns>
|
||||
Task<TResponse> HandleAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for a raw Stella endpoint that handles requests with full context.
|
||||
/// </summary>
|
||||
public interface IRawStellaEndpoint : IStellaEndpoint
|
||||
{
|
||||
/// <summary>
|
||||
/// Handles the raw request with full context.
|
||||
/// </summary>
|
||||
/// <param name="context">The request context including headers, path parameters, and body stream.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The raw response including status code, headers, and body stream.</returns>
|
||||
Task<RawResponse> HandleAsync(RawRequestContext context, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Microservice;
|
||||
|
||||
/// <summary>
|
||||
/// Hosted service that manages the microservice lifecycle.
|
||||
/// </summary>
|
||||
public sealed class MicroserviceHostedService : IHostedService
|
||||
{
|
||||
private readonly IRouterConnectionManager _connectionManager;
|
||||
private readonly ILogger<MicroserviceHostedService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MicroserviceHostedService"/> class.
|
||||
/// </summary>
|
||||
public MicroserviceHostedService(
|
||||
IRouterConnectionManager connectionManager,
|
||||
ILogger<MicroserviceHostedService> logger)
|
||||
{
|
||||
_connectionManager = connectionManager;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Starting Stella microservice");
|
||||
await _connectionManager.StartAsync(cancellationToken);
|
||||
_logger.LogInformation("Stella microservice started");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Stopping Stella microservice");
|
||||
await _connectionManager.StopAsync(cancellationToken);
|
||||
_logger.LogInformation("Stella microservice stopped");
|
||||
}
|
||||
}
|
||||
85
src/__Libraries/StellaOps.Microservice/PathMatcher.cs
Normal file
85
src/__Libraries/StellaOps.Microservice/PathMatcher.cs
Normal file
@@ -0,0 +1,85 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Microservice;
|
||||
|
||||
/// <summary>
|
||||
/// Matches request paths against route templates.
|
||||
/// </summary>
|
||||
public sealed partial class PathMatcher
|
||||
{
|
||||
private readonly string _template;
|
||||
private readonly Regex _regex;
|
||||
private readonly string[] _parameterNames;
|
||||
private readonly bool _caseInsensitive;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the route template.
|
||||
/// </summary>
|
||||
public string Template => _template;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PathMatcher"/> class.
|
||||
/// </summary>
|
||||
/// <param name="template">The route template (e.g., "/api/users/{id}").</param>
|
||||
/// <param name="caseInsensitive">Whether matching should be case-insensitive.</param>
|
||||
public PathMatcher(string template, bool caseInsensitive = true)
|
||||
{
|
||||
_template = template;
|
||||
_caseInsensitive = caseInsensitive;
|
||||
|
||||
// Extract parameter names and build regex
|
||||
var paramNames = new List<string>();
|
||||
var pattern = "^" + ParameterRegex().Replace(template, match =>
|
||||
{
|
||||
paramNames.Add(match.Groups[1].Value);
|
||||
return "([^/]+)";
|
||||
}) + "/?$";
|
||||
|
||||
var options = caseInsensitive ? RegexOptions.IgnoreCase : RegexOptions.None;
|
||||
_regex = new Regex(pattern, options | RegexOptions.Compiled);
|
||||
_parameterNames = [.. paramNames];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to match a path against the template.
|
||||
/// </summary>
|
||||
/// <param name="path">The request path.</param>
|
||||
/// <param name="parameters">The extracted path parameters if matched.</param>
|
||||
/// <returns>True if the path matches.</returns>
|
||||
public bool TryMatch(string path, out Dictionary<string, string> parameters)
|
||||
{
|
||||
parameters = [];
|
||||
|
||||
// Normalize path
|
||||
path = path.TrimEnd('/');
|
||||
if (!path.StartsWith('/'))
|
||||
path = "/" + path;
|
||||
|
||||
var match = _regex.Match(path);
|
||||
if (!match.Success)
|
||||
return false;
|
||||
|
||||
for (int i = 0; i < _parameterNames.Length; i++)
|
||||
{
|
||||
parameters[_parameterNames[i]] = match.Groups[i + 1].Value;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a path matches the template.
|
||||
/// </summary>
|
||||
/// <param name="path">The request path.</param>
|
||||
/// <returns>True if the path matches.</returns>
|
||||
public bool IsMatch(string path)
|
||||
{
|
||||
path = path.TrimEnd('/');
|
||||
if (!path.StartsWith('/'))
|
||||
path = "/" + path;
|
||||
return _regex.IsMatch(path);
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\{([^}:]+)(?::[^}]+)?\}")]
|
||||
private static partial Regex ParameterRegex();
|
||||
}
|
||||
43
src/__Libraries/StellaOps.Microservice/RawRequestContext.cs
Normal file
43
src/__Libraries/StellaOps.Microservice/RawRequestContext.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
namespace StellaOps.Microservice;
|
||||
|
||||
/// <summary>
|
||||
/// Context for a raw request.
|
||||
/// </summary>
|
||||
public sealed class RawRequestContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the HTTP method.
|
||||
/// </summary>
|
||||
public string Method { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the request path.
|
||||
/// </summary>
|
||||
public string Path { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the path parameters extracted from route templates.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> PathParameters { get; init; }
|
||||
= new Dictionary<string, string>();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the request headers.
|
||||
/// </summary>
|
||||
public IHeaderCollection Headers { get; init; } = HeaderCollection.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the request body stream.
|
||||
/// </summary>
|
||||
public Stream Body { get; init; } = Stream.Null;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the cancellation token.
|
||||
/// </summary>
|
||||
public CancellationToken CancellationToken { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the correlation ID for request tracking.
|
||||
/// </summary>
|
||||
public string? CorrelationId { get; init; }
|
||||
}
|
||||
77
src/__Libraries/StellaOps.Microservice/RawResponse.cs
Normal file
77
src/__Libraries/StellaOps.Microservice/RawResponse.cs
Normal file
@@ -0,0 +1,77 @@
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Microservice;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a raw response from an endpoint.
|
||||
/// </summary>
|
||||
public sealed class RawResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the HTTP status code.
|
||||
/// </summary>
|
||||
public int StatusCode { get; init; } = 200;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the response headers.
|
||||
/// </summary>
|
||||
public IHeaderCollection Headers { get; init; } = HeaderCollection.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the response body stream.
|
||||
/// </summary>
|
||||
public Stream Body { get; init; } = Stream.Null;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a 200 OK response with a body.
|
||||
/// </summary>
|
||||
public static RawResponse Ok(Stream body) => new() { StatusCode = 200, Body = body };
|
||||
|
||||
/// <summary>
|
||||
/// Creates a 200 OK response with a byte array body.
|
||||
/// </summary>
|
||||
public static RawResponse Ok(byte[] body) => new() { StatusCode = 200, Body = new MemoryStream(body) };
|
||||
|
||||
/// <summary>
|
||||
/// Creates a 200 OK response with a string body.
|
||||
/// </summary>
|
||||
public static RawResponse Ok(string body) => Ok(Encoding.UTF8.GetBytes(body));
|
||||
|
||||
/// <summary>
|
||||
/// Creates a 204 No Content response.
|
||||
/// </summary>
|
||||
public static RawResponse NoContent() => new() { StatusCode = 204 };
|
||||
|
||||
/// <summary>
|
||||
/// Creates a 400 Bad Request response.
|
||||
/// </summary>
|
||||
public static RawResponse BadRequest(string? message = null) =>
|
||||
Error(400, message ?? "Bad Request");
|
||||
|
||||
/// <summary>
|
||||
/// Creates a 404 Not Found response.
|
||||
/// </summary>
|
||||
public static RawResponse NotFound(string? message = null) =>
|
||||
Error(404, message ?? "Not Found");
|
||||
|
||||
/// <summary>
|
||||
/// Creates a 500 Internal Server Error response.
|
||||
/// </summary>
|
||||
public static RawResponse InternalError(string? message = null) =>
|
||||
Error(500, message ?? "Internal Server Error");
|
||||
|
||||
/// <summary>
|
||||
/// Creates an error response with a message body.
|
||||
/// </summary>
|
||||
public static RawResponse Error(int statusCode, string message)
|
||||
{
|
||||
var headers = new HeaderCollection();
|
||||
headers.Set("Content-Type", "text/plain; charset=utf-8");
|
||||
return new RawResponse
|
||||
{
|
||||
StatusCode = statusCode,
|
||||
Headers = headers,
|
||||
Body = new MemoryStream(Encoding.UTF8.GetBytes(message))
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using System.Reflection;
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
namespace StellaOps.Microservice;
|
||||
|
||||
/// <summary>
|
||||
/// Discovers endpoints using runtime reflection.
|
||||
/// </summary>
|
||||
public sealed class ReflectionEndpointDiscoveryProvider : IEndpointDiscoveryProvider
|
||||
{
|
||||
private readonly StellaMicroserviceOptions _options;
|
||||
private readonly IEnumerable<Assembly> _assemblies;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ReflectionEndpointDiscoveryProvider"/> class.
|
||||
/// </summary>
|
||||
/// <param name="options">The microservice options.</param>
|
||||
/// <param name="assemblies">The assemblies to scan for endpoints.</param>
|
||||
public ReflectionEndpointDiscoveryProvider(StellaMicroserviceOptions options, IEnumerable<Assembly>? assemblies = null)
|
||||
{
|
||||
_options = options;
|
||||
_assemblies = assemblies ?? AppDomain.CurrentDomain.GetAssemblies();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<EndpointDescriptor> DiscoverEndpoints()
|
||||
{
|
||||
var endpoints = new List<EndpointDescriptor>();
|
||||
|
||||
foreach (var assembly in _assemblies)
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var type in assembly.GetTypes())
|
||||
{
|
||||
var attribute = type.GetCustomAttribute<StellaEndpointAttribute>();
|
||||
if (attribute is null) continue;
|
||||
|
||||
if (!typeof(IStellaEndpoint).IsAssignableFrom(type))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Type {type.FullName} has [StellaEndpoint] but does not implement IStellaEndpoint.");
|
||||
}
|
||||
|
||||
var claims = attribute.RequiredClaims
|
||||
.Select(c => new ClaimRequirement { Type = c })
|
||||
.ToList();
|
||||
|
||||
var descriptor = new EndpointDescriptor
|
||||
{
|
||||
ServiceName = _options.ServiceName,
|
||||
Version = _options.Version,
|
||||
Method = attribute.Method,
|
||||
Path = attribute.Path,
|
||||
DefaultTimeout = TimeSpan.FromSeconds(attribute.TimeoutSeconds),
|
||||
SupportsStreaming = attribute.SupportsStreaming,
|
||||
RequiringClaims = claims
|
||||
};
|
||||
|
||||
endpoints.Add(descriptor);
|
||||
}
|
||||
}
|
||||
catch (ReflectionTypeLoadException)
|
||||
{
|
||||
// Skip assemblies that cannot be loaded
|
||||
}
|
||||
}
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
namespace StellaOps.Microservice;
|
||||
|
||||
/// <summary>
|
||||
/// Manages connections to router gateways.
|
||||
/// </summary>
|
||||
public sealed class RouterConnectionManager : IRouterConnectionManager, IDisposable
|
||||
{
|
||||
private readonly StellaMicroserviceOptions _options;
|
||||
private readonly IEndpointDiscoveryProvider _endpointDiscovery;
|
||||
private readonly ITransportClient _transportClient;
|
||||
private readonly ILogger<RouterConnectionManager> _logger;
|
||||
private readonly ConcurrentDictionary<string, ConnectionState> _connections = new();
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private IReadOnlyList<EndpointDescriptor>? _endpoints;
|
||||
private Task? _heartbeatTask;
|
||||
private bool _disposed;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<ConnectionState> Connections => [.. _connections.Values];
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RouterConnectionManager"/> class.
|
||||
/// </summary>
|
||||
public RouterConnectionManager(
|
||||
IOptions<StellaMicroserviceOptions> options,
|
||||
IEndpointDiscoveryProvider endpointDiscovery,
|
||||
ITransportClient transportClient,
|
||||
ILogger<RouterConnectionManager> logger)
|
||||
{
|
||||
_options = options.Value;
|
||||
_endpointDiscovery = endpointDiscovery;
|
||||
_transportClient = transportClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
_options.Validate();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Starting router connection manager for {ServiceName}/{Version}",
|
||||
_options.ServiceName,
|
||||
_options.Version);
|
||||
|
||||
// Discover endpoints
|
||||
_endpoints = _endpointDiscovery.DiscoverEndpoints();
|
||||
_logger.LogInformation("Discovered {EndpointCount} endpoints", _endpoints.Count);
|
||||
|
||||
// Connect to each router
|
||||
foreach (var router in _options.Routers)
|
||||
{
|
||||
await ConnectToRouterAsync(router, cancellationToken);
|
||||
}
|
||||
|
||||
// Start heartbeat task
|
||||
_heartbeatTask = Task.Run(() => HeartbeatLoopAsync(_cts.Token), CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Stopping router connection manager");
|
||||
|
||||
await _cts.CancelAsync();
|
||||
|
||||
if (_heartbeatTask is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _heartbeatTask.WaitAsync(cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected
|
||||
}
|
||||
}
|
||||
|
||||
_connections.Clear();
|
||||
}
|
||||
|
||||
private async Task ConnectToRouterAsync(RouterEndpointConfig router, CancellationToken cancellationToken)
|
||||
{
|
||||
var connectionId = $"{router.Host}:{router.Port}";
|
||||
var backoff = _options.ReconnectBackoffInitial;
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Connecting to router at {Host}:{Port} via {Transport}",
|
||||
router.Host,
|
||||
router.Port,
|
||||
router.TransportType);
|
||||
|
||||
// Create connection state
|
||||
var instance = new InstanceDescriptor
|
||||
{
|
||||
InstanceId = _options.InstanceId,
|
||||
ServiceName = _options.ServiceName,
|
||||
Version = _options.Version,
|
||||
Region = _options.Region
|
||||
};
|
||||
|
||||
var state = new ConnectionState
|
||||
{
|
||||
ConnectionId = connectionId,
|
||||
Instance = instance,
|
||||
Status = InstanceHealthStatus.Healthy,
|
||||
LastHeartbeatUtc = DateTime.UtcNow,
|
||||
TransportType = router.TransportType
|
||||
};
|
||||
|
||||
// Register endpoints
|
||||
foreach (var endpoint in _endpoints ?? [])
|
||||
{
|
||||
state.Endpoints[(endpoint.Method, endpoint.Path)] = endpoint;
|
||||
}
|
||||
|
||||
_connections[connectionId] = state;
|
||||
|
||||
// For InMemory transport, connectivity is handled via the transport client
|
||||
// Real transports will establish actual network connections here
|
||||
|
||||
_logger.LogInformation(
|
||||
"Connected to router at {Host}:{Port}, registered {EndpointCount} endpoints",
|
||||
router.Host,
|
||||
router.Port,
|
||||
_endpoints?.Count ?? 0);
|
||||
|
||||
// Reset backoff on successful connection
|
||||
backoff = _options.ReconnectBackoffInitial;
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Failed to connect to router at {Host}:{Port}, retrying in {Backoff}",
|
||||
router.Host,
|
||||
router.Port,
|
||||
backoff);
|
||||
|
||||
await Task.Delay(backoff, cancellationToken);
|
||||
|
||||
// Exponential backoff
|
||||
backoff = TimeSpan.FromTicks(Math.Min(
|
||||
backoff.Ticks * 2,
|
||||
_options.ReconnectBackoffMax.Ticks));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HeartbeatLoopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(_options.HeartbeatInterval, cancellationToken);
|
||||
|
||||
foreach (var connection in _connections.Values)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Build heartbeat payload
|
||||
var heartbeat = new HeartbeatPayload
|
||||
{
|
||||
InstanceId = _options.InstanceId,
|
||||
Status = connection.Status,
|
||||
TimestampUtc = DateTime.UtcNow
|
||||
};
|
||||
|
||||
// Update last heartbeat time
|
||||
connection.LastHeartbeatUtc = DateTime.UtcNow;
|
||||
|
||||
_logger.LogDebug(
|
||||
"Sent heartbeat for connection {ConnectionId}",
|
||||
connection.ConnectionId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Failed to send heartbeat for connection {ConnectionId}",
|
||||
connection.ConnectionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected on shutdown
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Unexpected error in heartbeat loop");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
_cts.Cancel();
|
||||
_cts.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using StellaOps.Router.Common;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
|
||||
namespace StellaOps.Microservice;
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace StellaOps.Microservice;
|
||||
|
||||
@@ -20,9 +22,53 @@ public static class ServiceCollectionExtensions
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configure);
|
||||
|
||||
// Stub implementation - will be filled in later sprints
|
||||
// Configure options
|
||||
services.Configure(configure);
|
||||
|
||||
// Register endpoint discovery
|
||||
services.TryAddSingleton<IEndpointDiscoveryProvider>(sp =>
|
||||
{
|
||||
var options = new StellaMicroserviceOptions { ServiceName = "", Version = "1.0.0", Region = "" };
|
||||
configure(options);
|
||||
return new ReflectionEndpointDiscoveryProvider(options);
|
||||
});
|
||||
|
||||
// Register connection manager
|
||||
services.TryAddSingleton<IRouterConnectionManager, RouterConnectionManager>();
|
||||
|
||||
// Register hosted service
|
||||
services.AddHostedService<MicroserviceHostedService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds Stella microservice services with a custom endpoint discovery provider.
|
||||
/// </summary>
|
||||
/// <typeparam name="TDiscovery">The endpoint discovery provider type.</typeparam>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configure">Action to configure the microservice options.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddStellaMicroservice<TDiscovery>(
|
||||
this IServiceCollection services,
|
||||
Action<StellaMicroserviceOptions> configure)
|
||||
where TDiscovery : class, IEndpointDiscoveryProvider
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configure);
|
||||
|
||||
// Configure options
|
||||
services.Configure(configure);
|
||||
|
||||
// Register custom endpoint discovery
|
||||
services.TryAddSingleton<IEndpointDiscoveryProvider, TDiscovery>();
|
||||
|
||||
// Register connection manager
|
||||
services.TryAddSingleton<IRouterConnectionManager, RouterConnectionManager>();
|
||||
|
||||
// Register hosted service
|
||||
services.AddHostedService<MicroserviceHostedService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
namespace StellaOps.Microservice;
|
||||
|
||||
/// <summary>
|
||||
/// Marks a class as a Stella endpoint handler.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
|
||||
public sealed class StellaEndpointAttribute : Attribute
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the HTTP method for this endpoint.
|
||||
/// </summary>
|
||||
public string Method { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the path for this endpoint.
|
||||
/// </summary>
|
||||
public string Path { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether this endpoint supports streaming.
|
||||
/// Default: false.
|
||||
/// </summary>
|
||||
public bool SupportsStreaming { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the default timeout in seconds.
|
||||
/// Default: 30 seconds.
|
||||
/// </summary>
|
||||
public int TimeoutSeconds { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the required claim types for this endpoint.
|
||||
/// </summary>
|
||||
public string[] RequiredClaims { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="StellaEndpointAttribute"/> class.
|
||||
/// </summary>
|
||||
/// <param name="method">The HTTP method.</param>
|
||||
/// <param name="path">The endpoint path.</param>
|
||||
public StellaEndpointAttribute(string method, string path)
|
||||
{
|
||||
Method = method?.ToUpperInvariant() ?? throw new ArgumentNullException(nameof(method));
|
||||
Path = path ?? throw new ArgumentNullException(nameof(path));
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
using StellaOps.Router.Common;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Microservice;
|
||||
|
||||
/// <summary>
|
||||
/// Options for configuring a Stella microservice.
|
||||
/// </summary>
|
||||
public sealed class StellaMicroserviceOptions
|
||||
public sealed partial class StellaMicroserviceOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the service name.
|
||||
@@ -14,6 +14,7 @@ public sealed class StellaMicroserviceOptions
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the semantic version.
|
||||
/// Must be valid semver (e.g., "1.0.0", "2.1.0-beta.1").
|
||||
/// </summary>
|
||||
public required string Version { get; set; }
|
||||
|
||||
@@ -24,6 +25,7 @@ public sealed class StellaMicroserviceOptions
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the unique instance identifier.
|
||||
/// Auto-generated if not provided.
|
||||
/// </summary>
|
||||
public string InstanceId { get; set; } = Guid.NewGuid().ToString("N");
|
||||
|
||||
@@ -36,5 +38,55 @@ public sealed class StellaMicroserviceOptions
|
||||
/// <summary>
|
||||
/// Gets or sets the optional path to a YAML config file for endpoint overrides.
|
||||
/// </summary>
|
||||
public string? EndpointConfigPath { get; set; }
|
||||
public string? ConfigFilePath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the heartbeat interval.
|
||||
/// Default: 10 seconds.
|
||||
/// </summary>
|
||||
public TimeSpan HeartbeatInterval { get; set; } = TimeSpan.FromSeconds(10);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum reconnect backoff.
|
||||
/// Default: 1 minute.
|
||||
/// </summary>
|
||||
public TimeSpan ReconnectBackoffMax { get; set; } = TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the initial reconnect delay.
|
||||
/// Default: 1 second.
|
||||
/// </summary>
|
||||
public TimeSpan ReconnectBackoffInitial { get; set; } = TimeSpan.FromSeconds(1);
|
||||
|
||||
/// <summary>
|
||||
/// Validates the options and throws if invalid.
|
||||
/// </summary>
|
||||
public void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ServiceName))
|
||||
throw new InvalidOperationException("ServiceName is required.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Version))
|
||||
throw new InvalidOperationException("Version is required.");
|
||||
|
||||
if (!SemverRegex().IsMatch(Version))
|
||||
throw new InvalidOperationException($"Version '{Version}' is not valid semver.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Region))
|
||||
throw new InvalidOperationException("Region is required.");
|
||||
|
||||
if (Routers.Count == 0)
|
||||
throw new InvalidOperationException("At least one router endpoint is required.");
|
||||
|
||||
foreach (var router in Routers)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(router.Host))
|
||||
throw new InvalidOperationException("Router host is required.");
|
||||
if (router.Port <= 0 || router.Port > 65535)
|
||||
throw new InvalidOperationException($"Router port {router.Port} is invalid.");
|
||||
}
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$")]
|
||||
private static partial Regex SemverRegex();
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0-rc.2.25502.107" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
|
||||
Reference in New Issue
Block a user